Skip to content

Commit 6327958

Browse files
Merge pull request #246 from CodeForPhilly/develop
Release: v0.10.0
2 parents c55a863 + f46f8be commit 6327958

21 files changed

Lines changed: 592 additions & 19 deletions

File tree

apps/api/gitsheets/contracts/codeforphilly.org/projects/v1.toml renamed to apps/api/gitsheets/contracts/codeforphilly.org/projects/v2.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"$id" = "https://codeforphilly.org/projects/v1"
1+
"$id" = "https://codeforphilly.org/projects/v2"
22
"$schema" = "http://json-schema.org/draft-07/schema#"
33
required = ["createdAt", "id", "slug", "title", "updatedAt"]
44
title = "Project"
@@ -13,6 +13,11 @@ format = "date-time"
1313
pattern = '^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$'
1414
type = "string"
1515

16+
[properties.createdById]
17+
format = "uuid"
18+
pattern = "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
19+
type = "string"
20+
1621
[properties.deletedAt]
1722
format = "date-time"
1823
pattern = '^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$'

‎apps/api/gitsheets/contracts/sources.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ source = "https://raw.githubusercontent.com/CodeForPhilly/codeforphilly-ng/a60d2
7070
adopted = 2026-10-14T03:31:29.88Z
7171
source = "https://raw.githubusercontent.com/CodeForPhilly/codeforphilly-ng/7fe6b81a0bbd05b7a4e6896d55e181fdb61cfe42/contracts/codeforphilly.org/projects/v1.json"
7272

73+
["codeforphilly.org/projects/v2"]
74+
adopted = 2026-10-15T17:47:46.895Z
75+
source = "https://raw.githubusercontent.com/CodeForPhilly/codeforphilly-ng/38870322de7e4f243a87e02d62b0603bd42498dc/contracts/codeforphilly.org/projects/v2.json"
76+
7377
["codeforphilly.org/revocations/v1"]
7478
adopted = 2026-10-14T03:31:32.497Z
7579
source = "https://raw.githubusercontent.com/CodeForPhilly/codeforphilly-ng/7fe6b81a0bbd05b7a4e6896d55e181fdb61cfe42/contracts/codeforphilly.org/revocations/v1.json"

‎apps/api/gitsheets/projects.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[gitsheet]
22
root = 'projects'
33
path = '${{ slug }}'
4-
implements = ["codeforphilly.org/projects/v1"]
4+
implements = ["codeforphilly.org/projects/v2"]

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* PATCH /api/people/:slug
66
* POST /api/people/:slug/deactivate
77
* POST /api/people/:slug/reactivate
8+
* GET /api/people/:slug/purge-preview
89
* POST /api/people/:slug/purge
910
* POST /api/people/:slug/account-level (administrator)
1011
* PATCH /api/people/:slug/newsletter (private-only mutation)
@@ -215,6 +216,19 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
215216
return ok(await fastify.services.people.get(result.value.person.slug, caller));
216217
});
217218

219+
// GET /api/people/:slug/purge-preview (administrator only)
220+
// Spec: specs/api/people.md → GET /api/people/:slug/purge-preview
221+
fastify.get('/api/people/:slug/purge-preview', {
222+
schema: {
223+
tags: ['people'],
224+
summary: 'Projects a purge would delete with the person (admin only)',
225+
params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] },
226+
},
227+
}, async (request) => {
228+
const { slug } = request.params as { slug: string };
229+
return { success: true, data: fastify.services.peopleWrite.purgePreview(slug, request.session) };
230+
});
231+
218232
// POST /api/people/:slug/purge (administrator only)
219233
// Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md
220234
fastify.post('/api/people/:slug/purge', {

‎apps/api/src/services/person.write.ts‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type { SessionContext } from '../auth/middleware.js';
3535
import { applyTagsForEntity, type TagAssignmentInput, type TagNamespace } from './tag.write.js';
3636
import type { PrivateStore } from '../store/private/index.js';
3737
import { cascadeRename } from './reference-cascade.js';
38+
import { purgeableProjects, purgeProject } from './project-purge.js';
3839
import { rekeyAttachment, upsertMoved } from './slug-rename.js';
3940

4041
function nowIso(): string {
@@ -283,6 +284,14 @@ export class PersonWriteService {
283284
const stateApply = new StateApply();
284285
const personId = existing.id;
285286

287+
// 0. Projects only this person touched go first, whole (computed before
288+
// anything is deleted). Every id deleted is tracked so the person's
289+
// own cascade below skips what the project purge already removed.
290+
const deleted = new Set<string>();
291+
for (const project of purgeableProjects(this.#state, existing)) {
292+
await purgeProject(tx, this.#state, stateApply, project, deleted);
293+
}
294+
286295
// 1. Delete the person record.
287296
await tx.public.people.delete(existing);
288297
stateApply.removePerson(personId, existing.slug);
@@ -291,15 +300,15 @@ export class PersonWriteService {
291300
const membershipIds = this.#state.membershipsByPerson.get(existing.slug) ?? new Set<string>();
292301
for (const mId of membershipIds) {
293302
const m = this.#state.projectMemberships.get(mId);
294-
if (m) {
303+
if (m && !deleted.has(m.id)) {
295304
await tx.public['project-memberships'].delete(m);
296305
stateApply.removeMembership(m);
297306
}
298307
}
299308

300309
// 3. Cascade-delete help-wanted-interest.
301310
for (const interest of this.#state.helpWantedInterest.values()) {
302-
if (interest.personSlug === existing.slug) {
311+
if (interest.personSlug === existing.slug && !deleted.has(interest.id)) {
303312
await tx.public['help-wanted-interest'].delete(interest);
304313
stateApply.removeInterest(interest);
305314
}
@@ -308,7 +317,7 @@ export class PersonWriteService {
308317
// 3b. Cascade-delete event RSVPs (path: year/eventSlug/personSlug).
309318
for (const rsvpId of this.#state.rsvpsByPerson.get(existing.slug) ?? new Set<string>()) {
310319
const rsvp = this.#state.eventRsvps.get(rsvpId);
311-
if (rsvp) {
320+
if (rsvp && !deleted.has(rsvp.id)) {
312321
await tx.public['event-rsvps'].delete(rsvp);
313322
stateApply.removeEventRsvp(rsvp);
314323
}
@@ -326,15 +335,15 @@ export class PersonWriteService {
326335

327336
// 5. Delete authored project-updates.
328337
for (const update of this.#state.projectUpdates.values()) {
329-
if (update.authorId === personId) {
338+
if (update.authorId === personId && !deleted.has(update.id)) {
330339
await tx.public['project-updates'].delete(update);
331340
stateApply.removeProjectUpdate(update);
332341
}
333342
}
334343

335344
// 6. Delete posted project-buzz.
336345
for (const buzz of this.#state.projectBuzz.values()) {
337-
if (buzz.postedById === personId) {
346+
if (buzz.postedById === personId && !deleted.has(buzz.id)) {
338347
await tx.public['project-buzz'].delete(buzz);
339348
stateApply.removeProjectBuzz(buzz);
340349
}
@@ -351,6 +360,21 @@ export class PersonWriteService {
351360
return { stateApply };
352361
}
353362

363+
/**
364+
* The projects a purge of this person would delete with them —
365+
* specs/api/people.md → GET /api/people/:slug/purge-preview.
366+
*/
367+
purgePreview(slug: string, session: SessionContext): { projects: Array<{ slug: string; title: string }> } {
368+
requireAuth('administrator', { session });
369+
// Deactivated people included: a spam vote deactivates, and they're who gets purged.
370+
const id = this.#state.personIdBySlug.get(slug);
371+
const person = id ? this.#state.people.get(id) : undefined;
372+
if (!person) throw new ApiNotFoundError(`Person '${slug}' not found`);
373+
return {
374+
projects: purgeableProjects(this.#state, person).map((p) => ({ slug: p.slug, title: p.title })),
375+
};
376+
}
377+
354378
/**
355379
* Set a person's accountLevel — administrator only. This is the sole path
356380
* for changing accountLevel (never via the generic PATCH), so the privilege
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* The projects a person purge takes with it — specs/behaviors/person-lifecycle.md
3+
* → Purge: projects the person created (`createdById`, or `maintainerId` when
4+
* a project has no `createdById`) that nobody else touched, deleted with
5+
* everything that hangs off them.
6+
*/
7+
import type { Person, Project } from '@cfp/shared/schemas';
8+
9+
import type { InMemoryState } from '../store/memory/state.js';
10+
import type { StateApply } from '../store/state-apply.js';
11+
import type { DualStoreTx } from '../store/store.js';
12+
13+
function idsIn(index: Map<string, Set<string>>, key: string): string[] {
14+
return [...(index.get(key) ?? [])];
15+
}
16+
17+
/** Whether `person` created `project` — the field, else the maintainer for older records. */
18+
function createdBy(project: Project, person: Person): boolean {
19+
return project.createdById ? project.createdById === person.id : project.maintainerId === person.id;
20+
}
21+
22+
/** Whether anyone but `person` has a record on `project`. */
23+
function touchedByOthers(state: InMemoryState, project: Project, person: Person): boolean {
24+
for (const id of idsIn(state.membershipsByProject, project.slug)) {
25+
if (state.projectMemberships.get(id)?.personSlug !== person.slug) return true;
26+
}
27+
for (const id of idsIn(state.updatesByProject, project.slug)) {
28+
if (state.projectUpdates.get(id)?.authorId !== person.id) return true;
29+
}
30+
for (const id of idsIn(state.buzzByProject, project.slug)) {
31+
if (state.projectBuzz.get(id)?.postedById !== person.id) return true;
32+
}
33+
for (const roleId of idsIn(state.helpWantedByProject, project.slug)) {
34+
if (state.helpWantedRoles.get(roleId)?.postedById !== person.id) return true;
35+
for (const id of idsIn(state.interestByRole, roleId)) {
36+
if (state.helpWantedInterest.get(id)?.personSlug !== person.slug) return true;
37+
}
38+
}
39+
for (const rsvp of state.eventRsvps.values()) {
40+
if (rsvp.projectId === project.id && rsvp.personSlug !== person.slug) return true;
41+
}
42+
return false;
43+
}
44+
45+
/** Live or soft-deleted projects a purge of `person` deletes, by title. */
46+
export function purgeableProjects(state: InMemoryState, person: Person): Project[] {
47+
return [...state.projects.values()]
48+
.filter((p) => createdBy(p, person) && !touchedByOthers(state, p, person))
49+
.sort((a, b) => a.title.localeCompare(b.title));
50+
}
51+
52+
/**
53+
* Hard-delete `project` and every record on it inside `tx`. Records already
54+
* deleted earlier in the same transaction (ids in `deleted`) are skipped, and
55+
* every id this deletes is added, so the person cascade can skip them too.
56+
*/
57+
export async function purgeProject(
58+
tx: DualStoreTx,
59+
state: InMemoryState,
60+
stateApply: StateApply,
61+
project: Project,
62+
deleted: Set<string>,
63+
): Promise<void> {
64+
const once = async (id: string, del: () => Promise<void>) => {
65+
if (deleted.has(id)) return;
66+
deleted.add(id);
67+
await del();
68+
};
69+
70+
for (const id of idsIn(state.membershipsByProject, project.slug)) {
71+
const m = state.projectMemberships.get(id);
72+
if (m) await once(m.id, async () => { await tx.public['project-memberships'].delete(m); stateApply.removeMembership(m); });
73+
}
74+
for (const id of idsIn(state.updatesByProject, project.slug)) {
75+
const u = state.projectUpdates.get(id);
76+
if (u) await once(u.id, async () => { await tx.public['project-updates'].delete(u); stateApply.removeProjectUpdate(u); });
77+
}
78+
for (const id of idsIn(state.buzzByProject, project.slug)) {
79+
const b = state.projectBuzz.get(id);
80+
if (b) await once(b.id, async () => { await tx.public['project-buzz'].delete(b); stateApply.removeProjectBuzz(b); });
81+
}
82+
for (const roleId of idsIn(state.helpWantedByProject, project.slug)) {
83+
for (const id of idsIn(state.interestByRole, roleId)) {
84+
const e = state.helpWantedInterest.get(id);
85+
if (e) await once(e.id, async () => { await tx.public['help-wanted-interest'].delete(e); stateApply.removeInterest(e); });
86+
}
87+
await deleteTagAssignments(tx, state, stateApply, roleId, once);
88+
const r = state.helpWantedRoles.get(roleId);
89+
if (r) await once(r.id, async () => { await tx.public['help-wanted-roles'].delete(r); stateApply.removeHelpWantedRole(r); });
90+
}
91+
for (const rsvp of [...state.eventRsvps.values()]) {
92+
if (rsvp.projectId !== project.id) continue;
93+
await once(rsvp.id, async () => { await tx.public['event-rsvps'].delete(rsvp); stateApply.removeEventRsvp(rsvp); });
94+
}
95+
await deleteTagAssignments(tx, state, stateApply, project.id, once);
96+
// Deleting the record removes its attachment folder with it.
97+
await once(project.id, async () => {
98+
await tx.public.projects.delete(project);
99+
stateApply.removeProject(project.id, project.slug);
100+
});
101+
}
102+
103+
async function deleteTagAssignments(
104+
tx: DualStoreTx,
105+
state: InMemoryState,
106+
stateApply: StateApply,
107+
taggableId: string,
108+
once: (id: string, del: () => Promise<void>) => Promise<void>,
109+
): Promise<void> {
110+
for (const id of idsIn(state.tagAssignmentsByTaggable, taggableId)) {
111+
const ta = state.tagAssignments.get(id);
112+
if (ta) await once(ta.id, async () => { await tx.public['tag-assignments'].delete(ta); stateApply.removeTagAssignment(ta); });
113+
}
114+
}

‎apps/api/src/services/project.write.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export class ProjectWriteService {
150150
overview: input.overview ?? null,
151151
stage,
152152
maintainerId: session.person!.id,
153+
createdById: session.person!.id,
153154
usersUrl: input.usersUrl ?? null,
154155
developersUrl: input.developersUrl ?? null,
155156
chatChannel: input.chatChannel ?? null,

0 commit comments

Comments
 (0)