Barazo AppView backend
barazo.forum
1import { eq, and, desc, sql, isNull } from 'drizzle-orm'
2import { requireCommunityDid } from '../middleware/community-resolver.js'
3import type { FastifyPluginCallback } from 'fastify'
4import { notFound, forbidden, badRequest, errorResponseSchema } from '../lib/api-errors.js'
5import {
6 createModNoteSchema,
7 modNoteQuerySchema,
8 deleteModNoteSchema,
9 createTopicNoticeSchema,
10 dismissTopicNoticeSchema,
11 topicNoticeQuerySchema,
12 createWarningSchema,
13 warningQuerySchema,
14 acknowledgeWarningSchema,
15} from '../validation/mod-annotations.js'
16import { modNotes } from '../db/schema/mod-notes.js'
17import { topicNotices } from '../db/schema/topic-notices.js'
18import { modWarnings } from '../db/schema/mod-warnings.js'
19import { moderationActions } from '../db/schema/moderation-actions.js'
20import { createRequireModerator } from '../auth/require-moderator.js'
21
22// ---------------------------------------------------------------------------
23// OpenAPI JSON Schema definitions
24// ---------------------------------------------------------------------------
25
26const modNoteJsonSchema = {
27 type: 'object' as const,
28 properties: {
29 id: { type: 'number' as const },
30 authorDid: { type: 'string' as const },
31 subjectDid: { type: ['string', 'null'] as const },
32 subjectUri: { type: ['string', 'null'] as const },
33 content: { type: 'string' as const },
34 noteType: { type: 'string' as const },
35 createdAt: { type: 'string' as const, format: 'date-time' as const },
36 },
37}
38
39const topicNoticeJsonSchema = {
40 type: 'object' as const,
41 properties: {
42 id: { type: 'number' as const },
43 topicUri: { type: 'string' as const },
44 authorDid: { type: 'string' as const },
45 noticeType: { type: 'string' as const },
46 headline: { type: 'string' as const },
47 body: { type: ['string', 'null'] as const },
48 createdAt: { type: 'string' as const, format: 'date-time' as const },
49 dismissedAt: { type: ['string', 'null'] as const },
50 },
51}
52
53const warningJsonSchema = {
54 type: 'object' as const,
55 properties: {
56 id: { type: 'number' as const },
57 targetDid: { type: 'string' as const },
58 moderatorDid: { type: 'string' as const },
59 warningType: { type: 'string' as const },
60 message: { type: 'string' as const },
61 modComment: { type: ['string', 'null'] as const },
62 acknowledgedAt: { type: ['string', 'null'] as const },
63 createdAt: { type: 'string' as const, format: 'date-time' as const },
64 },
65}
66
67// ---------------------------------------------------------------------------
68// Helpers
69// ---------------------------------------------------------------------------
70
71function serializeModNote(row: typeof modNotes.$inferSelect) {
72 return {
73 id: row.id,
74 authorDid: row.authorDid,
75 subjectDid: row.subjectDid,
76 subjectUri: row.subjectUri,
77 content: row.content,
78 noteType: row.noteType,
79 createdAt: row.createdAt.toISOString(),
80 }
81}
82
83function serializeTopicNotice(row: typeof topicNotices.$inferSelect) {
84 return {
85 id: row.id,
86 topicUri: row.topicUri,
87 authorDid: row.authorDid,
88 noticeType: row.noticeType,
89 headline: row.headline,
90 body: row.body,
91 createdAt: row.createdAt.toISOString(),
92 dismissedAt: row.dismissedAt?.toISOString() ?? null,
93 }
94}
95
96function serializeWarning(row: typeof modWarnings.$inferSelect) {
97 return {
98 id: row.id,
99 targetDid: row.targetDid,
100 moderatorDid: row.moderatorDid,
101 warningType: row.warningType,
102 message: row.message,
103 modComment: row.modComment,
104 acknowledgedAt: row.acknowledgedAt?.toISOString() ?? null,
105 createdAt: row.createdAt.toISOString(),
106 }
107}
108
109function encodeCursor(createdAt: string, id: number): string {
110 return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64')
111}
112
113function decodeCursor(cursor: string): { createdAt: string; id: number } | null {
114 try {
115 const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')) as Record<
116 string,
117 unknown
118 >
119 if (typeof decoded.createdAt === 'string' && typeof decoded.id === 'number') {
120 return { createdAt: decoded.createdAt, id: decoded.id }
121 }
122 return null
123 } catch {
124 return null
125 }
126}
127
128// ---------------------------------------------------------------------------
129// Mod annotation routes plugin
130// ---------------------------------------------------------------------------
131
132export function modAnnotationRoutes(): FastifyPluginCallback {
133 return (app, _opts, done) => {
134 const { db, authMiddleware } = app
135 const requireModerator = createRequireModerator(db, authMiddleware, app.log)
136
137 // -------------------------------------------------------------------
138 // POST /api/mod-notes (moderator+)
139 // -------------------------------------------------------------------
140
141 app.post(
142 '/api/mod-notes',
143 {
144 preHandler: [requireModerator],
145 schema: {
146 tags: ['Mod Annotations'],
147 summary: 'Create a moderator note on a user or post',
148 security: [{ bearerAuth: [] }],
149 body: {
150 type: 'object',
151 properties: {
152 subjectDid: { type: 'string' },
153 subjectUri: { type: 'string' },
154 content: { type: 'string', minLength: 1, maxLength: 5000 },
155 },
156 required: ['content'],
157 },
158 response: {
159 201: {
160 type: 'object',
161 properties: {
162 note: modNoteJsonSchema,
163 },
164 },
165 400: errorResponseSchema,
166 401: errorResponseSchema,
167 403: errorResponseSchema,
168 },
169 },
170 },
171 async (request, reply) => {
172 const communityDid = requireCommunityDid(request)
173 const user = request.user
174 if (!user) {
175 return reply.status(401).send({ error: 'Authentication required' })
176 }
177
178 const parsed = createModNoteSchema.safeParse(request.body)
179 if (!parsed.success) {
180 throw badRequest(parsed.error.issues[0]?.message ?? 'Invalid request body')
181 }
182
183 const { subjectDid, subjectUri, content } = parsed.data
184
185 const rows = await db
186 .insert(modNotes)
187 .values({
188 communityDid,
189 authorDid: user.did,
190 subjectDid: subjectDid ?? null,
191 subjectUri: subjectUri ?? null,
192 content,
193 noteType: 'note',
194 })
195 .returning()
196
197 const note = rows[0]
198 if (!note) {
199 throw badRequest('Failed to create mod note')
200 }
201
202 // Audit trail
203 await db.insert(moderationActions).values({
204 action: 'note_created',
205 targetUri: subjectUri ?? null,
206 targetDid: subjectDid ?? null,
207 moderatorDid: user.did,
208 communityDid,
209 reason: content.slice(0, 200),
210 })
211
212 app.log.info(
213 { noteId: note.id, moderatorDid: user.did, subjectDid, subjectUri },
214 'Mod note created'
215 )
216
217 return reply.status(201).send({ note: serializeModNote(note) })
218 }
219 )
220
221 // -------------------------------------------------------------------
222 // GET /api/mod-notes (moderator+)
223 // -------------------------------------------------------------------
224
225 app.get(
226 '/api/mod-notes',
227 {
228 preHandler: [requireModerator],
229 schema: {
230 tags: ['Mod Annotations'],
231 summary: 'List moderator notes (filter by subjectDid or subjectUri)',
232 security: [{ bearerAuth: [] }],
233 querystring: {
234 type: 'object',
235 properties: {
236 subjectDid: { type: 'string' },
237 subjectUri: { type: 'string' },
238 cursor: { type: 'string' },
239 limit: { type: 'string' },
240 },
241 },
242 response: {
243 200: {
244 type: 'object',
245 properties: {
246 notes: { type: 'array', items: modNoteJsonSchema },
247 cursor: { type: ['string', 'null'] },
248 },
249 },
250 400: errorResponseSchema,
251 401: errorResponseSchema,
252 },
253 },
254 },
255 async (request, reply) => {
256 const communityDid = requireCommunityDid(request)
257
258 const parsed = modNoteQuerySchema.safeParse(request.query)
259 if (!parsed.success) {
260 throw badRequest('Invalid query parameters')
261 }
262
263 const { subjectDid, subjectUri, cursor, limit } = parsed.data
264 const conditions = [eq(modNotes.communityDid, communityDid)]
265
266 if (subjectDid) {
267 conditions.push(eq(modNotes.subjectDid, subjectDid))
268 }
269 if (subjectUri) {
270 conditions.push(eq(modNotes.subjectUri, subjectUri))
271 }
272
273 if (cursor) {
274 const decoded = decodeCursor(cursor)
275 if (decoded) {
276 conditions.push(
277 sql`(${modNotes.createdAt}, ${modNotes.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`
278 )
279 }
280 }
281
282 const whereClause = and(...conditions)
283 const fetchLimit = limit + 1
284
285 const rows = await db
286 .select()
287 .from(modNotes)
288 .where(whereClause)
289 .orderBy(desc(modNotes.createdAt))
290 .limit(fetchLimit)
291
292 const hasMore = rows.length > limit
293 const resultRows = hasMore ? rows.slice(0, limit) : rows
294
295 let nextCursor: string | null = null
296 if (hasMore) {
297 const lastRow = resultRows[resultRows.length - 1]
298 if (lastRow) {
299 nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id)
300 }
301 }
302
303 return reply.status(200).send({
304 notes: resultRows.map(serializeModNote),
305 cursor: nextCursor,
306 })
307 }
308 )
309
310 // -------------------------------------------------------------------
311 // DELETE /api/mod-notes/:id (moderator+)
312 // -------------------------------------------------------------------
313
314 app.delete(
315 '/api/mod-notes/:id',
316 {
317 preHandler: [requireModerator],
318 schema: {
319 tags: ['Mod Annotations'],
320 summary: 'Delete a moderator note',
321 security: [{ bearerAuth: [] }],
322 params: {
323 type: 'object',
324 required: ['id'],
325 properties: { id: { type: 'string' } },
326 },
327 response: {
328 200: {
329 type: 'object',
330 properties: {
331 success: { type: 'boolean' },
332 },
333 },
334 400: errorResponseSchema,
335 401: errorResponseSchema,
336 404: errorResponseSchema,
337 },
338 },
339 },
340 async (request, reply) => {
341 const communityDid = requireCommunityDid(request)
342 const user = request.user
343 if (!user) {
344 return reply.status(401).send({ error: 'Authentication required' })
345 }
346
347 const paramsParsed = deleteModNoteSchema.safeParse(request.params)
348 if (!paramsParsed.success) {
349 throw badRequest('Invalid note ID')
350 }
351
352 const { id } = paramsParsed.data
353
354 const rows = await db
355 .select()
356 .from(modNotes)
357 .where(and(eq(modNotes.id, id), eq(modNotes.communityDid, communityDid)))
358
359 const note = rows[0]
360 if (!note) {
361 throw notFound('Mod note not found')
362 }
363
364 await db
365 .delete(modNotes)
366 .where(and(eq(modNotes.id, id), eq(modNotes.communityDid, communityDid)))
367
368 await db.insert(moderationActions).values({
369 action: 'note_deleted',
370 targetUri: note.subjectUri,
371 targetDid: note.subjectDid,
372 moderatorDid: user.did,
373 communityDid,
374 reason: `Deleted mod note #${String(id)}`,
375 })
376
377 app.log.info({ noteId: id, moderatorDid: user.did }, 'Mod note deleted')
378
379 return reply.status(200).send({ success: true })
380 }
381 )
382
383 // -------------------------------------------------------------------
384 // POST /api/topic-notices (moderator+)
385 // -------------------------------------------------------------------
386
387 app.post(
388 '/api/topic-notices',
389 {
390 preHandler: [requireModerator],
391 schema: {
392 tags: ['Mod Annotations'],
393 summary: 'Create a topic notice',
394 security: [{ bearerAuth: [] }],
395 body: {
396 type: 'object',
397 required: ['topicUri', 'noticeType', 'headline'],
398 properties: {
399 topicUri: { type: 'string' },
400 noticeType: {
401 type: 'string',
402 enum: ['closed', 'moved', 'outdated', 'announcement', 'custom'],
403 },
404 headline: { type: 'string', minLength: 1, maxLength: 200 },
405 body: { type: 'string', maxLength: 2000 },
406 },
407 },
408 response: {
409 201: {
410 type: 'object',
411 properties: {
412 notice: topicNoticeJsonSchema,
413 },
414 },
415 400: errorResponseSchema,
416 401: errorResponseSchema,
417 403: errorResponseSchema,
418 },
419 },
420 },
421 async (request, reply) => {
422 const communityDid = requireCommunityDid(request)
423 const user = request.user
424 if (!user) {
425 return reply.status(401).send({ error: 'Authentication required' })
426 }
427
428 const parsed = createTopicNoticeSchema.safeParse(request.body)
429 if (!parsed.success) {
430 throw badRequest(parsed.error.issues[0]?.message ?? 'Invalid request body')
431 }
432
433 const { topicUri, noticeType, headline, body } = parsed.data
434
435 const rows = await db
436 .insert(topicNotices)
437 .values({
438 communityDid,
439 topicUri,
440 authorDid: user.did,
441 noticeType,
442 headline,
443 body: body ?? null,
444 })
445 .returning()
446
447 const notice = rows[0]
448 if (!notice) {
449 throw badRequest('Failed to create topic notice')
450 }
451
452 // Audit trail
453 await db.insert(moderationActions).values({
454 action: 'notice_added',
455 targetUri: topicUri,
456 moderatorDid: user.did,
457 communityDid,
458 reason: headline,
459 })
460
461 app.log.info(
462 { noticeId: notice.id, moderatorDid: user.did, topicUri },
463 'Topic notice created'
464 )
465
466 return reply.status(201).send({ notice: serializeTopicNotice(notice) })
467 }
468 )
469
470 // -------------------------------------------------------------------
471 // GET /api/topic-notices (public with topicUri, moderator+ without)
472 // -------------------------------------------------------------------
473
474 app.get(
475 '/api/topic-notices',
476 {
477 schema: {
478 tags: ['Mod Annotations'],
479 summary:
480 'List topic notices (public when filtered by topicUri, moderator-only otherwise)',
481 querystring: {
482 type: 'object',
483 properties: {
484 topicUri: { type: 'string' },
485 cursor: { type: 'string' },
486 limit: { type: 'string' },
487 },
488 },
489 response: {
490 200: {
491 type: 'object',
492 properties: {
493 notices: { type: 'array', items: topicNoticeJsonSchema },
494 cursor: { type: ['string', 'null'] },
495 },
496 },
497 400: errorResponseSchema,
498 401: errorResponseSchema,
499 },
500 },
501 },
502 async (request, reply) => {
503 const communityDid = requireCommunityDid(request)
504
505 const parsed = topicNoticeQuerySchema.safeParse(request.query)
506 if (!parsed.success) {
507 throw badRequest('Invalid query parameters')
508 }
509
510 const { topicUri, cursor, limit } = parsed.data
511
512 // When no topicUri is provided, require moderator auth
513 if (!topicUri) {
514 await requireModerator(request, reply)
515 if (reply.sent) return
516 }
517
518 const conditions = [eq(topicNotices.communityDid, communityDid)]
519
520 if (topicUri) {
521 conditions.push(eq(topicNotices.topicUri, topicUri))
522 // Public view: only active (non-dismissed) notices
523 conditions.push(isNull(topicNotices.dismissedAt))
524 }
525
526 if (cursor) {
527 const decoded = decodeCursor(cursor)
528 if (decoded) {
529 conditions.push(
530 sql`(${topicNotices.createdAt}, ${topicNotices.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`
531 )
532 }
533 }
534
535 const whereClause = and(...conditions)
536 const fetchLimit = limit + 1
537
538 const rows = await db
539 .select()
540 .from(topicNotices)
541 .where(whereClause)
542 .orderBy(desc(topicNotices.createdAt))
543 .limit(fetchLimit)
544
545 const hasMore = rows.length > limit
546 const resultRows = hasMore ? rows.slice(0, limit) : rows
547
548 let nextCursor: string | null = null
549 if (hasMore) {
550 const lastRow = resultRows[resultRows.length - 1]
551 if (lastRow) {
552 nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id)
553 }
554 }
555
556 return reply.status(200).send({
557 notices: resultRows.map(serializeTopicNotice),
558 cursor: nextCursor,
559 })
560 }
561 )
562
563 // -------------------------------------------------------------------
564 // DELETE /api/topic-notices/:id (moderator+, soft delete)
565 // -------------------------------------------------------------------
566
567 app.delete(
568 '/api/topic-notices/:id',
569 {
570 preHandler: [requireModerator],
571 schema: {
572 tags: ['Mod Annotations'],
573 summary: 'Dismiss a topic notice (soft delete)',
574 security: [{ bearerAuth: [] }],
575 params: {
576 type: 'object',
577 required: ['id'],
578 properties: { id: { type: 'string' } },
579 },
580 response: {
581 200: {
582 type: 'object',
583 properties: {
584 notice: topicNoticeJsonSchema,
585 },
586 },
587 400: errorResponseSchema,
588 401: errorResponseSchema,
589 404: errorResponseSchema,
590 },
591 },
592 },
593 async (request, reply) => {
594 const communityDid = requireCommunityDid(request)
595 const user = request.user
596 if (!user) {
597 return reply.status(401).send({ error: 'Authentication required' })
598 }
599
600 const paramsParsed = dismissTopicNoticeSchema.safeParse(request.params)
601 if (!paramsParsed.success) {
602 throw badRequest('Invalid notice ID')
603 }
604
605 const { id } = paramsParsed.data
606
607 const rows = await db
608 .select()
609 .from(topicNotices)
610 .where(and(eq(topicNotices.id, id), eq(topicNotices.communityDid, communityDid)))
611
612 const notice = rows[0]
613 if (!notice) {
614 throw notFound('Topic notice not found')
615 }
616
617 const updatedRows = await db
618 .update(topicNotices)
619 .set({ dismissedAt: sql`now()` })
620 .where(and(eq(topicNotices.id, id), eq(topicNotices.communityDid, communityDid)))
621 .returning()
622
623 const updated = updatedRows[0]
624 if (!updated) {
625 throw notFound('Failed to dismiss topic notice')
626 }
627
628 // Audit trail
629 await db.insert(moderationActions).values({
630 action: 'notice_removed',
631 targetUri: notice.topicUri,
632 moderatorDid: user.did,
633 communityDid,
634 reason: `Dismissed notice: ${notice.headline}`,
635 })
636
637 app.log.info({ noticeId: id, moderatorDid: user.did }, 'Topic notice dismissed')
638
639 return reply.status(200).send({ notice: serializeTopicNotice(updated) })
640 }
641 )
642
643 // -------------------------------------------------------------------
644 // POST /api/warnings (moderator+)
645 // -------------------------------------------------------------------
646
647 app.post(
648 '/api/warnings',
649 {
650 preHandler: [requireModerator],
651 schema: {
652 tags: ['Mod Annotations'],
653 summary: 'Issue a warning to a user',
654 security: [{ bearerAuth: [] }],
655 body: {
656 type: 'object',
657 required: ['targetDid', 'warningType', 'message'],
658 properties: {
659 targetDid: { type: 'string' },
660 warningType: {
661 type: 'string',
662 enum: ['off_topic', 'harassment', 'rule_violation', 'other', 'custom'],
663 },
664 message: { type: 'string', minLength: 1, maxLength: 2000 },
665 modComment: { type: 'string', maxLength: 300 },
666 internalNote: { type: 'string', maxLength: 5000 },
667 },
668 },
669 response: {
670 201: {
671 type: 'object',
672 properties: {
673 warning: warningJsonSchema,
674 },
675 },
676 400: errorResponseSchema,
677 401: errorResponseSchema,
678 403: errorResponseSchema,
679 },
680 },
681 },
682 async (request, reply) => {
683 const communityDid = requireCommunityDid(request)
684 const user = request.user
685 if (!user) {
686 return reply.status(401).send({ error: 'Authentication required' })
687 }
688
689 const parsed = createWarningSchema.safeParse(request.body)
690 if (!parsed.success) {
691 throw badRequest(parsed.error.issues[0]?.message ?? 'Invalid request body')
692 }
693
694 const { targetDid, warningType, message, modComment, internalNote } = parsed.data
695
696 const rows = await db
697 .insert(modWarnings)
698 .values({
699 communityDid,
700 targetDid,
701 moderatorDid: user.did,
702 warningType,
703 message,
704 modComment: modComment ?? null,
705 internalNote: internalNote ?? null,
706 })
707 .returning()
708
709 const warning = rows[0]
710 if (!warning) {
711 throw badRequest('Failed to create warning')
712 }
713
714 // If internalNote provided, also create a mod note with warning_context type
715 if (internalNote) {
716 await db.insert(modNotes).values({
717 communityDid,
718 authorDid: user.did,
719 subjectDid: targetDid,
720 content: internalNote,
721 noteType: 'warning_context',
722 })
723 }
724
725 // Audit trail
726 await db.insert(moderationActions).values({
727 action: 'warning_issued',
728 targetDid,
729 moderatorDid: user.did,
730 communityDid,
731 reason: message.slice(0, 200),
732 })
733
734 app.log.info({ warningId: warning.id, moderatorDid: user.did, targetDid }, 'Warning issued')
735
736 return reply.status(201).send({ warning: serializeWarning(warning) })
737 }
738 )
739
740 // -------------------------------------------------------------------
741 // GET /api/warnings (moderator+)
742 // -------------------------------------------------------------------
743
744 app.get(
745 '/api/warnings',
746 {
747 preHandler: [requireModerator],
748 schema: {
749 tags: ['Mod Annotations'],
750 summary: 'List warnings (filter by targetDid)',
751 security: [{ bearerAuth: [] }],
752 querystring: {
753 type: 'object',
754 properties: {
755 targetDid: { type: 'string' },
756 cursor: { type: 'string' },
757 limit: { type: 'string' },
758 },
759 },
760 response: {
761 200: {
762 type: 'object',
763 properties: {
764 warnings: { type: 'array', items: warningJsonSchema },
765 cursor: { type: ['string', 'null'] },
766 },
767 },
768 400: errorResponseSchema,
769 401: errorResponseSchema,
770 },
771 },
772 },
773 async (request, reply) => {
774 const communityDid = requireCommunityDid(request)
775
776 const parsed = warningQuerySchema.safeParse(request.query)
777 if (!parsed.success) {
778 throw badRequest('Invalid query parameters')
779 }
780
781 const { targetDid, cursor, limit } = parsed.data
782 const conditions = [eq(modWarnings.communityDid, communityDid)]
783
784 if (targetDid) {
785 conditions.push(eq(modWarnings.targetDid, targetDid))
786 }
787
788 if (cursor) {
789 const decoded = decodeCursor(cursor)
790 if (decoded) {
791 conditions.push(
792 sql`(${modWarnings.createdAt}, ${modWarnings.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`
793 )
794 }
795 }
796
797 const whereClause = and(...conditions)
798 const fetchLimit = limit + 1
799
800 const rows = await db
801 .select()
802 .from(modWarnings)
803 .where(whereClause)
804 .orderBy(desc(modWarnings.createdAt))
805 .limit(fetchLimit)
806
807 const hasMore = rows.length > limit
808 const resultRows = hasMore ? rows.slice(0, limit) : rows
809
810 let nextCursor: string | null = null
811 if (hasMore) {
812 const lastRow = resultRows[resultRows.length - 1]
813 if (lastRow) {
814 nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id)
815 }
816 }
817
818 return reply.status(200).send({
819 warnings: resultRows.map(serializeWarning),
820 cursor: nextCursor,
821 })
822 }
823 )
824
825 // -------------------------------------------------------------------
826 // POST /api/warnings/:id/acknowledge (authenticated, target user only)
827 // -------------------------------------------------------------------
828
829 app.post(
830 '/api/warnings/:id/acknowledge',
831 {
832 preHandler: [authMiddleware.requireAuth],
833 schema: {
834 tags: ['Mod Annotations'],
835 summary: 'Acknowledge a warning (target user only)',
836 security: [{ bearerAuth: [] }],
837 params: {
838 type: 'object',
839 required: ['id'],
840 properties: { id: { type: 'string' } },
841 },
842 response: {
843 200: {
844 type: 'object',
845 properties: {
846 warning: warningJsonSchema,
847 },
848 },
849 400: errorResponseSchema,
850 401: errorResponseSchema,
851 403: errorResponseSchema,
852 404: errorResponseSchema,
853 },
854 },
855 },
856 async (request, reply) => {
857 const communityDid = requireCommunityDid(request)
858 const user = request.user
859 if (!user) {
860 return reply.status(401).send({ error: 'Authentication required' })
861 }
862
863 const paramsParsed = acknowledgeWarningSchema.safeParse(request.params)
864 if (!paramsParsed.success) {
865 throw badRequest('Invalid warning ID')
866 }
867
868 const { id } = paramsParsed.data
869
870 const rows = await db
871 .select()
872 .from(modWarnings)
873 .where(and(eq(modWarnings.id, id), eq(modWarnings.communityDid, communityDid)))
874
875 const warning = rows[0]
876 if (!warning) {
877 throw notFound('Warning not found')
878 }
879
880 if (warning.targetDid !== user.did) {
881 throw forbidden('You can only acknowledge warnings directed at you')
882 }
883
884 if (warning.acknowledgedAt) {
885 throw badRequest('Warning has already been acknowledged')
886 }
887
888 const updatedRows = await db
889 .update(modWarnings)
890 .set({ acknowledgedAt: sql`now()` })
891 .where(and(eq(modWarnings.id, id), eq(modWarnings.communityDid, communityDid)))
892 .returning()
893
894 const updated = updatedRows[0]
895 if (!updated) {
896 throw notFound('Failed to acknowledge warning')
897 }
898
899 app.log.info({ warningId: id, userDid: user.did }, 'Warning acknowledged')
900
901 return reply.status(200).send({ warning: serializeWarning(updated) })
902 }
903 )
904
905 // -------------------------------------------------------------------
906 // GET /api/my-warnings (authenticated)
907 // -------------------------------------------------------------------
908
909 app.get(
910 '/api/my-warnings',
911 {
912 preHandler: [authMiddleware.requireAuth],
913 schema: {
914 tags: ['Mod Annotations'],
915 summary: 'List own warnings in this community',
916 security: [{ bearerAuth: [] }],
917 querystring: {
918 type: 'object',
919 properties: {
920 cursor: { type: 'string' },
921 limit: { type: 'string' },
922 },
923 },
924 response: {
925 200: {
926 type: 'object',
927 properties: {
928 warnings: { type: 'array', items: warningJsonSchema },
929 cursor: { type: ['string', 'null'] },
930 },
931 },
932 400: errorResponseSchema,
933 401: errorResponseSchema,
934 },
935 },
936 },
937 async (request, reply) => {
938 const communityDid = requireCommunityDid(request)
939 const user = request.user
940 if (!user) {
941 return reply.status(401).send({ error: 'Authentication required' })
942 }
943
944 const parsed = warningQuerySchema.safeParse(request.query)
945 if (!parsed.success) {
946 throw badRequest('Invalid query parameters')
947 }
948
949 const { cursor, limit } = parsed.data
950 const conditions = [
951 eq(modWarnings.communityDid, communityDid),
952 eq(modWarnings.targetDid, user.did),
953 ]
954
955 if (cursor) {
956 const decoded = decodeCursor(cursor)
957 if (decoded) {
958 conditions.push(
959 sql`(${modWarnings.createdAt}, ${modWarnings.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`
960 )
961 }
962 }
963
964 const whereClause = and(...conditions)
965 const fetchLimit = limit + 1
966
967 const rows = await db
968 .select()
969 .from(modWarnings)
970 .where(whereClause)
971 .orderBy(desc(modWarnings.createdAt))
972 .limit(fetchLimit)
973
974 const hasMore = rows.length > limit
975 const resultRows = hasMore ? rows.slice(0, limit) : rows
976
977 let nextCursor: string | null = null
978 if (hasMore) {
979 const lastRow = resultRows[resultRows.length - 1]
980 if (lastRow) {
981 nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id)
982 }
983 }
984
985 return reply.status(200).send({
986 warnings: resultRows.map(serializeWarning),
987 cursor: nextCursor,
988 })
989 }
990 )
991
992 done()
993 }
994}