Barazo AppView backend barazo.forum
at main 994 lines 31 kB view raw
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}