Barazo AppView backend
barazo.forum
1import { eq, and, desc, sql } from 'drizzle-orm'
2import { requireCommunityDid } from '../middleware/community-resolver.js'
3import type { FastifyPluginCallback } from 'fastify'
4import {
5 notFound,
6 forbidden,
7 badRequest,
8 conflict,
9 errorResponseSchema,
10} from '../lib/api-errors.js'
11import {
12 lockTopicSchema,
13 pinTopicSchema,
14 modDeleteSchema,
15 banUserSchema,
16 moderationLogQuerySchema,
17 createReportSchema,
18 reportQuerySchema,
19 resolveReportSchema,
20 reportedUsersQuerySchema,
21 moderationThresholdsSchema,
22 appealReportSchema,
23 myReportsQuerySchema,
24} from '../validation/moderation.js'
25import { topics } from '../db/schema/topics.js'
26import { replies } from '../db/schema/replies.js'
27import { users } from '../db/schema/users.js'
28import { moderationActions } from '../db/schema/moderation-actions.js'
29import { reports } from '../db/schema/reports.js'
30import { communitySettings } from '../db/schema/community-settings.js'
31import { notifications } from '../db/schema/notifications.js'
32import { communityFilters } from '../db/schema/community-filters.js'
33import { createRequireModerator } from '../auth/require-moderator.js'
34import { checkBanPropagation } from '../services/ban-propagation.js'
35import { createNotificationService } from '../services/notification.js'
36
37// ---------------------------------------------------------------------------
38// OpenAPI JSON Schema definitions
39// ---------------------------------------------------------------------------
40
41const moderationActionJsonSchema = {
42 type: 'object' as const,
43 properties: {
44 id: { type: 'number' as const },
45 action: { type: 'string' as const },
46 targetUri: { type: ['string', 'null'] as const },
47 targetDid: { type: ['string', 'null'] as const },
48 moderatorDid: { type: 'string' as const },
49 reason: { type: ['string', 'null'] as const },
50 createdAt: { type: 'string' as const, format: 'date-time' as const },
51 },
52}
53
54const reportJsonSchema = {
55 type: 'object' as const,
56 properties: {
57 id: { type: 'number' as const },
58 reporterDid: { type: 'string' as const },
59 targetUri: { type: 'string' as const },
60 targetDid: { type: 'string' as const },
61 reasonType: { type: 'string' as const },
62 description: { type: ['string', 'null'] as const },
63 status: { type: 'string' as const },
64 resolutionType: { type: ['string', 'null'] as const },
65 resolvedBy: { type: ['string', 'null'] as const },
66 resolvedAt: { type: ['string', 'null'] as const },
67 appealReason: { type: ['string', 'null'] as const },
68 appealedAt: { type: ['string', 'null'] as const },
69 appealStatus: { type: 'string' as const, enum: ['none', 'pending', 'rejected'] },
70 createdAt: { type: 'string' as const, format: 'date-time' as const },
71 },
72}
73
74// ---------------------------------------------------------------------------
75// Helpers
76// ---------------------------------------------------------------------------
77
78function serializeAction(row: typeof moderationActions.$inferSelect) {
79 return {
80 id: row.id,
81 action: row.action,
82 targetUri: row.targetUri,
83 targetDid: row.targetDid,
84 moderatorDid: row.moderatorDid,
85 reason: row.reason,
86 createdAt: row.createdAt.toISOString(),
87 }
88}
89
90function serializeReport(row: typeof reports.$inferSelect) {
91 return {
92 id: row.id,
93 reporterDid: row.reporterDid,
94 targetUri: row.targetUri,
95 targetDid: row.targetDid,
96 reasonType: row.reasonType,
97 description: row.description,
98 status: row.status,
99 resolutionType: row.resolutionType,
100 resolvedBy: row.resolvedBy,
101 resolvedAt: row.resolvedAt?.toISOString() ?? null,
102 appealReason: row.appealReason ?? null,
103 appealedAt: row.appealedAt?.toISOString() ?? null,
104 appealStatus: row.appealStatus,
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 * Extract DID from an AT URI.
130 * Format: at://did:plc:xxx/collection/rkey -> did:plc:xxx
131 */
132function extractDidFromUri(uri: string): string | undefined {
133 const match = /^at:\/\/(did:[^/]+)\//.exec(uri)
134 return match?.[1]
135}
136
137// ---------------------------------------------------------------------------
138// Moderation routes plugin
139// ---------------------------------------------------------------------------
140
141export function moderationRoutes(): FastifyPluginCallback {
142 return (app, _opts, done) => {
143 const { db, env, authMiddleware } = app
144 const requireModerator = createRequireModerator(db, authMiddleware, app.log)
145 const requireAdmin = app.requireAdmin
146 const notificationService = createNotificationService(db, app.log)
147
148 // -------------------------------------------------------------------
149 // POST /api/moderation/lock/:id (moderator+)
150 // -------------------------------------------------------------------
151
152 app.post(
153 '/api/moderation/lock/:id',
154 {
155 preHandler: [requireModerator],
156 schema: {
157 tags: ['Moderation'],
158 summary: 'Lock or unlock a topic',
159 security: [{ bearerAuth: [] }],
160 params: {
161 type: 'object',
162 required: ['id'],
163 properties: { id: { type: 'string' } },
164 },
165 body: {
166 type: 'object',
167 properties: {
168 reason: { type: 'string', maxLength: 500 },
169 },
170 },
171 response: {
172 200: {
173 type: 'object',
174 properties: {
175 uri: { type: 'string' },
176 isLocked: { type: 'boolean' },
177 },
178 },
179 401: errorResponseSchema,
180 403: errorResponseSchema,
181 404: errorResponseSchema,
182 },
183 },
184 },
185 async (request, reply) => {
186 const communityDid = requireCommunityDid(request)
187 const user = request.user
188 if (!user) {
189 return reply.status(401).send({ error: 'Authentication required' })
190 }
191
192 const { id } = request.params as { id: string }
193 const decodedUri = decodeURIComponent(id)
194 const parsed = lockTopicSchema.safeParse(request.body)
195
196 const topicRows = await db
197 .select()
198 .from(topics)
199 .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid)))
200
201 const topic = topicRows[0]
202 if (!topic) {
203 throw notFound('Topic not found')
204 }
205
206 const newLocked = !topic.isLocked
207 const action = newLocked ? 'lock' : 'unlock'
208
209 await db.transaction(async (tx) => {
210 await tx.update(topics).set({ isLocked: newLocked }).where(eq(topics.uri, decodedUri))
211
212 await tx.insert(moderationActions).values({
213 action,
214 targetUri: decodedUri,
215 moderatorDid: user.did,
216 communityDid,
217 reason: parsed.success ? parsed.data.reason : undefined,
218 })
219 })
220
221 app.log.info({ action, topicUri: decodedUri, moderatorDid: user.did }, `Topic ${action}ed`)
222
223 // Fire-and-forget: notify topic author of lock/unlock
224 notificationService
225 .notifyOnModAction({
226 targetUri: decodedUri,
227 moderatorDid: user.did,
228 targetDid: topic.authorDid,
229 communityDid,
230 })
231 .catch((err: unknown) => {
232 app.log.error({ err, topicUri: decodedUri }, 'Mod action notification failed')
233 })
234
235 return reply.status(200).send({
236 uri: decodedUri,
237 isLocked: newLocked,
238 })
239 }
240 )
241
242 // -------------------------------------------------------------------
243 // POST /api/moderation/pin/:id (moderator+)
244 // -------------------------------------------------------------------
245
246 app.post(
247 '/api/moderation/pin/:id',
248 {
249 preHandler: [requireModerator],
250 schema: {
251 tags: ['Moderation'],
252 summary: 'Pin or unpin a topic',
253 security: [{ bearerAuth: [] }],
254 params: {
255 type: 'object',
256 required: ['id'],
257 properties: { id: { type: 'string' } },
258 },
259 body: {
260 type: 'object',
261 properties: {
262 reason: { type: 'string', maxLength: 500 },
263 scope: { type: 'string', enum: ['category', 'forum'] },
264 },
265 },
266 response: {
267 200: {
268 type: 'object',
269 properties: {
270 uri: { type: 'string' },
271 isPinned: { type: 'boolean' },
272 pinnedScope: { type: 'string', nullable: true },
273 pinnedAt: { type: 'string', nullable: true },
274 },
275 },
276 401: errorResponseSchema,
277 403: errorResponseSchema,
278 404: errorResponseSchema,
279 },
280 },
281 },
282 async (request, reply) => {
283 const communityDid = requireCommunityDid(request)
284 const user = request.user
285 if (!user) {
286 return reply.status(401).send({ error: 'Authentication required' })
287 }
288
289 const { id } = request.params as { id: string }
290 const decodedUri = decodeURIComponent(id)
291 const parsed = pinTopicSchema.safeParse(request.body)
292 const scope = parsed.success ? parsed.data.scope : 'category'
293
294 const topicRows = await db
295 .select()
296 .from(topics)
297 .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid)))
298
299 const topic = topicRows[0]
300 if (!topic) {
301 throw notFound('Topic not found')
302 }
303
304 const newPinned = !topic.isPinned
305 const action = newPinned ? 'pin' : 'unpin'
306
307 // Forum-wide pins require admin role
308 if (newPinned && scope === 'forum') {
309 const userRows = await db.select().from(users).where(eq(users.did, user.did))
310 const userRecord = userRows[0]
311 if (!userRecord || userRecord.role !== 'admin') {
312 throw forbidden('Forum-wide pins require admin privileges')
313 }
314 }
315
316 const pinnedAt = newPinned ? new Date() : null
317 const pinnedScope = newPinned ? scope : null
318
319 await db.transaction(async (tx) => {
320 await tx
321 .update(topics)
322 .set({ isPinned: newPinned, pinnedAt, pinnedScope })
323 .where(eq(topics.uri, decodedUri))
324
325 await tx.insert(moderationActions).values({
326 action,
327 targetUri: decodedUri,
328 moderatorDid: user.did,
329 communityDid,
330 reason: parsed.success ? parsed.data.reason : undefined,
331 })
332 })
333
334 app.log.info(
335 { action, topicUri: decodedUri, moderatorDid: user.did, pinnedScope },
336 `Topic ${action}ned`
337 )
338
339 // Fire-and-forget: notify topic author of pin/unpin
340 notificationService
341 .notifyOnModAction({
342 targetUri: decodedUri,
343 moderatorDid: user.did,
344 targetDid: topic.authorDid,
345 communityDid,
346 })
347 .catch((err: unknown) => {
348 app.log.error({ err, topicUri: decodedUri }, 'Mod action notification failed')
349 })
350
351 return reply.status(200).send({
352 uri: decodedUri,
353 isPinned: newPinned,
354 pinnedScope,
355 pinnedAt: pinnedAt?.toISOString() ?? null,
356 })
357 }
358 )
359
360 // -------------------------------------------------------------------
361 // POST /api/moderation/delete/:id (moderator+)
362 // -------------------------------------------------------------------
363
364 app.post(
365 '/api/moderation/delete/:id',
366 {
367 preHandler: [requireModerator],
368 schema: {
369 tags: ['Moderation'],
370 summary: 'Mod-delete content (marks as deleted in index, does NOT delete from PDS)',
371 security: [{ bearerAuth: [] }],
372 params: {
373 type: 'object',
374 required: ['id'],
375 properties: { id: { type: 'string' } },
376 },
377 body: {
378 type: 'object',
379 required: ['reason'],
380 properties: {
381 reason: { type: 'string', minLength: 1, maxLength: 500 },
382 },
383 },
384 response: {
385 200: {
386 type: 'object',
387 properties: {
388 uri: { type: 'string' },
389 isModDeleted: { type: 'boolean' },
390 },
391 },
392 400: errorResponseSchema,
393 401: errorResponseSchema,
394 403: errorResponseSchema,
395 404: errorResponseSchema,
396 409: errorResponseSchema,
397 },
398 },
399 },
400 async (request, reply) => {
401 const communityDid = requireCommunityDid(request)
402 const user = request.user
403 if (!user) {
404 return reply.status(401).send({ error: 'Authentication required' })
405 }
406
407 const { id } = request.params as { id: string }
408 const decodedUri = decodeURIComponent(id)
409 const parsed = modDeleteSchema.safeParse(request.body)
410 if (!parsed.success) {
411 throw badRequest('Reason is required for mod-delete')
412 }
413
414 // Check if this is a topic or reply
415 const topicRows = await db
416 .select()
417 .from(topics)
418 .where(and(eq(topics.uri, decodedUri), eq(topics.communityDid, communityDid)))
419
420 const topic = topicRows[0]
421
422 if (topic) {
423 if (topic.isModDeleted) {
424 throw conflict('Content already mod-deleted')
425 }
426
427 await db.transaction(async (tx) => {
428 await tx.update(topics).set({ isModDeleted: true }).where(eq(topics.uri, decodedUri))
429
430 await tx.insert(moderationActions).values({
431 action: 'delete',
432 targetUri: decodedUri,
433 targetDid: topic.authorDid,
434 moderatorDid: user.did,
435 communityDid,
436 reason: parsed.data.reason,
437 })
438 })
439
440 app.log.info(
441 { action: 'delete', topicUri: decodedUri, moderatorDid: user.did },
442 'Topic mod-deleted'
443 )
444
445 // Fire-and-forget: notify topic author of deletion
446 notificationService
447 .notifyOnModAction({
448 targetUri: decodedUri,
449 moderatorDid: user.did,
450 targetDid: topic.authorDid,
451 communityDid,
452 })
453 .catch((err: unknown) => {
454 app.log.error({ err, topicUri: decodedUri }, 'Mod action notification failed')
455 })
456
457 return reply.status(200).send({
458 uri: decodedUri,
459 isModDeleted: true,
460 })
461 }
462
463 // Not a topic -- check replies
464 const replyRows = await db
465 .select()
466 .from(replies)
467 .where(and(eq(replies.uri, decodedUri), eq(replies.communityDid, communityDid)))
468
469 const replyRow = replyRows[0]
470 if (!replyRow) {
471 throw notFound('Content not found')
472 }
473
474 if (replyRow.isModDeleted) {
475 throw conflict('Content already mod-deleted')
476 }
477
478 await db.transaction(async (tx) => {
479 await tx.update(replies).set({ isModDeleted: true }).where(eq(replies.uri, decodedUri))
480
481 // Decrement reply count on parent topic
482 await tx
483 .update(topics)
484 .set({ replyCount: sql`GREATEST(${topics.replyCount} - 1, 0)` })
485 .where(eq(topics.uri, replyRow.rootUri))
486
487 await tx.insert(moderationActions).values({
488 action: 'delete',
489 targetUri: decodedUri,
490 targetDid: replyRow.authorDid,
491 moderatorDid: user.did,
492 communityDid,
493 reason: parsed.data.reason,
494 })
495 })
496
497 app.log.info(
498 { action: 'delete', replyUri: decodedUri, moderatorDid: user.did },
499 'Reply mod-deleted'
500 )
501
502 // Fire-and-forget: notify reply author of deletion
503 notificationService
504 .notifyOnModAction({
505 targetUri: decodedUri,
506 moderatorDid: user.did,
507 targetDid: replyRow.authorDid,
508 communityDid,
509 })
510 .catch((err: unknown) => {
511 app.log.error({ err, replyUri: decodedUri }, 'Mod action notification failed')
512 })
513
514 return reply.status(200).send({
515 uri: decodedUri,
516 isModDeleted: true,
517 })
518 }
519 )
520
521 // -------------------------------------------------------------------
522 // POST /api/moderation/ban (admin only)
523 // -------------------------------------------------------------------
524
525 app.post(
526 '/api/moderation/ban',
527 {
528 preHandler: [requireAdmin],
529 schema: {
530 tags: ['Moderation'],
531 summary: 'Ban or unban a user by DID',
532 security: [{ bearerAuth: [] }],
533 body: {
534 type: 'object',
535 required: ['did', 'reason'],
536 properties: {
537 did: { type: 'string', minLength: 1 },
538 reason: { type: 'string', minLength: 1, maxLength: 500 },
539 },
540 },
541 response: {
542 200: {
543 type: 'object',
544 properties: {
545 did: { type: 'string' },
546 isBanned: { type: 'boolean' },
547 },
548 },
549 400: errorResponseSchema,
550 401: errorResponseSchema,
551 403: errorResponseSchema,
552 404: errorResponseSchema,
553 },
554 },
555 },
556 async (request, reply) => {
557 const communityDid = requireCommunityDid(request)
558 const admin = request.user
559 if (!admin) {
560 return reply.status(401).send({ error: 'Authentication required' })
561 }
562
563 const parsed = banUserSchema.safeParse(request.body)
564 if (!parsed.success) {
565 throw badRequest('DID and reason are required')
566 }
567
568 const { did: targetDid, reason } = parsed.data
569
570 // Prevent self-ban
571 if (targetDid === admin.did) {
572 throw badRequest('Cannot ban yourself')
573 }
574
575 // Check user exists
576 const userRows = await db.select().from(users).where(eq(users.did, targetDid))
577
578 const targetUser = userRows[0]
579 if (!targetUser) {
580 throw notFound('User not found')
581 }
582
583 // Prevent banning other admins
584 if (targetUser.role === 'admin') {
585 throw forbidden('Cannot ban an admin')
586 }
587
588 const newBanned = !targetUser.isBanned
589 const action = newBanned ? 'ban' : 'unban'
590
591 await db.transaction(async (tx) => {
592 await tx.update(users).set({ isBanned: newBanned }).where(eq(users.did, targetDid))
593
594 await tx.insert(moderationActions).values({
595 action,
596 targetDid,
597 moderatorDid: admin.did,
598 communityDid,
599 reason,
600 })
601 })
602
603 app.log.info({ action, targetDid, adminDid: admin.did }, `User ${action}ned`)
604
605 // In multi mode, check ban propagation across communities
606 if (env.COMMUNITY_MODE === 'multi' && action === 'ban') {
607 try {
608 const result = await checkBanPropagation(db, app.cache, app.log, targetDid)
609 if (result.propagated) {
610 app.log.info(
611 { targetDid, banCount: result.banCount },
612 'Ban propagation triggered global account filter'
613 )
614 }
615 } catch (err) {
616 app.log.warn({ err, targetDid }, 'Ban propagation check failed (non-critical)')
617 }
618 }
619
620 // Fire-and-forget: notify banned/unbanned user
621 // Use targetDid as the targetUri since bans are user-level, not content-level
622 notificationService
623 .notifyOnModAction({
624 targetUri: `at://${targetDid}`,
625 moderatorDid: admin.did,
626 targetDid,
627 communityDid,
628 })
629 .catch((err: unknown) => {
630 app.log.error({ err, targetDid }, 'Mod action notification failed')
631 })
632
633 return reply.status(200).send({
634 did: targetDid,
635 isBanned: newBanned,
636 })
637 }
638 )
639
640 // -------------------------------------------------------------------
641 // GET /api/moderation/log (moderator+)
642 // -------------------------------------------------------------------
643
644 app.get(
645 '/api/moderation/log',
646 {
647 preHandler: [requireModerator],
648 schema: {
649 tags: ['Moderation'],
650 summary: 'Get moderation action log (paginated)',
651 security: [{ bearerAuth: [] }],
652 querystring: {
653 type: 'object',
654 properties: {
655 cursor: { type: 'string' },
656 limit: { type: 'string' },
657 action: {
658 type: 'string',
659 enum: ['lock', 'unlock', 'pin', 'unpin', 'delete', 'ban', 'unban'],
660 },
661 },
662 },
663 response: {
664 200: {
665 type: 'object',
666 properties: {
667 actions: { type: 'array', items: moderationActionJsonSchema },
668 cursor: { type: ['string', 'null'] },
669 },
670 },
671 400: errorResponseSchema,
672 },
673 },
674 },
675 async (request, reply) => {
676 const communityDid = requireCommunityDid(request)
677 const parsed = moderationLogQuerySchema.safeParse(request.query)
678 if (!parsed.success) {
679 throw badRequest('Invalid query parameters')
680 }
681
682 const { cursor, limit, action } = parsed.data
683 const conditions = [eq(moderationActions.communityDid, communityDid)]
684
685 if (action) {
686 conditions.push(eq(moderationActions.action, action))
687 }
688
689 if (cursor) {
690 const decoded = decodeCursor(cursor)
691 if (decoded) {
692 conditions.push(
693 sql`(${moderationActions.createdAt}, ${moderationActions.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`
694 )
695 }
696 }
697
698 const whereClause = and(...conditions)
699 const fetchLimit = limit + 1
700
701 const rows = await db
702 .select()
703 .from(moderationActions)
704 .where(whereClause)
705 .orderBy(desc(moderationActions.createdAt))
706 .limit(fetchLimit)
707
708 const hasMore = rows.length > limit
709 const resultRows = hasMore ? rows.slice(0, limit) : rows
710
711 let nextCursor: string | null = null
712 if (hasMore) {
713 const lastRow = resultRows[resultRows.length - 1]
714 if (lastRow) {
715 nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id)
716 }
717 }
718
719 return reply.status(200).send({
720 actions: resultRows.map(serializeAction),
721 cursor: nextCursor,
722 })
723 }
724 )
725
726 // -------------------------------------------------------------------
727 // POST /api/moderation/report (authenticated user)
728 // -------------------------------------------------------------------
729
730 app.post(
731 '/api/moderation/report',
732 {
733 preHandler: [authMiddleware.requireAuth],
734 schema: {
735 tags: ['Moderation'],
736 summary: 'Report content for moderator review',
737 security: [{ bearerAuth: [] }],
738 body: {
739 type: 'object',
740 required: ['targetUri', 'reasonType'],
741 properties: {
742 targetUri: { type: 'string', minLength: 1 },
743 reasonType: {
744 type: 'string',
745 enum: ['spam', 'sexual', 'harassment', 'violation', 'misleading', 'other'],
746 },
747 description: { type: 'string', maxLength: 1000 },
748 },
749 },
750 response: {
751 201: reportJsonSchema,
752 400: errorResponseSchema,
753 401: errorResponseSchema,
754 404: errorResponseSchema,
755 409: errorResponseSchema,
756 },
757 },
758 },
759 async (request, reply) => {
760 const communityDid = requireCommunityDid(request)
761 const user = request.user
762 if (!user) {
763 return reply.status(401).send({ error: 'Authentication required' })
764 }
765
766 const parsed = createReportSchema.safeParse(request.body)
767 if (!parsed.success) {
768 throw badRequest('Invalid report data')
769 }
770
771 const { targetUri, reasonType, description } = parsed.data
772
773 // Extract target DID from URI
774 const targetDid = extractDidFromUri(targetUri)
775 if (!targetDid) {
776 throw badRequest('Invalid target URI format')
777 }
778
779 // Cannot report own content
780 if (targetDid === user.did) {
781 throw badRequest('Cannot report your own content')
782 }
783
784 // Verify target content exists (topic or reply)
785 const topicRows = await db
786 .select({ uri: topics.uri })
787 .from(topics)
788 .where(and(eq(topics.uri, targetUri), eq(topics.communityDid, communityDid)))
789
790 let contentExists = topicRows.length > 0
791
792 if (!contentExists) {
793 const replyRows = await db
794 .select({ uri: replies.uri })
795 .from(replies)
796 .where(and(eq(replies.uri, targetUri), eq(replies.communityDid, communityDid)))
797 contentExists = replyRows.length > 0
798 }
799
800 if (!contentExists) {
801 throw notFound('Content not found')
802 }
803
804 // Check for duplicate report
805 const existingReports = await db
806 .select({ id: reports.id })
807 .from(reports)
808 .where(
809 and(
810 eq(reports.reporterDid, user.did),
811 eq(reports.targetUri, targetUri),
812 eq(reports.communityDid, communityDid)
813 )
814 )
815
816 if (existingReports.length > 0) {
817 throw conflict('You have already reported this content')
818 }
819
820 const inserted = await db
821 .insert(reports)
822 .values({
823 reporterDid: user.did,
824 targetUri,
825 targetDid,
826 reasonType,
827 description,
828 communityDid,
829 })
830 .returning()
831
832 const report = inserted[0]
833 if (!report) {
834 throw badRequest('Failed to create report')
835 }
836
837 app.log.info(
838 { reportId: report.id, reporterDid: user.did, targetUri, reasonType },
839 'Content reported'
840 )
841
842 // In multi mode, notify the community admin about the report
843 if (env.COMMUNITY_MODE === 'multi') {
844 try {
845 const filterRows = await db
846 .select({ adminDid: communityFilters.adminDid })
847 .from(communityFilters)
848 .where(eq(communityFilters.communityDid, communityDid))
849
850 const adminDid = filterRows[0]?.adminDid
851 if (adminDid) {
852 await db.insert(notifications).values({
853 recipientDid: adminDid,
854 type: 'global_report',
855 subjectUri: targetUri,
856 actorDid: user.did,
857 communityDid,
858 })
859 }
860 } catch (err) {
861 app.log.warn(
862 { err, communityDid },
863 'Failed to send global report notification (non-critical)'
864 )
865 }
866 }
867
868 return reply.status(201).send(serializeReport(report))
869 }
870 )
871
872 // -------------------------------------------------------------------
873 // GET /api/moderation/reports (moderator+)
874 // -------------------------------------------------------------------
875
876 app.get(
877 '/api/moderation/reports',
878 {
879 preHandler: [requireModerator],
880 schema: {
881 tags: ['Moderation'],
882 summary: 'List content reports (paginated)',
883 security: [{ bearerAuth: [] }],
884 querystring: {
885 type: 'object',
886 properties: {
887 status: { type: 'string', enum: ['pending', 'resolved'] },
888 cursor: { type: 'string' },
889 limit: { type: 'string' },
890 },
891 },
892 response: {
893 200: {
894 type: 'object',
895 properties: {
896 reports: { type: 'array', items: reportJsonSchema },
897 cursor: { type: ['string', 'null'] },
898 },
899 },
900 400: errorResponseSchema,
901 },
902 },
903 },
904 async (request, reply) => {
905 const communityDid = requireCommunityDid(request)
906 const parsed = reportQuerySchema.safeParse(request.query)
907 if (!parsed.success) {
908 throw badRequest('Invalid query parameters')
909 }
910
911 const { status, cursor, limit } = parsed.data
912 const conditions = [eq(reports.communityDid, communityDid)]
913
914 if (status) {
915 conditions.push(eq(reports.status, status))
916 }
917
918 if (cursor) {
919 const decoded = decodeCursor(cursor)
920 if (decoded) {
921 conditions.push(
922 sql`(${reports.createdAt}, ${reports.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`
923 )
924 }
925 }
926
927 const whereClause = and(...conditions)
928 const fetchLimit = limit + 1
929
930 const rows = await db
931 .select()
932 .from(reports)
933 .where(whereClause)
934 .orderBy(desc(reports.createdAt))
935 .limit(fetchLimit)
936
937 const hasMore = rows.length > limit
938 const resultRows = hasMore ? rows.slice(0, limit) : rows
939
940 let nextCursor: string | null = null
941 if (hasMore) {
942 const lastRow = resultRows[resultRows.length - 1]
943 if (lastRow) {
944 nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id)
945 }
946 }
947
948 return reply.status(200).send({
949 reports: resultRows.map(serializeReport),
950 cursor: nextCursor,
951 })
952 }
953 )
954
955 // -------------------------------------------------------------------
956 // PUT /api/moderation/reports/:id (moderator+)
957 // -------------------------------------------------------------------
958
959 app.put(
960 '/api/moderation/reports/:id',
961 {
962 preHandler: [requireModerator],
963 schema: {
964 tags: ['Moderation'],
965 summary: 'Resolve a content report',
966 security: [{ bearerAuth: [] }],
967 params: {
968 type: 'object',
969 required: ['id'],
970 properties: { id: { type: 'string' } },
971 },
972 body: {
973 type: 'object',
974 required: ['resolutionType'],
975 properties: {
976 resolutionType: {
977 type: 'string',
978 enum: ['dismissed', 'warned', 'labeled', 'removed', 'banned'],
979 },
980 },
981 },
982 response: {
983 200: reportJsonSchema,
984 400: errorResponseSchema,
985 401: errorResponseSchema,
986 404: errorResponseSchema,
987 409: errorResponseSchema,
988 },
989 },
990 },
991 async (request, reply) => {
992 const communityDid = requireCommunityDid(request)
993 const user = request.user
994 if (!user) {
995 return reply.status(401).send({ error: 'Authentication required' })
996 }
997
998 const { id } = request.params as { id: string }
999 const reportId = Number(id)
1000 if (Number.isNaN(reportId)) {
1001 throw badRequest('Invalid report ID')
1002 }
1003
1004 const parsed = resolveReportSchema.safeParse(request.body)
1005 if (!parsed.success) {
1006 throw badRequest('Invalid resolution data')
1007 }
1008
1009 const existing = await db
1010 .select()
1011 .from(reports)
1012 .where(and(eq(reports.id, reportId), eq(reports.communityDid, communityDid)))
1013
1014 const report = existing[0]
1015 if (!report) {
1016 throw notFound('Report not found')
1017 }
1018
1019 if (report.status === 'resolved') {
1020 throw conflict('Report already resolved')
1021 }
1022
1023 const updated = await db
1024 .update(reports)
1025 .set({
1026 status: 'resolved',
1027 resolutionType: parsed.data.resolutionType,
1028 resolvedBy: user.did,
1029 resolvedAt: new Date(),
1030 })
1031 .where(eq(reports.id, reportId))
1032 .returning()
1033
1034 const resolvedReport = updated[0]
1035 if (!resolvedReport) {
1036 throw notFound('Report not found after update')
1037 }
1038
1039 app.log.info(
1040 {
1041 reportId,
1042 resolutionType: parsed.data.resolutionType,
1043 resolvedBy: user.did,
1044 },
1045 'Report resolved'
1046 )
1047
1048 return reply.status(200).send(serializeReport(resolvedReport))
1049 }
1050 )
1051
1052 // -------------------------------------------------------------------
1053 // GET /api/admin/reports/users (admin only)
1054 // -------------------------------------------------------------------
1055
1056 app.get(
1057 '/api/admin/reports/users',
1058 {
1059 preHandler: [requireAdmin],
1060 schema: {
1061 tags: ['Admin'],
1062 summary: 'Most-reported users in this community',
1063 security: [{ bearerAuth: [] }],
1064 querystring: {
1065 type: 'object',
1066 properties: {
1067 limit: { type: 'string' },
1068 },
1069 },
1070 response: {
1071 200: {
1072 type: 'object',
1073 properties: {
1074 users: {
1075 type: 'array',
1076 items: {
1077 type: 'object',
1078 properties: {
1079 did: { type: 'string' },
1080 reportCount: { type: 'number' },
1081 },
1082 },
1083 },
1084 },
1085 },
1086 },
1087 },
1088 },
1089 async (request, reply) => {
1090 const communityDid = requireCommunityDid(request)
1091 const parsed = reportedUsersQuerySchema.safeParse(request.query)
1092 const limit = parsed.success ? parsed.data.limit : 25
1093
1094 const rows = await db
1095 .select({
1096 did: reports.targetDid,
1097 reportCount: sql<number>`count(*)::int`,
1098 })
1099 .from(reports)
1100 .where(eq(reports.communityDid, communityDid))
1101 .groupBy(reports.targetDid)
1102 .orderBy(sql`count(*) DESC`)
1103 .limit(limit)
1104
1105 return reply.status(200).send({
1106 users: rows.map((r) => ({ did: r.did, reportCount: r.reportCount })),
1107 })
1108 }
1109 )
1110
1111 // -------------------------------------------------------------------
1112 // GET /api/admin/moderation/thresholds (admin only)
1113 // -------------------------------------------------------------------
1114
1115 app.get(
1116 '/api/admin/moderation/thresholds',
1117 {
1118 preHandler: [requireAdmin],
1119 schema: {
1120 tags: ['Admin'],
1121 summary: 'Get moderation thresholds for this community',
1122 security: [{ bearerAuth: [] }],
1123 response: {
1124 200: {
1125 type: 'object',
1126 properties: {
1127 autoBlockReportCount: { type: 'number' },
1128 warnThreshold: { type: 'number' },
1129 firstPostQueueCount: { type: 'number' },
1130 newAccountDays: { type: 'number' },
1131 newAccountWriteRatePerMin: { type: 'number' },
1132 establishedWriteRatePerMin: { type: 'number' },
1133 linkHoldEnabled: { type: 'boolean' },
1134 topicCreationDelayEnabled: { type: 'boolean' },
1135 burstPostCount: { type: 'number' },
1136 burstWindowMinutes: { type: 'number' },
1137 trustedPostThreshold: { type: 'number' },
1138 },
1139 },
1140 },
1141 },
1142 },
1143 async (request, reply) => {
1144 const communityDid = requireCommunityDid(request)
1145 const settingsRows = await db
1146 .select({ moderationThresholds: communitySettings.moderationThresholds })
1147 .from(communitySettings)
1148 .where(eq(communitySettings.communityDid, communityDid))
1149
1150 const settings = settingsRows[0]
1151 const t = settings?.moderationThresholds
1152
1153 return reply.status(200).send({
1154 autoBlockReportCount: t?.autoBlockReportCount ?? 5,
1155 warnThreshold: t?.warnThreshold ?? 3,
1156 firstPostQueueCount: t?.firstPostQueueCount ?? 0,
1157 newAccountDays: t?.newAccountDays ?? 7,
1158 newAccountWriteRatePerMin: t?.newAccountWriteRatePerMin ?? 3,
1159 establishedWriteRatePerMin: t?.establishedWriteRatePerMin ?? 10,
1160 linkHoldEnabled: t?.linkHoldEnabled ?? false,
1161 topicCreationDelayEnabled: t?.topicCreationDelayEnabled ?? false,
1162 burstPostCount: t?.burstPostCount ?? 5,
1163 burstWindowMinutes: t?.burstWindowMinutes ?? 10,
1164 trustedPostThreshold: t?.trustedPostThreshold ?? 10,
1165 })
1166 }
1167 )
1168
1169 // -------------------------------------------------------------------
1170 // PUT /api/admin/moderation/thresholds (admin only)
1171 // -------------------------------------------------------------------
1172
1173 app.put(
1174 '/api/admin/moderation/thresholds',
1175 {
1176 preHandler: [requireAdmin],
1177 schema: {
1178 tags: ['Admin'],
1179 summary: 'Update moderation thresholds',
1180 security: [{ bearerAuth: [] }],
1181 body: {
1182 type: 'object',
1183 properties: {
1184 autoBlockReportCount: { type: 'number', minimum: 1, maximum: 100 },
1185 warnThreshold: { type: 'number', minimum: 1, maximum: 50 },
1186 firstPostQueueCount: { type: 'number', minimum: 0, maximum: 50 },
1187 newAccountDays: { type: 'number', minimum: 0, maximum: 90 },
1188 newAccountWriteRatePerMin: { type: 'number', minimum: 1, maximum: 30 },
1189 establishedWriteRatePerMin: { type: 'number', minimum: 1, maximum: 100 },
1190 linkHoldEnabled: { type: 'boolean' },
1191 topicCreationDelayEnabled: { type: 'boolean' },
1192 burstPostCount: { type: 'number', minimum: 2, maximum: 50 },
1193 burstWindowMinutes: { type: 'number', minimum: 1, maximum: 60 },
1194 trustedPostThreshold: { type: 'number', minimum: 1, maximum: 100 },
1195 },
1196 },
1197 response: {
1198 200: {
1199 type: 'object',
1200 properties: {
1201 autoBlockReportCount: { type: 'number' },
1202 warnThreshold: { type: 'number' },
1203 firstPostQueueCount: { type: 'number' },
1204 newAccountDays: { type: 'number' },
1205 newAccountWriteRatePerMin: { type: 'number' },
1206 establishedWriteRatePerMin: { type: 'number' },
1207 linkHoldEnabled: { type: 'boolean' },
1208 topicCreationDelayEnabled: { type: 'boolean' },
1209 burstPostCount: { type: 'number' },
1210 burstWindowMinutes: { type: 'number' },
1211 trustedPostThreshold: { type: 'number' },
1212 },
1213 },
1214 400: errorResponseSchema,
1215 },
1216 },
1217 },
1218 async (request, reply) => {
1219 const communityDid = requireCommunityDid(request)
1220 const parsed = moderationThresholdsSchema.safeParse(request.body)
1221 if (!parsed.success) {
1222 throw badRequest('Invalid threshold values')
1223 }
1224
1225 // Load existing thresholds, merge with partial update
1226 const existingRows = await db
1227 .select({ moderationThresholds: communitySettings.moderationThresholds })
1228 .from(communitySettings)
1229 .where(eq(communitySettings.communityDid, communityDid))
1230
1231 const existing = existingRows[0]?.moderationThresholds ?? {
1232 autoBlockReportCount: 5,
1233 warnThreshold: 3,
1234 firstPostQueueCount: 0,
1235 newAccountDays: 7,
1236 newAccountWriteRatePerMin: 3,
1237 establishedWriteRatePerMin: 10,
1238 linkHoldEnabled: false,
1239 topicCreationDelayEnabled: false,
1240 burstPostCount: 5,
1241 burstWindowMinutes: 10,
1242 trustedPostThreshold: 10,
1243 }
1244
1245 // Filter out undefined values from the partial update
1246 const definedUpdates: Record<string, unknown> = {}
1247 for (const [key, value] of Object.entries(parsed.data)) {
1248 if (value !== undefined) {
1249 definedUpdates[key] = value
1250 }
1251 }
1252 const merged = { ...existing, ...definedUpdates } as typeof existing
1253
1254 await db
1255 .update(communitySettings)
1256 .set({ moderationThresholds: merged })
1257 .where(eq(communitySettings.communityDid, communityDid))
1258
1259 // Invalidate cached anti-spam settings
1260 try {
1261 await app.cache.del(`antispam:settings:${communityDid}`)
1262 } catch {
1263 // Non-critical
1264 }
1265
1266 return reply.status(200).send(merged)
1267 }
1268 )
1269
1270 // -------------------------------------------------------------------
1271 // GET /api/moderation/my-reports (authenticated user)
1272 // -------------------------------------------------------------------
1273
1274 app.get(
1275 '/api/moderation/my-reports',
1276 {
1277 preHandler: [authMiddleware.requireAuth],
1278 schema: {
1279 tags: ['Moderation'],
1280 summary: 'List reports submitted by the authenticated user (paginated)',
1281 security: [{ bearerAuth: [] }],
1282 querystring: {
1283 type: 'object',
1284 properties: {
1285 cursor: { type: 'string' },
1286 limit: { type: 'string' },
1287 },
1288 },
1289 response: {
1290 200: {
1291 type: 'object',
1292 properties: {
1293 reports: { type: 'array', items: reportJsonSchema },
1294 cursor: { type: ['string', 'null'] },
1295 },
1296 },
1297 400: errorResponseSchema,
1298 401: errorResponseSchema,
1299 },
1300 },
1301 },
1302 async (request, reply) => {
1303 const communityDid = requireCommunityDid(request)
1304 const user = request.user
1305 if (!user) {
1306 return reply.status(401).send({ error: 'Authentication required' })
1307 }
1308
1309 const parsed = myReportsQuerySchema.safeParse(request.query)
1310 if (!parsed.success) {
1311 throw badRequest('Invalid query parameters')
1312 }
1313
1314 const { cursor, limit } = parsed.data
1315 const conditions = [
1316 eq(reports.reporterDid, user.did),
1317 eq(reports.communityDid, communityDid),
1318 ]
1319
1320 if (cursor) {
1321 const decoded = decodeCursor(cursor)
1322 if (decoded) {
1323 conditions.push(
1324 sql`(${reports.createdAt}, ${reports.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id})`
1325 )
1326 }
1327 }
1328
1329 const whereClause = and(...conditions)
1330 const fetchLimit = limit + 1
1331
1332 const rows = await db
1333 .select()
1334 .from(reports)
1335 .where(whereClause)
1336 .orderBy(desc(reports.createdAt))
1337 .limit(fetchLimit)
1338
1339 const hasMore = rows.length > limit
1340 const resultRows = hasMore ? rows.slice(0, limit) : rows
1341
1342 let nextCursor: string | null = null
1343 if (hasMore) {
1344 const lastRow = resultRows[resultRows.length - 1]
1345 if (lastRow) {
1346 nextCursor = encodeCursor(lastRow.createdAt.toISOString(), lastRow.id)
1347 }
1348 }
1349
1350 return reply.status(200).send({
1351 reports: resultRows.map(serializeReport),
1352 cursor: nextCursor,
1353 })
1354 }
1355 )
1356
1357 // -------------------------------------------------------------------
1358 // POST /api/moderation/reports/:id/appeal (authenticated user)
1359 // -------------------------------------------------------------------
1360
1361 app.post(
1362 '/api/moderation/reports/:id/appeal',
1363 {
1364 preHandler: [authMiddleware.requireAuth],
1365 schema: {
1366 tags: ['Moderation'],
1367 summary: 'Appeal a dismissed report',
1368 security: [{ bearerAuth: [] }],
1369 params: {
1370 type: 'object',
1371 required: ['id'],
1372 properties: { id: { type: 'string' } },
1373 },
1374 body: {
1375 type: 'object',
1376 required: ['reason'],
1377 properties: {
1378 reason: { type: 'string', minLength: 1, maxLength: 1000 },
1379 },
1380 },
1381 response: {
1382 200: reportJsonSchema,
1383 400: errorResponseSchema,
1384 401: errorResponseSchema,
1385 403: errorResponseSchema,
1386 404: errorResponseSchema,
1387 409: errorResponseSchema,
1388 },
1389 },
1390 },
1391 async (request, reply) => {
1392 const communityDid = requireCommunityDid(request)
1393 const user = request.user
1394 if (!user) {
1395 return reply.status(401).send({ error: 'Authentication required' })
1396 }
1397
1398 const { id } = request.params as { id: string }
1399 const reportId = Number(id)
1400 if (Number.isNaN(reportId)) {
1401 throw badRequest('Invalid report ID')
1402 }
1403
1404 const parsed = appealReportSchema.safeParse(request.body)
1405 if (!parsed.success) {
1406 throw badRequest('Invalid appeal data')
1407 }
1408
1409 const existing = await db
1410 .select()
1411 .from(reports)
1412 .where(and(eq(reports.id, reportId), eq(reports.communityDid, communityDid)))
1413
1414 const report = existing[0]
1415 if (!report) {
1416 throw notFound('Report not found')
1417 }
1418
1419 if (report.reporterDid !== user.did) {
1420 throw forbidden('You can only appeal your own reports')
1421 }
1422
1423 if (report.status !== 'resolved') {
1424 throw badRequest('Can only appeal resolved reports')
1425 }
1426
1427 if (report.resolutionType !== 'dismissed') {
1428 throw badRequest('Can only appeal dismissed reports')
1429 }
1430
1431 if (report.appealStatus !== 'none') {
1432 throw conflict('Report has already been appealed')
1433 }
1434
1435 const updated = await db
1436 .update(reports)
1437 .set({
1438 appealReason: parsed.data.reason,
1439 appealedAt: new Date(),
1440 appealStatus: 'pending',
1441 status: 'pending',
1442 })
1443 .where(eq(reports.id, reportId))
1444 .returning()
1445
1446 const appealedReport = updated[0]
1447 if (!appealedReport) {
1448 throw notFound('Report not found after update')
1449 }
1450
1451 app.log.info({ reportId, reporterDid: user.did }, 'Report appealed')
1452
1453 return reply.status(200).send(serializeReport(appealedReport))
1454 }
1455 )
1456
1457 done()
1458 }
1459}