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 */
912import 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 : {
0 commit comments