Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5646dab
fix(web): enlarge header logo to fill the navbar
themightychris Jun 26, 2026
4a464c2
Merge pull request #136 from CodeForPhilly/fix/header-logo-fill
themightychris Jun 26, 2026
8321e21
feat(web): add Join/Leave project buttons (#113)
themightychris Jun 26, 2026
64a2715
Merge pull request #138 from CodeForPhilly/feat/project-detail-join-l…
themightychris Jun 27, 2026
402eebf
feat(web): add self "Manage account" link to PersonDetail (#113)
themightychris Jun 27, 2026
2b9a64f
Merge pull request #139 from CodeForPhilly/feat/screen-gaps-small-batch
themightychris Jun 27, 2026
344c30e
feat(web): soft-delete banner on ProjectDetail for staff (#113)
themightychris Jun 27, 2026
8566982
Merge pull request #140 from CodeForPhilly/feat/project-detail-banner…
themightychris Jun 27, 2026
d0ab542
feat(blog): excerpt fallback + tag chips (#113)
themightychris Jun 27, 2026
786d8f3
feat(web): consolidate ProjectDetail header actions into "More ▾" (#113)
themightychris Jun 27, 2026
254e385
Merge pull request #141 from CodeForPhilly/feat/blog-excerpt-tags
themightychris Jun 27, 2026
6243bf6
Merge pull request #142 from CodeForPhilly/feat/project-detail-more-d…
themightychris Jun 27, 2026
21eef00
chore(plans): open import-person-avatars
themightychris Jun 27, 2026
4b712c3
feat(import): import legacy person avatars (#130)
themightychris Jun 27, 2026
a603f3d
chore(plans): mark import-person-avatars done (PR #143)
themightychris Jun 27, 2026
3cd3149
docs(specs): person lifecycle — deactivate / reactivate / purge (#129)
themightychris Jun 27, 2026
dde3f9d
feat(person): deactivate / reactivate / purge (#129)
themightychris Jun 27, 2026
9441404
test(person): cover deactivate / reactivate / purge + placeholder (#129)
themightychris Jun 27, 2026
0491973
chore(plans): mark person-deactivate-purge done (PR #144)
themightychris Jun 27, 2026
6a0fc79
Merge pull request #143 from CodeForPhilly/feat/import-person-avatars
themightychris Jun 28, 2026
b8df4a6
Merge pull request #144 from CodeForPhilly/fix/person-lifecycle
themightychris Jun 28, 2026
906a3d1
chore(deps): bump gitsheets to 1.4.1
themightychris Jun 28, 2026
e97728c
Merge pull request #145 from CodeForPhilly/chore/bump-gitsheets-1.4.1
themightychris Jun 28, 2026
4f6525f
chore(deploy): lower memory limits after gitsheets 1.4.1 heap fix
themightychris Jun 28, 2026
3ee3c2b
Merge pull request #146 from CodeForPhilly/chore/lower-memory-limits
themightychris Jun 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"fastify": "^5.8.5",
"gitsheets": "^1.3.1",
"gitsheets": "^1.4.1",
"jose": "^6.2.3",
"resend": "^6.12.4",
"samlify": "^2.13.0",
Expand Down
100 changes: 94 additions & 6 deletions apps/api/scripts/import-laddr/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import type {
} from '@cfp/shared/schemas';

import { openPublicStore, type PublicStore } from '../../src/store/public.js';
import { processAvatar } from '../../src/lib/avatar.js';
import {
fetchAllPages,
RawBlogPostSchema,
Expand Down Expand Up @@ -235,6 +236,9 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
log(`[import] fetching people from ${opts.sourceHost} (this is the large one)`);
const people: Person[] = [];
const tagAssignments: TagAssignment[] = [];
// slug → laddr PrimaryPhotoID, for people who have a profile photo. Their
// avatars are fetched from `/media/<id>` and stored as gitsheets attachments.
const photoIdBySlug = new Map<string, number>();
for await (const row of fetchAllPages<RawPerson>(
'/people',
RawPersonSchema,
Expand All @@ -253,6 +257,9 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
if (parsed) {
people.push(parsed);
counts.people!.imported++;
if (typeof row.PrimaryPhotoID === 'number') {
photoIdBySlug.set(parsed.slug, row.PrimaryPhotoID);
}
for (const rawTag of row.Tags ?? []) {
const ta = translateTagAssignment(rawTag, row.ID, 'person', ctx);
if (ta === null) {
Expand Down Expand Up @@ -460,6 +467,16 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
warnings,
);

// Fetch + process person avatars from laddr (`/media/<PrimaryPhotoID>`) into
// square original + 128px thumbnail buffers, keyed by person slug.
const avatarsBySlug = await fetchAndMaterializePersonAvatars(
photoIdBySlug,
opts.sourceHost,
fetchOpts,
log,
warnings,
);

// -------------------------------------------------------------------------
// 3. One atomic gitsheets transaction:
// - clear() each importer-owned sheet (deletes capture for free)
Expand All @@ -480,13 +497,33 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
},
},
async (tx) => {
if (publicRepo === null) {
throw new Error('[import-laddr] internal: publicRepo not opened');
}
const hologit = publicRepo.hologitRepo;

log(`[import] clear + upsert tags (${tags.length})`);
await tx.tags.clear();
for (const t of tags) await tx.tags.upsert(t);

log(`[import] clear + upsert people (${people.length})`);
log(`[import] clear + upsert people (${people.length}, avatars: ${avatarsBySlug.size})`);
await tx.people.clear();
for (const p of people) await tx.people.upsert(p);
for (const p of people) {
const avatar = avatarsBySlug.get(p.slug);
if (avatar) {
// Mirror POST /api/people/:slug/avatar: store original + 128 thumb
// as attachments and point avatarKey at the conventional path.
const originalBlob = await BlobObject.write(hologit, avatar.original as unknown as string);
const thumbnailBlob = await BlobObject.write(hologit, avatar.thumbnail as unknown as string);
await tx.people.setAttachments(p, {
'avatar.jpg': originalBlob,
'avatar-128.jpg': thumbnailBlob,
});
await tx.people.upsert({ ...p, avatarKey: `people/${p.slug}/avatar.jpg` });
} else {
await tx.people.upsert(p);
}
}

log(`[import] clear + upsert projects (${projects.length})`);
await tx.projects.clear();
Expand Down Expand Up @@ -540,10 +577,6 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
`[import] clear + upsert blog-posts (${blogTranslations.length}) + media attachments`,
);
await tx['blog-posts'].clear();
if (publicRepo === null) {
throw new Error('[import-laddr] internal: publicRepo not opened');
}
const hologit = publicRepo.hologitRepo;
for (const { record } of blogTranslations) {
const artifacts = mediaArtifactsBySlug.get(record.slug) ?? [];
if (artifacts.length > 0) {
Expand Down Expand Up @@ -839,6 +872,61 @@ async function fetchMediaBytes(
}
}

/**
* Fetch each person's laddr photo (`/media/<PrimaryPhotoID>`) and process it
* into a square original + 128px thumbnail (the same outputs the avatar-upload
* route produces). Returns a map of person slug → buffers for the transact
* callback to wire in via setAttachments. Failed fetches/decodes are skipped
* with a warning — the person still imports, just without an avatar.
*
* Concurrency 4, matching the blog-media fetcher's politeness compromise.
*/
async function fetchAndMaterializePersonAvatars(
photoIdBySlug: Map<string, number>,
sourceHost: string,
fetchOpts: FetchOptions,
log: (msg: string) => void,
warnings: Warnings,
): Promise<Map<string, { original: Buffer; thumbnail: Buffer }>> {
const fetchImpl = fetchOpts.fetchImpl ?? fetch;
const userAgent = fetchOpts.userAgent ?? 'cfp-importer/dev';
const entries = [...photoIdBySlug.entries()];
const out = new Map<string, { original: Buffer; thumbnail: Buffer }>();
if (entries.length === 0) return out;

log(`[import] fetching ${entries.length} person avatars`);

const CONCURRENCY = 4;
let cursor = 0;
const workers: Promise<void>[] = [];
for (let w = 0; w < CONCURRENCY; w++) {
workers.push(
(async () => {
while (true) {
const idx = cursor++;
if (idx >= entries.length) return;
const [slug, photoId] = entries[idx]!;
const url = `https://${sourceHost}/media/${photoId}`;
const fetched = await fetchMediaBytes(url, fetchImpl, userAgent);
if (fetched === null) {
warnings.push(`[people] avatar fetch failed: ${url} (/${slug})`);
continue;
}
try {
const processed = await processAvatar(fetched.bytes);
out.set(slug, { original: processed.original, thumbnail: processed.thumbnail });
} catch (err) {
warnings.push(`[people] avatar decode failed for /${slug} (${url}): ${describe(err)}`);
}
}
})(),
);
}
await Promise.all(workers);
log(`[import] processed ${out.size}/${entries.length} person avatars`);
return out;
}

/**
* Fetch every distinct media asset referenced across all blog posts,
* derive the final filename per asset, then rewrite each post's body to
Expand Down
2 changes: 2 additions & 0 deletions apps/api/scripts/import-laddr/json-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export const RawPersonSchema = z
AccountLevel: z.string().nullable().optional(),
Newsletter: z.union([z.boolean(), z.number(), z.string()]).nullable().optional(),
Twitter: z.string().nullable().optional(),
/** Emergence Media ID of the person's photo; fetch at `/media/<id>`. */
PrimaryPhotoID: z.number().int().nullable().optional(),
Created: z.number().int().nullable().optional(),
Modified: z.number().int().nullable().optional(),
/** Present when `?include=Tags` */
Expand Down
82 changes: 79 additions & 3 deletions apps/api/src/routes/people.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
* GET /api/people
* GET /api/people/:slug
* PATCH /api/people/:slug
* DELETE /api/people/:slug
* POST /api/people/:slug/deactivate
* POST /api/people/:slug/reactivate
* POST /api/people/:slug/purge
* PATCH /api/people/:slug/newsletter (private-only mutation)
*/
import type { FastifyInstance } from 'fastify';
Expand Down Expand Up @@ -139,11 +141,11 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
return ok(await fastify.services.people.get(result.value.person.slug, caller));
});

// DELETE /api/people/:slug (admin-only soft-delete)
// DELETE /api/people/:slug (admin-only soft-delete — legacy, kept for backward compat)
fastify.delete('/api/people/:slug', {
schema: {
tags: ['people'],
summary: 'Soft-delete a person (admin only)',
summary: 'Soft-delete a person (admin only, legacy)',
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
},
}, async (request, reply) => {
Expand All @@ -162,6 +164,80 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
return reply.code(204).send();
});

// POST /api/people/:slug/deactivate (self | staff)
// Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md
fastify.post('/api/people/:slug/deactivate', {
schema: {
tags: ['people'],
summary: 'Deactivate a person account (self or staff)',
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
},
}, async (request) => {
const { slug } = request.params as { slug: string };
const result = await fastify.store.transact(
buildTransactionOptions({
request,
action: 'person.deactivate',
subjectType: 'person',
subjectSlug: slug,
responseCode: 200,
}),
async (tx) => fastify.services.peopleWrite.deactivate(tx, slug, request.session),
);
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
const caller = getCallerSession(request);
return ok(await fastify.services.people.get(result.value.person.slug, caller));
});

// POST /api/people/:slug/reactivate (self | staff)
// Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md
fastify.post('/api/people/:slug/reactivate', {
schema: {
tags: ['people'],
summary: 'Reactivate a deactivated person account (self or staff)',
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
},
}, async (request) => {
const { slug } = request.params as { slug: string };
const result = await fastify.store.transact(
buildTransactionOptions({
request,
action: 'person.reactivate',
subjectType: 'person',
subjectSlug: slug,
responseCode: 200,
}),
async (tx) => fastify.services.peopleWrite.reactivate(tx, slug, request.session),
);
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
const caller = getCallerSession(request);
return ok(await fastify.services.people.get(result.value.person.slug, caller));
});

// POST /api/people/:slug/purge (administrator only)
// Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md
fastify.post('/api/people/:slug/purge', {
schema: {
tags: ['people'],
summary: 'Purge a person and all their content (admin only)',
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
},
}, async (request, reply) => {
const { slug } = request.params as { slug: string };
const result = await fastify.store.transact(
buildTransactionOptions({
request,
action: 'person.purge',
subjectType: 'person',
subjectSlug: slug,
responseCode: 204,
}),
async (tx) => fastify.services.peopleWrite.purge(tx, slug, request.session),
);
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
return reply.code(204).send();
});

// PATCH /api/people/:slug/newsletter (private-store only — no public commit)
fastify.patch('/api/people/:slug/newsletter', {
schema: {
Expand Down
13 changes: 11 additions & 2 deletions apps/api/src/services/blog-post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Per specs/api/blog.md. Writes happen via PR to the data repo, not the
* runtime — no mutation methods here.
*/
import type { BlogPost } from '@cfp/shared/schemas';
import type { BlogPost, Tag } from '@cfp/shared/schemas';
import type { InMemoryState } from '../store/memory/state.js';
import { serializeBlogPost, type BlogPostResponse } from './serializers/blog-post.js';

Expand Down Expand Up @@ -66,6 +66,15 @@ export class BlogPostService {

#serialize(post: BlogPost): BlogPostResponse {
const author = post.authorId ? (this.#state.people.get(post.authorId) ?? null) : null;
return serializeBlogPost(post, { author });
return serializeBlogPost(post, { author, tags: this.#tagsFor(post.id) });
}

#tagsFor(postId: string): Tag[] {
const taIds = this.#state.tagAssignmentsByTaggable.get(postId) ?? new Set<string>();
return [...taIds]
.map((taId) => this.#state.tagAssignments.get(taId))
.filter((ta): ta is NonNullable<typeof ta> => ta?.taggableType === 'blog_post')
.map((ta) => this.#state.tags.get(ta.tagId))
.filter((t): t is Tag => t !== undefined);
}
}
9 changes: 8 additions & 1 deletion apps/api/src/services/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export interface ProjectPermissions {
export interface PersonPermissions {
readonly canEdit: boolean;
readonly canChangeAccountLevel: boolean;
/** Self or staff: can deactivate/reactivate this account. */
readonly canDeactivate: boolean;
/** Admin only: can purge this person and all their content. */
readonly canPurge: boolean;
}

export interface UpdatePermissions {
Expand Down Expand Up @@ -111,10 +115,13 @@ export function computePersonPermissions(
person: Person,
): PersonPermissions {
const staff = isStaff(caller);
const admin = caller?.accountLevel === 'administrator';
const isSelf = caller?.id === person.id;
return {
canEdit: isSelf || staff,
canChangeAccountLevel: caller?.accountLevel === 'administrator',
canChangeAccountLevel: admin,
canDeactivate: isSelf || staff,
canPurge: admin,
};
}

Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/services/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ export class PersonService {

const isStaff =
caller?.accountLevel === 'staff' || caller?.accountLevel === 'administrator';
if (person.deletedAt && !isStaff) return null;
const isSelfCaller = caller?.id === person.id;
// Deactivated profiles 404 for everyone except staff and the person
// themselves (self may view to reactivate). Per specs/behaviors/person-lifecycle.md.
if (person.deletedAt && !isStaff && !isSelfCaller) return null;

const memberships = this.#getMembershipsForPerson(person.id);
const projectsMap = this.#getProjectsForMemberships(memberships);
Expand Down
Loading
Loading