Skip to content

Commit d174d7e

Browse files
Merge pull request #253 from CodeForPhilly/feat/scheduled-spam-purge
feat: daily scheduled purge of spam-voted accounts
2 parents e5312d4 + 86dd4fe commit d174d7e

14 files changed

Lines changed: 570 additions & 94 deletions

File tree

‎apps/api/src/app.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import markdownPlugin from './plugins/markdown.js';
4343
import slugRedirectPlugin from './plugins/slug-redirect.js';
4444
import legacyRedirectPlugin from './plugins/legacy-redirect.js';
4545
import eventRemindersPlugin from './plugins/event-reminders.js';
46+
import spamPurgePlugin from './plugins/spam-purge.js';
4647
import rateLimitPlugin from './plugins/rate-limit.js';
4748
import idempotencyPlugin from './plugins/idempotency.js';
4849
import sessionMiddlewarePlugin from './auth/middleware.js';
@@ -157,6 +158,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
157158
await fastify.register(slugRedirectPlugin);
158159
await fastify.register(legacyRedirectPlugin);
159160
await fastify.register(eventRemindersPlugin);
161+
await fastify.register(spamPurgePlugin);
160162

161163
// ----- 7. Session middleware (JWT auth) -----
162164
// Before rate-limit and idempotency: all three use onRequest hooks, which run

‎apps/api/src/plugins/spam-purge.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Scheduled spam purge plugin — specs/behaviors/person-lifecycle.md →
3+
* "Scheduled spam purge". Runs once when the server is ready and then daily;
4+
* in-process because the purge writes through the store's single writer.
5+
*/
6+
import type { FastifyInstance } from 'fastify';
7+
import fp from 'fastify-plugin';
8+
9+
import { SPAM_PURGE_INTERVAL_MS, SpamPurgeService } from '../services/spam-purge.js';
10+
11+
declare module 'fastify' {
12+
interface FastifyInstance {
13+
spamPurge: SpamPurgeService;
14+
}
15+
}
16+
17+
async function spamPurgePlugin(fastify: FastifyInstance): Promise<void> {
18+
const service = new SpamPurgeService({
19+
state: fastify.inMemoryState,
20+
fts: fastify.fts,
21+
store: fastify.store,
22+
log: fastify.log,
23+
});
24+
fastify.decorate('spamPurge', service);
25+
26+
const run = (): void => {
27+
void service.sweep().catch((err) => {
28+
fastify.log.error({ err }, 'scheduled spam purge failed');
29+
});
30+
};
31+
32+
let interval: ReturnType<typeof setInterval> | undefined;
33+
fastify.addHook('onReady', async () => {
34+
// Tests boot many apps; the sweep is exercised directly there.
35+
if (fastify.config.NODE_ENV === 'test') return;
36+
run();
37+
interval = setInterval(run, SPAM_PURGE_INTERVAL_MS);
38+
});
39+
fastify.addHook('onClose', async () => {
40+
if (interval) clearInterval(interval);
41+
});
42+
}
43+
44+
export default fp(spamPurgePlugin, {
45+
name: 'spam-purge',
46+
fastify: '5.x',
47+
dependencies: ['services'],
48+
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* records a `human-<voterSlug>` person-evaluation and applies the lifecycle
88
* side effect from specs/behaviors/person-lifecycle.md.
99
*/
10+
import { spamPurgeDueAt } from './spam-purge.js';
1011
import {
1112
PersonSchema,
1213
PersonEvaluationSchema,
@@ -90,6 +91,8 @@ export interface MemberRow {
9091
readonly bioExcerpt: string;
9192
readonly footprint: FootprintCounts;
9293
readonly latestVote: VoteView | null;
94+
/** When the scheduled spam purge will take this member, or null (specs/behaviors/person-lifecycle.md). */
95+
readonly purgeAfter: string | null;
9396
/** Row-local facts worth a glance; see `computeSignals`. */
9497
readonly signals: string[];
9598
/** Number of negative signals — drives the roster's attention tint. */
@@ -458,6 +461,7 @@ export class ModerationService {
458461
bioExcerpt: bioExcerpt(p.bio),
459462
footprint,
460463
latestVote: this.latestHumanVote(p.slug),
464+
purgeAfter: spamPurgeDueAt(this.#state, p)?.toISOString() ?? null,
461465
signals,
462466
attention,
463467
};
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Purge — the cascading hard delete behind the admin Purge button and the
3+
* scheduled spam purge (specs/behaviors/person-lifecycle.md). Everything
4+
* happens inside the caller's transaction, so a batch is one commit.
5+
*/
6+
import type { Person } from '@cfp/shared/schemas';
7+
8+
import type { InMemoryState } from '../store/memory/state.js';
9+
import type { StateApply } from '../store/state-apply.js';
10+
import type { DualStoreTx } from '../store/store.js';
11+
import { purgeableProjects, purgeProject } from './project-purge.js';
12+
13+
/**
14+
* Hard-delete `people` and everything that is theirs: first the projects only
15+
* they touched (judged across the whole batch, before anything is deleted),
16+
* then each person's own records. Ids deleted along the way are tracked so no
17+
* record is deleted twice.
18+
*/
19+
export async function purgePeople(
20+
tx: DualStoreTx,
21+
state: InMemoryState,
22+
stateApply: StateApply,
23+
people: readonly Person[],
24+
): Promise<void> {
25+
const deleted = new Set<string>();
26+
for (const project of purgeableProjects(state, people)) {
27+
await purgeProject(tx, state, stateApply, project, deleted);
28+
}
29+
for (const person of people) {
30+
await purgePersonRecords(tx, state, stateApply, person, deleted);
31+
}
32+
}
33+
34+
async function purgePersonRecords(
35+
tx: DualStoreTx,
36+
state: InMemoryState,
37+
stateApply: StateApply,
38+
existing: Person,
39+
deleted: Set<string>,
40+
): Promise<void> {
41+
const personId = existing.id;
42+
43+
// 1. Delete the person record.
44+
await tx.public.people.delete(existing);
45+
stateApply.removePerson(personId, existing.slug);
46+
47+
// 2. Cascade-delete project-memberships.
48+
const membershipIds = state.membershipsByPerson.get(existing.slug) ?? new Set<string>();
49+
for (const mId of membershipIds) {
50+
const m = state.projectMemberships.get(mId);
51+
if (m && !deleted.has(m.id)) {
52+
await tx.public['project-memberships'].delete(m);
53+
stateApply.removeMembership(m);
54+
}
55+
}
56+
57+
// 3. Cascade-delete help-wanted-interest.
58+
for (const interest of state.helpWantedInterest.values()) {
59+
if (interest.personSlug === existing.slug && !deleted.has(interest.id)) {
60+
await tx.public['help-wanted-interest'].delete(interest);
61+
stateApply.removeInterest(interest);
62+
}
63+
}
64+
65+
// 3b. Cascade-delete event RSVPs (path: year/eventSlug/personSlug).
66+
for (const rsvpId of state.rsvpsByPerson.get(existing.slug) ?? new Set<string>()) {
67+
const rsvp = state.eventRsvps.get(rsvpId);
68+
if (rsvp && !deleted.has(rsvp.id)) {
69+
await tx.public['event-rsvps'].delete(rsvp);
70+
stateApply.removeEventRsvp(rsvp);
71+
}
72+
}
73+
74+
// 4. Cascade-delete person tag-assignments.
75+
const taIds = state.tagAssignmentsByTaggable.get(personId) ?? new Set<string>();
76+
for (const taId of taIds) {
77+
const ta = state.tagAssignments.get(taId);
78+
if (ta && ta.taggableType === 'person') {
79+
await tx.public['tag-assignments'].delete(ta);
80+
stateApply.removeTagAssignment(ta);
81+
}
82+
}
83+
84+
// 5. Delete authored project-updates.
85+
for (const update of state.projectUpdates.values()) {
86+
if (update.authorId === personId && !deleted.has(update.id)) {
87+
await tx.public['project-updates'].delete(update);
88+
stateApply.removeProjectUpdate(update);
89+
}
90+
}
91+
92+
// 6. Delete posted project-buzz.
93+
for (const buzz of state.projectBuzz.values()) {
94+
if (buzz.postedById === personId && !deleted.has(buzz.id)) {
95+
await tx.public['project-buzz'].delete(buzz);
96+
stateApply.removeProjectBuzz(buzz);
97+
}
98+
}
99+
100+
// 7. Delete authored blog-posts.
101+
for (const post of state.blogPosts.values()) {
102+
if (post.authorId === personId) {
103+
await tx.public['blog-posts'].delete(post);
104+
stateApply.removeBlogPost(post);
105+
}
106+
}
107+
108+
}

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

Lines changed: 4 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ 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';
38+
import { purgePeople } from './person-purge.js';
39+
import { purgeableProjects } from './project-purge.js';
3940
import { rekeyAttachment, upsertMoved } from './slug-rename.js';
4041

4142
function nowIso(): string {
@@ -282,80 +283,7 @@ export class PersonWriteService {
282283
if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`);
283284

284285
const stateApply = new StateApply();
285-
const personId = existing.id;
286-
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-
295-
// 1. Delete the person record.
296-
await tx.public.people.delete(existing);
297-
stateApply.removePerson(personId, existing.slug);
298-
299-
// 2. Cascade-delete project-memberships.
300-
const membershipIds = this.#state.membershipsByPerson.get(existing.slug) ?? new Set<string>();
301-
for (const mId of membershipIds) {
302-
const m = this.#state.projectMemberships.get(mId);
303-
if (m && !deleted.has(m.id)) {
304-
await tx.public['project-memberships'].delete(m);
305-
stateApply.removeMembership(m);
306-
}
307-
}
308-
309-
// 3. Cascade-delete help-wanted-interest.
310-
for (const interest of this.#state.helpWantedInterest.values()) {
311-
if (interest.personSlug === existing.slug && !deleted.has(interest.id)) {
312-
await tx.public['help-wanted-interest'].delete(interest);
313-
stateApply.removeInterest(interest);
314-
}
315-
}
316-
317-
// 3b. Cascade-delete event RSVPs (path: year/eventSlug/personSlug).
318-
for (const rsvpId of this.#state.rsvpsByPerson.get(existing.slug) ?? new Set<string>()) {
319-
const rsvp = this.#state.eventRsvps.get(rsvpId);
320-
if (rsvp && !deleted.has(rsvp.id)) {
321-
await tx.public['event-rsvps'].delete(rsvp);
322-
stateApply.removeEventRsvp(rsvp);
323-
}
324-
}
325-
326-
// 4. Cascade-delete person tag-assignments.
327-
const taIds = this.#state.tagAssignmentsByTaggable.get(personId) ?? new Set<string>();
328-
for (const taId of taIds) {
329-
const ta = this.#state.tagAssignments.get(taId);
330-
if (ta && ta.taggableType === 'person') {
331-
await tx.public['tag-assignments'].delete(ta);
332-
stateApply.removeTagAssignment(ta);
333-
}
334-
}
335-
336-
// 5. Delete authored project-updates.
337-
for (const update of this.#state.projectUpdates.values()) {
338-
if (update.authorId === personId && !deleted.has(update.id)) {
339-
await tx.public['project-updates'].delete(update);
340-
stateApply.removeProjectUpdate(update);
341-
}
342-
}
343-
344-
// 6. Delete posted project-buzz.
345-
for (const buzz of this.#state.projectBuzz.values()) {
346-
if (buzz.postedById === personId && !deleted.has(buzz.id)) {
347-
await tx.public['project-buzz'].delete(buzz);
348-
stateApply.removeProjectBuzz(buzz);
349-
}
350-
}
351-
352-
// 7. Delete authored blog-posts.
353-
for (const post of this.#state.blogPosts.values()) {
354-
if (post.authorId === personId) {
355-
await tx.public['blog-posts'].delete(post);
356-
stateApply.removeBlogPost(post);
357-
}
358-
}
286+
await purgePeople(tx, this.#state, stateApply, [existing]);
359287

360288
return { stateApply };
361289
}
@@ -371,7 +299,7 @@ export class PersonWriteService {
371299
const person = id ? this.#state.people.get(id) : undefined;
372300
if (!person) throw new ApiNotFoundError(`Person '${slug}' not found`);
373301
return {
374-
projects: purgeableProjects(this.#state, person).map((p) => ({ slug: p.slug, title: p.title })),
302+
projects: purgeableProjects(this.#state, [person]).map((p) => ({ slug: p.slug, title: p.title })),
375303
};
376304
}
377305

‎apps/api/src/services/project-purge.ts‎

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,38 +14,52 @@ function idsIn(index: Map<string, Set<string>>, key: string): string[] {
1414
return [...(index.get(key) ?? [])];
1515
}
1616

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;
17+
/** The people being purged together: one person, or a scheduled batch. */
18+
interface Batch {
19+
readonly ids: ReadonlySet<string>;
20+
readonly slugs: ReadonlySet<string>;
2021
}
2122

22-
/** Whether anyone but `person` has a record on `project`. */
23-
function touchedByOthers(state: InMemoryState, project: Project, person: Person): boolean {
23+
function batchOf(people: readonly Person[]): Batch {
24+
return { ids: new Set(people.map((p) => p.id)), slugs: new Set(people.map((p) => p.slug)) };
25+
}
26+
27+
/** Whether someone in the batch created `project` — the field, else the maintainer for older records. */
28+
function createdBy(project: Project, batch: Batch): boolean {
29+
const creator = project.createdById ?? project.maintainerId;
30+
return !!creator && batch.ids.has(creator);
31+
}
32+
33+
/** Whether anyone outside the batch has a record on `project`. */
34+
function touchedByOthers(state: InMemoryState, project: Project, batch: Batch): boolean {
35+
const outsiderSlug = (slug: string | undefined) => !slug || !batch.slugs.has(slug);
36+
const outsiderId = (id: string | null | undefined) => !id || !batch.ids.has(id);
2437
for (const id of idsIn(state.membershipsByProject, project.slug)) {
25-
if (state.projectMemberships.get(id)?.personSlug !== person.slug) return true;
38+
if (outsiderSlug(state.projectMemberships.get(id)?.personSlug)) return true;
2639
}
2740
for (const id of idsIn(state.updatesByProject, project.slug)) {
28-
if (state.projectUpdates.get(id)?.authorId !== person.id) return true;
41+
if (outsiderId(state.projectUpdates.get(id)?.authorId)) return true;
2942
}
3043
for (const id of idsIn(state.buzzByProject, project.slug)) {
31-
if (state.projectBuzz.get(id)?.postedById !== person.id) return true;
44+
if (outsiderId(state.projectBuzz.get(id)?.postedById)) return true;
3245
}
3346
for (const roleId of idsIn(state.helpWantedByProject, project.slug)) {
34-
if (state.helpWantedRoles.get(roleId)?.postedById !== person.id) return true;
47+
if (outsiderId(state.helpWantedRoles.get(roleId)?.postedById)) return true;
3548
for (const id of idsIn(state.interestByRole, roleId)) {
36-
if (state.helpWantedInterest.get(id)?.personSlug !== person.slug) return true;
49+
if (outsiderSlug(state.helpWantedInterest.get(id)?.personSlug)) return true;
3750
}
3851
}
3952
for (const rsvp of state.eventRsvps.values()) {
40-
if (rsvp.projectId === project.id && rsvp.personSlug !== person.slug) return true;
53+
if (rsvp.projectId === project.id && outsiderSlug(rsvp.personSlug)) return true;
4154
}
4255
return false;
4356
}
4457

45-
/** Live or soft-deleted projects a purge of `person` deletes, by title. */
46-
export function purgeableProjects(state: InMemoryState, person: Person): Project[] {
58+
/** Live or soft-deleted projects a purge of `people` deletes, by title. */
59+
export function purgeableProjects(state: InMemoryState, people: readonly Person[]): Project[] {
60+
const batch = batchOf(people);
4761
return [...state.projects.values()]
48-
.filter((p) => createdBy(p, person) && !touchedByOthers(state, p, person))
62+
.filter((p) => createdBy(p, batch) && !touchedByOthers(state, p, batch))
4963
.sort((a, b) => a.title.localeCompare(b.title));
5064
}
5165

0 commit comments

Comments
 (0)