Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
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
171 changes: 168 additions & 3 deletions apps/api/src/services/person.write.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/**
* Person writes:
* - PATCH /api/people/:slug (self | staff)
* - DELETE /api/people/:slug (administrator)
* - PATCH /api/people/:slug/newsletter (self | staff) — private-store only
* - PATCH /api/people/:slug (self | staff)
* - POST /api/people/:slug/deactivate (self | staff)
* - POST /api/people/:slug/reactivate (self | staff)
* - POST /api/people/:slug/purge (administrator)
* - PATCH /api/people/:slug/newsletter (self | staff) — private-store only
*
* Avatar upload is handled by a separate multipart route handler that
* stages an attachment then calls a Person update; it is not covered by
Expand Down Expand Up @@ -189,6 +191,169 @@ export class PersonWriteService {
return { stateApply };
}

/**
* Deactivate — self-service or staff.
* Sets `deletedAt = now()`. The person can still sign in and reactivate.
* Spec: specs/behaviors/person-lifecycle.md
*/
async deactivate(
tx: DualStoreTx,
slug: string,
session: SessionContext,
): Promise<{ person: Person; stateApply: StateApply }> {
// Look up by slug even if already deleted — self-service path may need it.
// #personOrThrow filters out deleted; use the raw map here.
const id = this.#state.personIdBySlug.get(slug);
if (!id) throw new ApiNotFoundError(`Person '${slug}' not found`);
const existing = this.#state.people.get(id);
if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`);

requireAuth('self | staff', { session, selfId: existing.id });

if (existing.deletedAt) {
// Already deactivated — idempotent, return current state.
return { person: existing, stateApply: new StateApply() };
}

const now = nowIso();
const updated: Person = PersonSchema.parse({
...existing,
deletedAt: now,
updatedAt: now,
});

await tx.public.people.upsert(updated);
const stateApply = new StateApply().upsertPerson(updated);
return { person: updated, stateApply };
}

/**
* Reactivate — self-service or staff. Clears `deletedAt`.
* Note: the route must look up the person even when deactivated so the
* self-service reactivation path works. Non-staff callers may reactivate
* ONLY their own account (enforced by `requireAuth('self | staff', ...)`).
* Spec: specs/behaviors/person-lifecycle.md
*/
async reactivate(
tx: DualStoreTx,
slug: string,
session: SessionContext,
): Promise<{ person: Person; stateApply: StateApply }> {
// Must look up even if deleted (deactivated users can self-reactivate).
const id = this.#state.personIdBySlug.get(slug);
if (!id) throw new ApiNotFoundError(`Person '${slug}' not found`);
const existing = this.#state.people.get(id);
if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`);

requireAuth('self | staff', { session, selfId: existing.id });

if (!existing.deletedAt) {
// Already active — idempotent.
return { person: existing, stateApply: new StateApply() };
}

const now = nowIso();
const updated: Person = PersonSchema.parse({
...existing,
deletedAt: null,
updatedAt: now,
});

await tx.public.people.upsert(updated);
const stateApply = new StateApply().upsertPerson(updated);
return { person: updated, stateApply };
}

/**
* Purge — admin-only cascading hard delete.
*
* One transaction that hard-deletes:
* - the person record
* - all project-memberships for the person
* - all help-wanted-interest expressions for the person
* - all tag-assignments where taggableType = 'person' + taggableId = personId
* - all project-updates authored by the person (DELETE, not null)
* - all project-buzz posted by the person (DELETE, not null)
* - all blog-posts authored by the person (DELETE, not null)
*
* Unlike the offline spam-prune (which nulls authorId on updates), purge
* DELETES authored content — it is the on-demand garbage-collection path.
* Spec: specs/behaviors/person-lifecycle.md
*/
async purge(
tx: DualStoreTx,
slug: string,
session: SessionContext,
): Promise<{ stateApply: StateApply }> {
requireAuth('administrator', { session });

const id = this.#state.personIdBySlug.get(slug);
if (!id) throw new ApiNotFoundError(`Person '${slug}' not found`);
const existing = this.#state.people.get(id);
if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`);

const stateApply = new StateApply();
const personId = existing.id;

// 1. Delete the person record.
await tx.public.people.delete(existing);
stateApply.removePerson(personId, existing.slug);

// 2. Cascade-delete project-memberships.
const membershipIds = this.#state.membershipsByPerson.get(personId) ?? new Set<string>();
for (const mId of membershipIds) {
const m = this.#state.projectMemberships.get(mId);
if (m) {
await tx.public['project-memberships'].delete(m);
stateApply.removeMembership(m);
}
}

// 3. Cascade-delete help-wanted-interest.
for (const interest of this.#state.helpWantedInterest.values()) {
if (interest.personId === personId) {
await tx.public['help-wanted-interest'].delete(interest);
stateApply.removeInterest(interest);
}
}

// 4. Cascade-delete person tag-assignments.
const taIds = this.#state.tagAssignmentsByTaggable.get(personId) ?? new Set<string>();
for (const taId of taIds) {
const ta = this.#state.tagAssignments.get(taId);
if (ta && ta.taggableType === 'person') {
await tx.public['tag-assignments'].delete(ta);
stateApply.removeTagAssignment(ta);
}
}

// 5. Delete authored project-updates.
for (const update of this.#state.projectUpdates.values()) {
if (update.authorId === personId) {
await tx.public['project-updates'].delete(update);
stateApply.removeProjectUpdate(update);
}
}

// 6. Delete posted project-buzz.
for (const buzz of this.#state.projectBuzz.values()) {
if (buzz.postedById === personId) {
await tx.public['project-buzz'].delete(buzz);
stateApply.removeProjectBuzz(buzz);
}
}

// 7. Delete authored blog-posts.
for (const post of this.#state.blogPosts.values()) {
if (post.authorId === personId) {
await tx.public['blog-posts'].delete(post);
stateApply.removeBlogPost(post);
}
}

return { stateApply };
}

async updateNewsletter(
slug: string,
optedIn: boolean,
Expand Down
15 changes: 14 additions & 1 deletion apps/api/src/services/serializers/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ export function renderMarkdown(source: string): RenderMarkdownResult {

/** PersonAvatar shape used in many nested contexts. */
export interface PersonAvatar {
readonly slug: string;
readonly slug: string | null;
readonly fullName: string;
readonly avatarUrl: string | null;
/** Present and true when the person is deactivated; omitted otherwise. */
readonly deactivated?: true;
}

/** Tag shape used in nested contexts. */
Expand All @@ -46,8 +48,19 @@ export interface TagItem {
readonly title: string;
}

/**
* Serialize a person reference as a PersonAvatar.
*
* If the person is deactivated (`deletedAt` is set), returns a placeholder
* per specs/api/people.md#deactivated-person-placeholder and
* specs/behaviors/person-lifecycle.md. The placeholder substitutes rather
* than omits the reference so counts and history stay coherent.
*/
export function serializePersonAvatar(person: Person | undefined | null): PersonAvatar | null {
if (!person) return null;
if (person.deletedAt) {
return { slug: null, fullName: 'Deactivated user', avatarUrl: null, deactivated: true };
}
return {
slug: person.slug,
fullName: person.fullName,
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/services/serializers/help-wanted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
*/
import type { HelpWantedRole, Person, Project, Tag, TagAssignment } from '@cfp/shared/schemas';
import type { HelpWantedPermissions } from '../permissions.js';
import { groupTagsByNamespace, renderMarkdown, serializePersonAvatar, type TagItem } from './common.js';
import { groupTagsByNamespace, renderMarkdown, serializePersonAvatar, type PersonAvatar, type TagItem } from './common.js';

export interface HelpWantedRoleResponse {
readonly id: string;
readonly project: { readonly slug: string; readonly title: string };
readonly postedBy: { readonly slug: string; readonly fullName: string; readonly avatarUrl: string | null } | null;
readonly postedBy: PersonAvatar | null;
readonly title: string;
readonly description: string;
readonly descriptionHtml: string;
readonly commitmentHoursPerWeek: number | null;
readonly status: string;
readonly filledBy: { readonly slug: string; readonly fullName: string; readonly avatarUrl: string | null } | null;
readonly filledBy: PersonAvatar | null;
readonly filledAt: string | null;
readonly closedAt: string | null;
readonly tags: { topic: TagItem[]; tech: TagItem[] };
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/services/serializers/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export interface PersonDetail {
readonly permissions: PersonPermissions;
readonly createdAt: string;
readonly updatedAt: string;
/** Set when the person is deactivated; visible to staff and self callers only. */
readonly deletedAt: string | null;
}

export function serializePersonListItem(
Expand Down Expand Up @@ -181,5 +183,7 @@ export function serializePersonDetail(
permissions: opts.permissions,
createdAt: person.createdAt,
updatedAt: person.updatedAt,
// deletedAt is visible to self and staff; everyone else gets null.
deletedAt: (isSelf || callerIsStaff) ? (person.deletedAt ?? null) : null,
};
}
Loading