Skip to content

Commit 0d59c09

Browse files
Merge pull request #137 from CodeForPhilly/develop
Release: v0.1.0
2 parents eac2394 + ca3bcb3 commit 0d59c09

50 files changed

Lines changed: 2626 additions & 146 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/api/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"bcryptjs": "^3.0.3",
3636
"better-sqlite3": "^12.10.0",
3737
"fastify": "^5.8.5",
38-
"gitsheets": "^1.3.1",
38+
"gitsheets": "^1.4.1",
3939
"jose": "^6.2.3",
4040
"resend": "^6.12.4",
4141
"samlify": "^2.13.0",

‎apps/api/scripts/import-laddr/importer.ts‎

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import type {
5858
} from '@cfp/shared/schemas';
5959

6060
import { openPublicStore, type PublicStore } from '../../src/store/public.js';
61+
import { processAvatar } from '../../src/lib/avatar.js';
6162
import {
6263
fetchAllPages,
6364
RawBlogPostSchema,
@@ -235,6 +236,9 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
235236
log(`[import] fetching people from ${opts.sourceHost} (this is the large one)`);
236237
const people: Person[] = [];
237238
const tagAssignments: TagAssignment[] = [];
239+
// slug → laddr PrimaryPhotoID, for people who have a profile photo. Their
240+
// avatars are fetched from `/media/<id>` and stored as gitsheets attachments.
241+
const photoIdBySlug = new Map<string, number>();
238242
for await (const row of fetchAllPages<RawPerson>(
239243
'/people',
240244
RawPersonSchema,
@@ -253,6 +257,9 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
253257
if (parsed) {
254258
people.push(parsed);
255259
counts.people!.imported++;
260+
if (typeof row.PrimaryPhotoID === 'number') {
261+
photoIdBySlug.set(parsed.slug, row.PrimaryPhotoID);
262+
}
256263
for (const rawTag of row.Tags ?? []) {
257264
const ta = translateTagAssignment(rawTag, row.ID, 'person', ctx);
258265
if (ta === null) {
@@ -460,6 +467,16 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
460467
warnings,
461468
);
462469

470+
// Fetch + process person avatars from laddr (`/media/<PrimaryPhotoID>`) into
471+
// square original + 128px thumbnail buffers, keyed by person slug.
472+
const avatarsBySlug = await fetchAndMaterializePersonAvatars(
473+
photoIdBySlug,
474+
opts.sourceHost,
475+
fetchOpts,
476+
log,
477+
warnings,
478+
);
479+
463480
// -------------------------------------------------------------------------
464481
// 3. One atomic gitsheets transaction:
465482
// - clear() each importer-owned sheet (deletes capture for free)
@@ -480,13 +497,33 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
480497
},
481498
},
482499
async (tx) => {
500+
if (publicRepo === null) {
501+
throw new Error('[import-laddr] internal: publicRepo not opened');
502+
}
503+
const hologit = publicRepo.hologitRepo;
504+
483505
log(`[import] clear + upsert tags (${tags.length})`);
484506
await tx.tags.clear();
485507
for (const t of tags) await tx.tags.upsert(t);
486508

487-
log(`[import] clear + upsert people (${people.length})`);
509+
log(`[import] clear + upsert people (${people.length}, avatars: ${avatarsBySlug.size})`);
488510
await tx.people.clear();
489-
for (const p of people) await tx.people.upsert(p);
511+
for (const p of people) {
512+
const avatar = avatarsBySlug.get(p.slug);
513+
if (avatar) {
514+
// Mirror POST /api/people/:slug/avatar: store original + 128 thumb
515+
// as attachments and point avatarKey at the conventional path.
516+
const originalBlob = await BlobObject.write(hologit, avatar.original as unknown as string);
517+
const thumbnailBlob = await BlobObject.write(hologit, avatar.thumbnail as unknown as string);
518+
await tx.people.setAttachments(p, {
519+
'avatar.jpg': originalBlob,
520+
'avatar-128.jpg': thumbnailBlob,
521+
});
522+
await tx.people.upsert({ ...p, avatarKey: `people/${p.slug}/avatar.jpg` });
523+
} else {
524+
await tx.people.upsert(p);
525+
}
526+
}
490527

491528
log(`[import] clear + upsert projects (${projects.length})`);
492529
await tx.projects.clear();
@@ -540,10 +577,6 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
540577
`[import] clear + upsert blog-posts (${blogTranslations.length}) + media attachments`,
541578
);
542579
await tx['blog-posts'].clear();
543-
if (publicRepo === null) {
544-
throw new Error('[import-laddr] internal: publicRepo not opened');
545-
}
546-
const hologit = publicRepo.hologitRepo;
547580
for (const { record } of blogTranslations) {
548581
const artifacts = mediaArtifactsBySlug.get(record.slug) ?? [];
549582
if (artifacts.length > 0) {
@@ -839,6 +872,61 @@ async function fetchMediaBytes(
839872
}
840873
}
841874

875+
/**
876+
* Fetch each person's laddr photo (`/media/<PrimaryPhotoID>`) and process it
877+
* into a square original + 128px thumbnail (the same outputs the avatar-upload
878+
* route produces). Returns a map of person slug → buffers for the transact
879+
* callback to wire in via setAttachments. Failed fetches/decodes are skipped
880+
* with a warning — the person still imports, just without an avatar.
881+
*
882+
* Concurrency 4, matching the blog-media fetcher's politeness compromise.
883+
*/
884+
async function fetchAndMaterializePersonAvatars(
885+
photoIdBySlug: Map<string, number>,
886+
sourceHost: string,
887+
fetchOpts: FetchOptions,
888+
log: (msg: string) => void,
889+
warnings: Warnings,
890+
): Promise<Map<string, { original: Buffer; thumbnail: Buffer }>> {
891+
const fetchImpl = fetchOpts.fetchImpl ?? fetch;
892+
const userAgent = fetchOpts.userAgent ?? 'cfp-importer/dev';
893+
const entries = [...photoIdBySlug.entries()];
894+
const out = new Map<string, { original: Buffer; thumbnail: Buffer }>();
895+
if (entries.length === 0) return out;
896+
897+
log(`[import] fetching ${entries.length} person avatars`);
898+
899+
const CONCURRENCY = 4;
900+
let cursor = 0;
901+
const workers: Promise<void>[] = [];
902+
for (let w = 0; w < CONCURRENCY; w++) {
903+
workers.push(
904+
(async () => {
905+
while (true) {
906+
const idx = cursor++;
907+
if (idx >= entries.length) return;
908+
const [slug, photoId] = entries[idx]!;
909+
const url = `https://${sourceHost}/media/${photoId}`;
910+
const fetched = await fetchMediaBytes(url, fetchImpl, userAgent);
911+
if (fetched === null) {
912+
warnings.push(`[people] avatar fetch failed: ${url} (/${slug})`);
913+
continue;
914+
}
915+
try {
916+
const processed = await processAvatar(fetched.bytes);
917+
out.set(slug, { original: processed.original, thumbnail: processed.thumbnail });
918+
} catch (err) {
919+
warnings.push(`[people] avatar decode failed for /${slug} (${url}): ${describe(err)}`);
920+
}
921+
}
922+
})(),
923+
);
924+
}
925+
await Promise.all(workers);
926+
log(`[import] processed ${out.size}/${entries.length} person avatars`);
927+
return out;
928+
}
929+
842930
/**
843931
* Fetch every distinct media asset referenced across all blog posts,
844932
* derive the final filename per asset, then rewrite each post's body to

‎apps/api/scripts/import-laddr/json-fetcher.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export const RawPersonSchema = z
7575
AccountLevel: z.string().nullable().optional(),
7676
Newsletter: z.union([z.boolean(), z.number(), z.string()]).nullable().optional(),
7777
Twitter: z.string().nullable().optional(),
78+
/** Emergence Media ID of the person's photo; fetch at `/media/<id>`. */
79+
PrimaryPhotoID: z.number().int().nullable().optional(),
7880
Created: z.number().int().nullable().optional(),
7981
Modified: z.number().int().nullable().optional(),
8082
/** Present when `?include=Tags` */

‎apps/api/src/routes/people.ts‎

Lines changed: 130 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
* GET /api/people
44
* GET /api/people/:slug
55
* PATCH /api/people/:slug
6-
* DELETE /api/people/:slug
6+
* POST /api/people/:slug/deactivate
7+
* POST /api/people/:slug/reactivate
8+
* POST /api/people/:slug/purge
9+
* POST /api/people/:slug/account-level (administrator)
710
* PATCH /api/people/:slug/newsletter (private-only mutation)
811
*/
912
import type { FastifyInstance } from 'fastify';
@@ -119,7 +122,31 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
119122
tags: ['people'],
120123
summary: 'Update profile',
121124
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
122-
body: { type: 'object' },
125+
// Enumerate the editable fields and reject anything else, so privileged
126+
// fields (e.g. accountLevel — see POST /api/people/:slug/account-level)
127+
// can't be smuggled through the generic profile PATCH. Per
128+
// specs/api/people.md → "PATCH /api/people/:slug".
129+
body: {
130+
type: 'object',
131+
additionalProperties: false,
132+
properties: {
133+
fullName: { type: 'string' },
134+
firstName: { type: ['string', 'null'] },
135+
lastName: { type: ['string', 'null'] },
136+
bio: { type: ['string', 'null'] },
137+
slug: { type: 'string' },
138+
email: { type: 'string' },
139+
slackHandle: { type: ['string', 'null'] },
140+
tags: {
141+
type: 'object',
142+
additionalProperties: false,
143+
properties: {
144+
topic: { type: 'array', items: { type: 'string' } },
145+
tech: { type: 'array', items: { type: 'string' } },
146+
},
147+
},
148+
},
149+
},
123150
},
124151
}, async (request) => {
125152
const { slug } = request.params as { slug: string };
@@ -139,29 +166,125 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
139166
return ok(await fastify.services.people.get(result.value.person.slug, caller));
140167
});
141168

142-
// DELETE /api/people/:slug (admin-only soft-delete)
143-
fastify.delete('/api/people/:slug', {
169+
// POST /api/people/:slug/deactivate (self | staff)
170+
// Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md
171+
fastify.post('/api/people/:slug/deactivate', {
144172
schema: {
145173
tags: ['people'],
146-
summary: 'Soft-delete a person (admin only)',
174+
summary: 'Deactivate a person account (self or staff)',
175+
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
176+
},
177+
}, async (request) => {
178+
const { slug } = request.params as { slug: string };
179+
const result = await fastify.store.transact(
180+
buildTransactionOptions({
181+
request,
182+
action: 'person.deactivate',
183+
subjectType: 'person',
184+
subjectSlug: slug,
185+
responseCode: 200,
186+
}),
187+
async (tx) => fastify.services.peopleWrite.deactivate(tx, slug, request.session),
188+
);
189+
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
190+
const caller = getCallerSession(request);
191+
return ok(await fastify.services.people.get(result.value.person.slug, caller));
192+
});
193+
194+
// POST /api/people/:slug/reactivate (self | staff)
195+
// Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md
196+
fastify.post('/api/people/:slug/reactivate', {
197+
schema: {
198+
tags: ['people'],
199+
summary: 'Reactivate a deactivated person account (self or staff)',
200+
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
201+
},
202+
}, async (request) => {
203+
const { slug } = request.params as { slug: string };
204+
const result = await fastify.store.transact(
205+
buildTransactionOptions({
206+
request,
207+
action: 'person.reactivate',
208+
subjectType: 'person',
209+
subjectSlug: slug,
210+
responseCode: 200,
211+
}),
212+
async (tx) => fastify.services.peopleWrite.reactivate(tx, slug, request.session),
213+
);
214+
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
215+
const caller = getCallerSession(request);
216+
return ok(await fastify.services.people.get(result.value.person.slug, caller));
217+
});
218+
219+
// POST /api/people/:slug/purge (administrator only)
220+
// Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md
221+
fastify.post('/api/people/:slug/purge', {
222+
schema: {
223+
tags: ['people'],
224+
summary: 'Purge a person and all their content (admin only)',
147225
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
148226
},
149227
}, async (request, reply) => {
150228
const { slug } = request.params as { slug: string };
151229
const result = await fastify.store.transact(
152230
buildTransactionOptions({
153231
request,
154-
action: 'person.soft-delete',
232+
action: 'person.purge',
155233
subjectType: 'person',
156234
subjectSlug: slug,
157235
responseCode: 204,
158236
}),
159-
async (tx) => fastify.services.peopleWrite.softDelete(tx, slug, request.session),
237+
async (tx) => fastify.services.peopleWrite.purge(tx, slug, request.session),
160238
);
161239
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
162240
return reply.code(204).send();
163241
});
164242

243+
// POST /api/people/:slug/account-level (administrator only)
244+
// Spec: specs/api/people.md → POST /api/people/:slug/account-level
245+
fastify.post('/api/people/:slug/account-level', {
246+
schema: {
247+
tags: ['people'],
248+
summary: "Change a person's account level (admin only)",
249+
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
250+
body: {
251+
type: 'object',
252+
properties: { level: { type: 'string', enum: ['user', 'staff', 'administrator'] } },
253+
required: ['level'],
254+
additionalProperties: false,
255+
},
256+
},
257+
}, async (request) => {
258+
const { slug } = request.params as { slug: string };
259+
const { level } = request.body as { level: Person['accountLevel'] };
260+
261+
// Resolve the current level up-front so the audit trailers capture the
262+
// before/after. Reads in-memory state, which is current under the write
263+
// mutex; if the slug is unknown, setAccountLevel below 404s.
264+
const existingId = fastify.inMemoryState.personIdBySlug.get(slug);
265+
const previousLevel = existingId
266+
? fastify.inMemoryState.people.get(existingId)?.accountLevel
267+
: undefined;
268+
269+
const result = await fastify.store.transact(
270+
buildTransactionOptions({
271+
request,
272+
action: 'account-level.change',
273+
subjectType: 'person',
274+
subjectSlug: slug,
275+
responseCode: 200,
276+
extraTrailers: {
277+
'Previous-Account-Level': previousLevel ?? 'unknown',
278+
'New-Account-Level': level,
279+
},
280+
}),
281+
async (tx) => fastify.services.peopleWrite.setAccountLevel(tx, slug, level, request.session),
282+
);
283+
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
284+
const caller = getCallerSession(request);
285+
return ok(await fastify.services.people.get(result.value.person.slug, caller));
286+
});
287+
165288
// PATCH /api/people/:slug/newsletter (private-store only — no public commit)
166289
fastify.patch('/api/people/:slug/newsletter', {
167290
schema: {

‎apps/api/src/services/blog-post.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Per specs/api/blog.md. Writes happen via PR to the data repo, not the
55
* runtime — no mutation methods here.
66
*/
7-
import type { BlogPost } from '@cfp/shared/schemas';
7+
import type { BlogPost, Tag } from '@cfp/shared/schemas';
88
import type { InMemoryState } from '../store/memory/state.js';
99
import { serializeBlogPost, type BlogPostResponse } from './serializers/blog-post.js';
1010

@@ -66,6 +66,15 @@ export class BlogPostService {
6666

6767
#serialize(post: BlogPost): BlogPostResponse {
6868
const author = post.authorId ? (this.#state.people.get(post.authorId) ?? null) : null;
69-
return serializeBlogPost(post, { author });
69+
return serializeBlogPost(post, { author, tags: this.#tagsFor(post.id) });
70+
}
71+
72+
#tagsFor(postId: string): Tag[] {
73+
const taIds = this.#state.tagAssignmentsByTaggable.get(postId) ?? new Set<string>();
74+
return [...taIds]
75+
.map((taId) => this.#state.tagAssignments.get(taId))
76+
.filter((ta): ta is NonNullable<typeof ta> => ta?.taggableType === 'blog_post')
77+
.map((ta) => this.#state.tags.get(ta.tagId))
78+
.filter((t): t is Tag => t !== undefined);
7079
}
7180
}

‎apps/api/src/services/permissions.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export interface ProjectPermissions {
4343
export interface PersonPermissions {
4444
readonly canEdit: boolean;
4545
readonly canChangeAccountLevel: boolean;
46+
/** Self or staff: can deactivate/reactivate this account. */
47+
readonly canDeactivate: boolean;
48+
/** Admin only: can purge this person and all their content. */
49+
readonly canPurge: boolean;
4650
}
4751

4852
export interface UpdatePermissions {
@@ -111,10 +115,13 @@ export function computePersonPermissions(
111115
person: Person,
112116
): PersonPermissions {
113117
const staff = isStaff(caller);
118+
const admin = caller?.accountLevel === 'administrator';
114119
const isSelf = caller?.id === person.id;
115120
return {
116121
canEdit: isSelf || staff,
117-
canChangeAccountLevel: caller?.accountLevel === 'administrator',
122+
canChangeAccountLevel: admin,
123+
canDeactivate: isSelf || staff,
124+
canPurge: admin,
118125
};
119126
}
120127

0 commit comments

Comments
 (0)