@nexpress/core 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/{audit-43OLHR3U.js → audit-ZLNKBIDO.js} +3 -3
  2. package/dist/auth.js +4 -4
  3. package/dist/{can-UJ2NAOIR.js → can-U5F4JBZ7.js} +2 -2
  4. package/dist/{chunk-OMGQZ4Q5.js → chunk-2OWUHCFY.js} +2 -2
  5. package/dist/{chunk-OMGQZ4Q5.js.map → chunk-2OWUHCFY.js.map} +1 -1
  6. package/dist/{chunk-ELK6AVW5.js → chunk-2X3GBJOT.js} +2 -2
  7. package/dist/{chunk-HNX7COHQ.js → chunk-3SW4L3DL.js} +12 -12
  8. package/dist/chunk-3SW4L3DL.js.map +1 -0
  9. package/dist/{chunk-ML2E3P3X.js → chunk-5C22NDW4.js} +2 -2
  10. package/dist/chunk-5C22NDW4.js.map +1 -0
  11. package/dist/{chunk-RKM4GDWM.js → chunk-6MRTH734.js} +1 -1
  12. package/dist/chunk-6MRTH734.js.map +1 -0
  13. package/dist/{chunk-PW43RCJK.js → chunk-6OUWW6JF.js} +2 -2
  14. package/dist/{chunk-QBIJZZ5V.js → chunk-CGLJBRRX.js} +2 -2
  15. package/dist/chunk-CGLJBRRX.js.map +1 -0
  16. package/dist/{chunk-2VZZ7M26.js → chunk-EAYUAXW3.js} +3 -3
  17. package/dist/chunk-EAYUAXW3.js.map +1 -0
  18. package/dist/{chunk-2N53KKIL.js → chunk-EWVXP3GP.js} +2 -2
  19. package/dist/{chunk-CAS4Z6IN.js → chunk-I4FSVEJK.js} +1 -1
  20. package/dist/chunk-I4FSVEJK.js.map +1 -0
  21. package/dist/{chunk-LN6NTH6E.js → chunk-K4CJ3KXB.js} +3 -3
  22. package/dist/chunk-K4CJ3KXB.js.map +1 -0
  23. package/dist/{chunk-B7DTNT4O.js → chunk-MWLSXK6Y.js} +2 -2
  24. package/dist/{chunk-NFHS7CFV.js → chunk-Q7MK5ZKG.js} +2 -2
  25. package/dist/{chunk-PUV3VZPD.js → chunk-QZ52U4ET.js} +2 -2
  26. package/dist/{chunk-2GXH7566.js → chunk-SJ7M2VCC.js} +10 -10
  27. package/dist/chunk-SJ7M2VCC.js.map +1 -0
  28. package/dist/{chunk-6UV2P5MW.js → chunk-TIWJVQOO.js} +3 -3
  29. package/dist/chunk-TIWJVQOO.js.map +1 -0
  30. package/dist/{chunk-MLXKZK6G.js → chunk-TSCXXBOM.js} +76 -28
  31. package/dist/chunk-TSCXXBOM.js.map +1 -0
  32. package/dist/{chunk-L6VG7IK6.js → chunk-VBVLYFSZ.js} +2 -2
  33. package/dist/chunk-VBVLYFSZ.js.map +1 -0
  34. package/dist/{chunk-RDTTK27V.js → chunk-XPD7EQML.js} +3 -3
  35. package/dist/chunk-XPD7EQML.js.map +1 -0
  36. package/dist/{chunk-RJ76SKWQ.js → chunk-XU2GJJ6Z.js} +1 -1
  37. package/dist/chunk-XU2GJJ6Z.js.map +1 -0
  38. package/dist/{chunk-WJJ5MBH5.js → chunk-YEOQJ7WW.js} +1 -1
  39. package/dist/chunk-YEOQJ7WW.js.map +1 -0
  40. package/dist/community.js +14 -14
  41. package/dist/{config-YHUEYQ66.js → config-YDGNUDKP.js} +5 -5
  42. package/dist/{digest-ZODDTXA2.js → digest-IWHMJPXI.js} +4 -4
  43. package/dist/{host-XBGYIQEE.js → host-HG4QGD3L.js} +4 -4
  44. package/dist/i18n.js +2 -2
  45. package/dist/index.js +21 -21
  46. package/dist/index.js.map +1 -1
  47. package/dist/{job-log-N3IGI4NA.js → job-log-UY6ERPQZ.js} +3 -3
  48. package/dist/jobs.js +3 -3
  49. package/dist/{logger-2WUTTELV.js → logger-6ZGEKEMK.js} +2 -2
  50. package/dist/media.js +3 -3
  51. package/dist/{mentions-U4JACYI6.js → mentions-LQRZWAGO.js} +2 -2
  52. package/dist/{mutes-MNQP6ACF.js → mutes-PQA6U5X7.js} +2 -2
  53. package/dist/{notification-prefs-H4HFVCL7.js → notification-prefs-62NX2GBF.js} +2 -2
  54. package/dist/observability.js +2 -2
  55. package/dist/{reputation-ICIXDGPM.js → reputation-5DJLDBZY.js} +3 -3
  56. package/dist/{scheduled-S6IO47JD.js → scheduled-C2IKVZVK.js} +5 -5
  57. package/dist/seo.js +4 -4
  58. package/dist/{settings-OZWM6L2K.js → settings-NBAP7E5E.js} +2 -2
  59. package/dist/{strings-4EWJYDOG.js → strings-O2M7VSKV.js} +3 -3
  60. package/package.json +1 -1
  61. package/dist/chunk-2GXH7566.js.map +0 -1
  62. package/dist/chunk-2VZZ7M26.js.map +0 -1
  63. package/dist/chunk-6UV2P5MW.js.map +0 -1
  64. package/dist/chunk-CAS4Z6IN.js.map +0 -1
  65. package/dist/chunk-HNX7COHQ.js.map +0 -1
  66. package/dist/chunk-L6VG7IK6.js.map +0 -1
  67. package/dist/chunk-LN6NTH6E.js.map +0 -1
  68. package/dist/chunk-ML2E3P3X.js.map +0 -1
  69. package/dist/chunk-MLXKZK6G.js.map +0 -1
  70. package/dist/chunk-QBIJZZ5V.js.map +0 -1
  71. package/dist/chunk-RDTTK27V.js.map +0 -1
  72. package/dist/chunk-RJ76SKWQ.js.map +0 -1
  73. package/dist/chunk-RKM4GDWM.js.map +0 -1
  74. package/dist/chunk-WJJ5MBH5.js.map +0 -1
  75. /package/dist/{audit-43OLHR3U.js.map → audit-ZLNKBIDO.js.map} +0 -0
  76. /package/dist/{can-UJ2NAOIR.js.map → can-U5F4JBZ7.js.map} +0 -0
  77. /package/dist/{chunk-ELK6AVW5.js.map → chunk-2X3GBJOT.js.map} +0 -0
  78. /package/dist/{chunk-PW43RCJK.js.map → chunk-6OUWW6JF.js.map} +0 -0
  79. /package/dist/{chunk-2N53KKIL.js.map → chunk-EWVXP3GP.js.map} +0 -0
  80. /package/dist/{chunk-B7DTNT4O.js.map → chunk-MWLSXK6Y.js.map} +0 -0
  81. /package/dist/{chunk-NFHS7CFV.js.map → chunk-Q7MK5ZKG.js.map} +0 -0
  82. /package/dist/{chunk-PUV3VZPD.js.map → chunk-QZ52U4ET.js.map} +0 -0
  83. /package/dist/{config-YHUEYQ66.js.map → config-YDGNUDKP.js.map} +0 -0
  84. /package/dist/{digest-ZODDTXA2.js.map → digest-IWHMJPXI.js.map} +0 -0
  85. /package/dist/{host-XBGYIQEE.js.map → host-HG4QGD3L.js.map} +0 -0
  86. /package/dist/{job-log-N3IGI4NA.js.map → job-log-UY6ERPQZ.js.map} +0 -0
  87. /package/dist/{logger-2WUTTELV.js.map → logger-6ZGEKEMK.js.map} +0 -0
  88. /package/dist/{mentions-U4JACYI6.js.map → mentions-LQRZWAGO.js.map} +0 -0
  89. /package/dist/{mutes-MNQP6ACF.js.map → mutes-PQA6U5X7.js.map} +0 -0
  90. /package/dist/{notification-prefs-H4HFVCL7.js.map → notification-prefs-62NX2GBF.js.map} +0 -0
  91. /package/dist/{reputation-ICIXDGPM.js.map → reputation-5DJLDBZY.js.map} +0 -0
  92. /package/dist/{scheduled-S6IO47JD.js.map → scheduled-C2IKVZVK.js.map} +0 -0
  93. /package/dist/{settings-OZWM6L2K.js.map → settings-NBAP7E5E.js.map} +0 -0
  94. /package/dist/{strings-4EWJYDOG.js.map → strings-O2M7VSKV.js.map} +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/community/profiles.ts","../src/community/markdown.ts","../src/community/comments.ts","../src/community/reactions.ts","../src/community/follows.ts","../src/community/principal.ts","../src/community/reports.ts","../src/community/bans.ts","../src/community/grants.ts","../src/community/member-admin.ts","../src/collections/revisions.ts","../src/collections/pending-queue.ts","../src/collections/search-api.ts","../src/collections/search-adapter.ts","../src/collections/translations.ts"],"sourcesContent":["import { and, eq, inArray, ne, or } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers } from \"../db/schema/community.js\";\nimport { getMediaUrl } from \"../media/url.js\";\n\n/**\n * Public-facing member profile. Hand-picked from `np_members` to\n * exclude PII (email, password hash, login attempts, reset tokens,\n * notification prefs, plugin meta) — page authors building public\n * surfaces (`/u/[handle]` etc.) get a safe-to-render shape without\n * having to remember which columns are sensitive.\n *\n * Suspended / deleted members are filtered out — calling\n * `getMemberProfile` for a hidden member returns `null`. The\n * \"imported\" status (Phase 21 WordPress-import provisional members)\n * IS exposed because those profiles are visible on the public site\n * by design. Bans are a separate, scope-based concept (`np_bans`)\n * and don't hide the profile shell — they restrict posting; the\n * profile page itself stays reachable like Reddit / Discourse.\n */\nexport interface NpMemberProfile {\n id: string;\n handle: string;\n displayName: string;\n avatarUrl: string | null;\n bio: string | null;\n reputation: number;\n joinedAt: Date;\n}\n\n/**\n * Fetch a public member profile by id or handle.\n *\n * Resolves the avatar to a public URL (via `getMediaUrl`) so the\n * caller doesn't need to know about the storage adapter. Pass an\n * explicit `variant` to fetch a sized avatar — defaults to\n * `\"thumbnail\"` since profile cards typically render at small\n * sizes. Pass `\"original\"` for the full avatar (e.g. on the\n * profile detail page itself).\n *\n * Returns `null` when:\n * - no row matches the id / handle,\n * - the member's status is `suspended` or `deleted` (treat as\n * \"not found\" for public surfaces).\n */\nexport async function getMemberProfile(\n idOrHandle: string,\n options: {\n avatarVariant?: \"original\" | \"thumbnail\" | \"small\" | \"medium\" | \"large\" | (string & {});\n } = {},\n): Promise<NpMemberProfile | null> {\n if (typeof idOrHandle !== \"string\" || idOrHandle.length === 0) return null;\n\n // Handles are stored lowercase by `api/members/register`; URL\n // segments can come in any case (`/u/HANDLE` should resolve the\n // same as `/u/handle`). Lowercasing the input is also a no-op\n // for UUIDs — they're stored lowercase too — so we don't need\n // to detect which form the caller passed.\n const needle = idOrHandle.toLowerCase();\n const db = getDb();\n\n // Match either id or handle in one query — we don't know which\n // form the caller has and a UUID-shape check would fail for\n // imported / synthetic ids that don't match the v4 pattern.\n const rows = await db\n .select({\n id: npMembers.id,\n handle: npMembers.handle,\n displayName: npMembers.displayName,\n avatarId: npMembers.avatar,\n bio: npMembers.bio,\n reputation: npMembers.reputation,\n status: npMembers.status,\n createdAt: npMembers.createdAt,\n })\n .from(npMembers)\n .where(\n and(\n or(eq(npMembers.id, needle), eq(npMembers.handle, needle)),\n ne(npMembers.status, \"suspended\"),\n ne(npMembers.status, \"deleted\"),\n ),\n )\n .limit(1);\n\n const row = rows[0];\n if (!row) return null;\n\n const avatarUrl = row.avatarId\n ? await getMemberAvatarUrl(row.avatarId, options.avatarVariant ?? \"thumbnail\")\n : null;\n\n return {\n id: row.id,\n handle: row.handle,\n displayName: row.displayName,\n avatarUrl,\n bio: row.bio ?? null,\n reputation: row.reputation,\n joinedAt: row.createdAt,\n };\n}\n\nasync function getMemberAvatarUrl(\n mediaId: string,\n variant: string,\n): Promise<string | null> {\n try {\n return await getMediaUrl(mediaId, { variant });\n } catch {\n // Storage adapter not initialized in this context — non-fatal\n // for profile rendering; just omit the avatar.\n return null;\n }\n}\n\n/**\n * Batch variant of `getMemberProfile` for listings (discussion\n * indexes, comment threads, follower lists, …). Single SELECT\n * for the rows; avatar URLs resolve in parallel via `Promise.all`.\n *\n * The caller passes member IDs (the `memberAuthorId` /\n * `memberId` foreign keys most listing rows already carry).\n * Handle-based batches aren't supported — list rows that\n * reference a handle and not an id are rare; pass IDs.\n *\n * Returns a `Map<id, NpMemberProfile>` with one entry per id\n * that matched (suspended / deleted members are dropped, so the\n * map size may be smaller than the input). Order isn't preserved\n * because callers typically use `byId.get(row.memberId)` per row\n * rather than a parallel array.\n *\n * Empty input → empty map (no DB query).\n */\nexport async function getMemberProfiles(\n ids: readonly string[],\n options: {\n avatarVariant?: \"original\" | \"thumbnail\" | \"small\" | \"medium\" | \"large\" | (string & {});\n } = {},\n): Promise<Map<string, NpMemberProfile>> {\n const result = new Map<string, NpMemberProfile>();\n if (ids.length === 0) return result;\n // Dedupe — listing pages often have the same author repeated\n // across rows.\n const unique = Array.from(new Set(ids.filter((id) => typeof id === \"string\" && id.length > 0)));\n if (unique.length === 0) return result;\n\n const db = getDb();\n const rows = await db\n .select({\n id: npMembers.id,\n handle: npMembers.handle,\n displayName: npMembers.displayName,\n avatarId: npMembers.avatar,\n bio: npMembers.bio,\n reputation: npMembers.reputation,\n status: npMembers.status,\n createdAt: npMembers.createdAt,\n })\n .from(npMembers)\n .where(\n and(\n inArray(npMembers.id, unique),\n ne(npMembers.status, \"suspended\"),\n ne(npMembers.status, \"deleted\"),\n ),\n );\n\n const variant = options.avatarVariant ?? \"thumbnail\";\n await Promise.all(\n rows.map(async (row) => {\n const avatarUrl = row.avatarId\n ? await getMemberAvatarUrl(row.avatarId, variant)\n : null;\n result.set(row.id, {\n id: row.id,\n handle: row.handle,\n displayName: row.displayName,\n avatarUrl,\n bio: row.bio ?? null,\n reputation: row.reputation,\n joinedAt: row.createdAt,\n });\n }),\n );\n\n return result;\n}\n","/**\n * Tiny safe markdown renderer for comment bodies. Deliberately minimal:\n * we escape every byte first, then pattern-match a small set of inline\n * + block constructs so the output HTML can only ever contain the\n * limited tag set listed below. No raw HTML pass-through, ever.\n *\n * Supported:\n * - Bold `**text**` → `<strong>text</strong>`\n * - Italic `*text*` → `<em>text</em>`\n * - Inline code `` `code` `` → `<code>code</code>`\n * - Code block ``` … ``` → `<pre><code>…</code></pre>`\n * - Link `[t](url)` → `<a href=\"url\" rel=\"…\">t</a>`\n * (URL must start with http://, https://, or mailto:)\n * - Paragraph break: blank line\n * - Hard break single \\n → `<br/>`\n *\n * NOT supported (deliberate, to keep the renderer tight + safe):\n * raw HTML, headings, lists, blockquotes, images, tables. If a\n * site needs richer formatting, plug `marked` + `dompurify` here\n * without changing the public function shape.\n */\n\nconst URL_RE = /^(?:https?:\\/\\/|mailto:)[^\\s)]+$/;\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\n}\n\nfunction renderInline(text: string): string {\n // Escape the entire chunk first so any subsequent replacements only\n // produce HTML we explicitly emit. Order matters: code spans absorb\n // anything inside them so they have to win first.\n let html = escapeHtml(text);\n\n // Inline code: `code`. Greedy single-backtick pairs only.\n html = html.replace(/`([^`\\n]+?)`/g, (_match, code) => `<code>${code}</code>`);\n\n // Bold: **text** — applied before italic so the `**` patterns aren't\n // greedily eaten by the italic regex.\n html = html.replace(/\\*\\*([^*\\n][^*\\n]*?)\\*\\*/g, \"<strong>$1</strong>\");\n\n // Italic: *text* — must not be adjacent to whitespace on either side\n // (CommonMark behavior). We match `*` followed by a non-space group\n // ending in a non-space.\n html = html.replace(/\\*(\\S(?:[^*\\n]*\\S)?)\\*/g, \"<em>$1</em>\");\n\n // Links: [text](url) — URL must already match the allow-list.\n html = html.replace(/\\[([^\\]\\n]+?)\\]\\(([^)\\n]+?)\\)/g, (_match, label, rawUrl) => {\n if (!URL_RE.test(rawUrl)) return `[${label}](${rawUrl})`;\n return `<a href=\"${rawUrl}\" rel=\"nofollow ugc\" target=\"_blank\">${label}</a>`;\n });\n\n // Hard breaks within a paragraph.\n html = html.replace(/\\n/g, \"<br/>\");\n\n return html;\n}\n\n/**\n * Render a comment body markdown source to safe HTML. Pure function;\n * idempotent; safe to call on the write path AND on display (we still\n * persist the rendered version to avoid re-rendering on every read).\n */\nexport function renderCommentMarkdown(source: string): string {\n if (!source) return \"\";\n\n const blocks: string[] = [];\n let cursor = 0;\n const fenceRe = /```([\\s\\S]*?)```/g;\n\n // Pull out fenced code blocks first so their contents don't get\n // mangled by inline rules. Render them as <pre><code> with the\n // contents HTML-escaped (no language highlighting in v1).\n let match: RegExpExecArray | null;\n while ((match = fenceRe.exec(source)) !== null) {\n const before = source.slice(cursor, match.index);\n if (before) blocks.push(renderTextBlocks(before));\n blocks.push(`<pre><code>${escapeHtml(match[1] ?? \"\")}</code></pre>`);\n cursor = match.index + match[0].length;\n }\n const tail = source.slice(cursor);\n if (tail) blocks.push(renderTextBlocks(tail));\n\n return blocks.join(\"\\n\").trim();\n}\n\n/** Splits a chunk on blank lines and renders each as a `<p>`. */\nfunction renderTextBlocks(chunk: string): string {\n return chunk\n .split(/\\n{2,}/)\n .map((para) => para.trim())\n .filter(Boolean)\n .map((para) => `<p>${renderInline(para)}</p>`)\n .join(\"\\n\");\n}\n","import { and, asc, count, desc, eq, notInArray, sql, type SQL } from \"drizzle-orm\";\n\nimport { getCollectionConfig } from \"../collections/registry.js\";\nimport { getDocumentById } from \"../collections/pipeline.js\";\nimport { getDb } from \"../db/runtime.js\";\nimport { npComments, npMembers, npReactions } from \"../db/schema/community.js\";\nimport { NpForbiddenError, NpNotFoundError, NpValidationError } from \"../errors.js\";\n\nimport { getLogger } from \"../observability/logger.js\";\n\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\nimport { getCurrentSiteId, requireSiteId } from \"../sites/context.js\";\n\nimport { recordAuditEvent } from \"./audit.js\";\nimport { memberCan, withMemberWrite } from \"./can.js\";\nimport { renderCommentMarkdown } from \"./markdown.js\";\nimport { extractMentionHandles, fanOutMentionNotifications } from \"./mentions.js\";\nimport { getMutedTargetIds } from \"./mutes.js\";\nimport { createNotification } from \"./notifications.js\";\nimport { getProfanityAdapter } from \"./profanity-adapter.js\";\nimport { applyReputation } from \"./reputation.js\";\nimport { getSpamAdapter } from \"./spam-adapter.js\";\n\n/**\n * Service layer for `np_comments`. Routes call into here so the\n * permission gate (`memberCan`) and the markdown render are\n * consistent across HTTP, the admin UI, and any future plugin\n * surface.\n *\n * Comments are gated by collection config: `community.comments === true`\n * on the target collection or every write returns 400.\n */\n\nconst MAX_BODY_LENGTH = 5000;\n\nexport type CommentStatus = \"visible\" | \"pending\" | \"hidden\" | \"deleted\";\n\nexport interface NpCommentRow {\n id: string;\n targetType: string;\n targetId: string;\n parentId: string | null;\n memberId: string;\n bodyMd: string;\n bodyHtml: string;\n status: CommentStatus;\n hiddenReason: string | null;\n editedAt: Date | null;\n /** Tenant the comment belongs to. Phase 18 added the column; the type was incomplete until #364. */\n siteId: string;\n createdAt: Date;\n /**\n * Phase 21.11 — author's `np_members.status` at read time.\n * `listComments` joins against `np_members` so callers can render\n * a `(imported)` badge without a second round trip. Older callers\n * that don't read this field stay unaffected — the column is\n * nullable on the type because the underlying join is `LEFT JOIN`\n * and `createComment` returns the row before the join is wired.\n */\n authorStatus?: string | null;\n}\n\nexport interface NpCommentCreateInput {\n targetType: string;\n targetId: string;\n parentId?: string | null;\n memberId: string;\n bodyMd: string;\n}\n\nfunction assertCollectionAcceptsComments(slug: string): void {\n const config = getCollectionConfig(slug);\n if (!config.community?.comments) {\n throw new NpValidationError(\"Comments disabled\", [\n {\n field: \"collection\",\n message: `Collection \"${slug}\" does not accept comments. Set community.comments=true on the collection config.`,\n },\n ]);\n }\n}\n\nfunction validateBody(bodyMd: string): void {\n const trimmed = bodyMd.trim();\n if (trimmed.length === 0) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"bodyMd\", message: \"Comment body required\" },\n ]);\n }\n if (trimmed.length > MAX_BODY_LENGTH) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"bodyMd\", message: `Comment body must be ≤ ${MAX_BODY_LENGTH} characters` },\n ]);\n }\n}\n\nfunction commentScopes(row: { targetType: string }): Array<{ type: \"collection\"; id: string }> {\n // The only scope a comment carries today is its target collection.\n // If a thread schema is ever added, this is where `category` /\n // `thread` would join the chain so category-mod / thread-author\n // grants resolve.\n return [{ type: \"collection\", id: row.targetType }];\n}\n\nexport async function createComment(input: NpCommentCreateInput): Promise<NpCommentRow> {\n validateBody(input.bodyMd);\n assertCollectionAcceptsComments(input.targetType);\n\n // #311 — withMemberWrite enforces the ban gate by structure.\n // Site-wide bans block every comment; collection-scoped bans\n // block writes to that collection (#53 — without the gate,\n // banned members kept commenting because createComment never\n // went through memberCan).\n return withMemberWrite(\n input.memberId,\n [{ type: \"collection\", id: input.targetType }],\n async () => doCreateComment(input),\n );\n}\n\nasync function doCreateComment(input: NpCommentCreateInput): Promise<NpCommentRow> {\n // Target document must actually exist. Without this guard, members\n // could insert orphan comment rows under random UUIDs for any\n // comment-enabled collection (#49). We use the public read path\n // (`undefined` user = anonymous) so the comment-creation surface\n // matches what's publicly visible — comments under a draft would\n // be filtered out of the rendered site anyway.\n const targetDoc = await getDocumentById(input.targetType, input.targetId);\n if (!targetDoc) {\n throw new NpNotFoundError(input.targetType, input.targetId);\n }\n\n // Issue #215 — reject cross-tenant writes. A member on site A\n // shouldn't be able to comment on site B's content just by\n // passing B's document UUID. Compare the target doc's\n // canonical `siteId` to the request resolver's site; bail\n // early before the locked / parent / spam / profanity passes\n // run so we don't log adapter calls on rejected requests.\n const requestSiteId = await getCurrentSiteId();\n if (\n requestSiteId &&\n typeof targetDoc.siteId === \"string\" &&\n targetDoc.siteId !== requestSiteId\n ) {\n throw new NpForbiddenError(\"comment\", \"cross-site\");\n }\n\n // Forum-style \"locked\" guard: collections that opted into a `locked`\n // checkbox on their schema (e.g. `defineDiscussionsCollection`) flip\n // it to true to prevent new comments. The flag lives at the document\n // level, not the collection level — different threads in the same\n // collection can be locked independently. (#47)\n if (targetDoc.locked === true) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"targetId\", message: \"This thread is locked and does not accept new comments.\" },\n ]);\n }\n\n // Parent thread sanity: if `parentId` is set, the parent must exist,\n // target the same collection + document, and BE VISIBLE. Cross-doc\n // replies are disallowed so a reply can't smuggle itself into a\n // different thread; replies under non-visible parents are\n // disallowed because:\n // - hidden — a mod took the parent down; new replies under\n // it would resurrect the thread on the public list\n // - deleted — the body was erased; threading children below\n // a tombstone leaks the parent's deletion to readers\n // - pending — the parent itself is awaiting moderation; the\n // reply would publish under content the site hasn't accepted\n // yet (and would fire a `comment.reply` notification to an\n // author whose own comment is still pending) — see #127\n const db = getDb();\n let parentAuthorId: string | null = null;\n if (input.parentId) {\n const [parent] = (await db\n .select({\n id: npComments.id,\n targetType: npComments.targetType,\n targetId: npComments.targetId,\n memberId: npComments.memberId,\n status: npComments.status,\n })\n .from(npComments)\n .where(eq(npComments.id, input.parentId))\n .limit(1)) as Array<{\n id: string;\n targetType: string;\n targetId: string;\n memberId: string;\n status: CommentStatus;\n }>;\n if (!parent) {\n throw new NpNotFoundError(\"comment\", input.parentId);\n }\n if (parent.targetType !== input.targetType || parent.targetId !== input.targetId) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"parentId\", message: \"Parent comment belongs to a different document\" },\n ]);\n }\n if (parent.status !== \"visible\") {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"parentId\",\n message: `Cannot reply to a comment with status '${parent.status}'`,\n },\n ]);\n }\n parentAuthorId = parent.memberId;\n }\n\n // Two adapters run in sequence: profanity (language-level) first,\n // then spam (intent-level). If profanity rejects we short-circuit\n // — no point billing the spam adapter's network call when the\n // content is already gone. Verdicts combine with the strongest-\n // wins rule: any reject → reject, any flag → pending, both pass\n // → visible.\n //\n // Fail-open on adapter throw (network blip, 5xx, timeout) — sites\n // that want fail-closed wrap their own adapter and return\n // `reject` on errors. Mirrors the doc-create policy.\n const ctx = {\n memberId: input.memberId,\n targetType: input.targetType,\n targetId: input.targetId,\n parentId: input.parentId ?? null,\n };\n let profanityVerdict;\n try {\n profanityVerdict = await getProfanityAdapter().check(input.bodyMd, ctx);\n } catch (err) {\n getLogger().warn(\"profanity adapter threw — treating as pass\", {\n error: err instanceof Error ? err.message : String(err),\n targetType: input.targetType,\n targetId: input.targetId,\n });\n profanityVerdict = { kind: \"pass\" as const };\n }\n if (profanityVerdict.kind === \"reject\") {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"bodyMd\",\n message: profanityVerdict.reason ?? \"Comment contains prohibited language\",\n },\n ]);\n }\n let spamVerdict;\n try {\n spamVerdict = await getSpamAdapter().check(input.bodyMd, ctx);\n } catch (err) {\n getLogger().warn(\"spam adapter threw — treating as pass\", {\n error: err instanceof Error ? err.message : String(err),\n targetType: input.targetType,\n targetId: input.targetId,\n });\n spamVerdict = { kind: \"pass\" as const };\n }\n if (spamVerdict.kind === \"reject\") {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"bodyMd\",\n message: spamVerdict.reason ?? \"Comment was rejected by the site's spam filter\",\n },\n ]);\n }\n const flaggedBy: Array<\"profanity\" | \"spam\"> = [];\n if (profanityVerdict.kind === \"flag\") flaggedBy.push(\"profanity\");\n if (spamVerdict.kind === \"flag\") flaggedBy.push(\"spam\");\n const initialStatus: CommentStatus = flaggedBy.length > 0 ? \"pending\" : \"visible\";\n\n const html = renderCommentMarkdown(input.bodyMd);\n // Phase 18 — derive site_id from the target document, which\n // already carries the canonical site (collections gained\n // `site_id` in Phase 15). Falls back to the request resolver\n // and finally to the default site so legacy single-tenant\n // tests / scripts (which don't seed a site_id on the target)\n // still produce a valid row.\n const targetSiteId =\n typeof targetDoc.siteId === \"string\" && targetDoc.siteId.length > 0\n ? targetDoc.siteId\n : ((await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID);\n const [row] = (await db\n .insert(npComments)\n .values({\n targetType: input.targetType,\n targetId: input.targetId,\n parentId: input.parentId ?? null,\n memberId: input.memberId,\n bodyMd: input.bodyMd,\n bodyHtml: html,\n status: initialStatus,\n siteId: targetSiteId,\n })\n .returning()) as Array<NpCommentRow>;\n if (!row) throw new Error(\"Comment insert returned no row\");\n\n if (flaggedBy.length > 0) {\n // Surface flagged content in the audit log so mods can triage.\n // Recorded as a member-actor event so it threads with other\n // member-originated audit entries on the comment. The `sources`\n // array tells mods which adapter(s) flagged the row — useful\n // when a site runs both profanity and spam and wants to know\n // which signal to tune.\n await recordAuditEvent({\n actor: { kind: \"member\", memberId: input.memberId },\n action: \"comment.flag\",\n targetType: \"comment\",\n targetId: row.id,\n payload: {\n sources: flaggedBy,\n profanity:\n profanityVerdict.kind === \"flag\"\n ? {\n reason: profanityVerdict.reason ?? null,\n metadata: profanityVerdict.metadata ?? null,\n }\n : null,\n spam:\n spamVerdict.kind === \"flag\"\n ? {\n reason: spamVerdict.reason ?? null,\n metadata: spamVerdict.metadata ?? null,\n }\n : null,\n },\n });\n }\n\n // Reputation: only credit visible comments. Flagged content waits\n // for a mod restore — at that point the moderation surface can\n // decide whether to retroactively credit (not done in v1).\n if (initialStatus === \"visible\") {\n await applyReputation(input.memberId, {\n kind: \"comment.created\",\n commentId: row.id,\n memberId: input.memberId,\n targetType: input.targetType,\n targetId: input.targetId,\n });\n }\n\n // Reply notification — fire-and-forget. Self-replies don't notify.\n // Pending (spam-flagged) comments don't notify either: surfacing a\n // notification for content the public list won't render is just\n // confusing. If a mod later restores the row to visible, that's\n // when it makes sense to notify; the moderation surface owns that\n // decision.\n if (initialStatus === \"visible\" && parentAuthorId && parentAuthorId !== input.memberId) {\n await createNotification({\n memberId: parentAuthorId,\n kind: \"comment.reply\",\n actorMemberId: input.memberId,\n payload: {\n commentId: row.id,\n replyAuthorId: input.memberId,\n targetType: input.targetType,\n targetId: input.targetId,\n },\n });\n }\n\n // Phase 16.2 — @mention fan-out. Skipped on pending rows (same\n // reason as the reply notification: don't notify on content the\n // public can't see yet). Parent author already received\n // `comment.reply` so we exclude them to avoid two pings for one\n // comment.\n if (initialStatus === \"visible\") {\n const exclude = new Set<string>();\n if (parentAuthorId) exclude.add(parentAuthorId);\n await fanOutMentionNotifications({\n actorMemberId: input.memberId,\n kind: \"comment.mention\",\n source: input.bodyMd,\n exclude,\n payload: {\n commentId: row.id,\n targetType: input.targetType,\n targetId: input.targetId,\n },\n });\n }\n\n return row;\n}\n\n/**\n * Comment ordering options.\n *\n * - `newest` — created_at DESC (default; matches the\n * surface a fresh thread should show)\n * - `oldest` — created_at ASC (chronological reads)\n * - `top` — reactions DESC, then created_at DESC as\n * tiebreaker. Useful for high-traffic threads where the\n * \"best\" comment should bubble up regardless of when\n * it was posted.\n */\nexport type NpCommentSort = \"newest\" | \"oldest\" | \"top\";\n\nexport interface NpCommentListOptions {\n /** Default 50, max 200. */\n limit?: number;\n /** Default 0. */\n offset?: number;\n /** Newest first by default. */\n order?: NpCommentSort;\n /** Override visibility — staff/mods may want to see hidden rows. */\n includeHidden?: boolean;\n /**\n * Phase 16.1 — when set, the viewer's mute list is applied\n * so authors they've muted disappear from the result. The\n * filter only kicks in for the logged-in viewer; anonymous\n * viewers see every visible comment.\n */\n viewerMemberId?: string;\n}\n\nexport interface NpCommentListResult {\n comments: NpCommentRow[];\n totalDocs: number;\n}\n\nexport async function listComments(\n targetType: string,\n targetId: string,\n options: NpCommentListOptions = {},\n): Promise<NpCommentListResult> {\n const db = getDb();\n const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);\n const offset = Math.max(options.offset ?? 0, 0);\n const order = options.order ?? \"newest\";\n\n // Phase 16.1 — apply viewer mute list as a NOT IN clause.\n // We resolve the muted ids once per call (single SELECT\n // bounded by the viewer's own mute list); for the typical\n // member with a handful of mutes the cost is trivial.\n // Empty mute list short-circuits — no NOT IN clause is\n // appended.\n const mutedAuthorIds: string[] = options.viewerMemberId\n ? Array.from(await getMutedTargetIds(options.viewerMemberId))\n : [];\n const muteFilter: SQL | undefined =\n mutedAuthorIds.length > 0 ? notInArray(npComments.memberId, mutedAuthorIds) : undefined;\n\n const baseWhere = options.includeHidden\n ? and(eq(npComments.targetType, targetType), eq(npComments.targetId, targetId))\n : sql`${eq(npComments.targetType, targetType)} and ${eq(npComments.targetId, targetId)} and ${eq(npComments.status, \"visible\")}`;\n\n const where = muteFilter ? and(baseWhere, muteFilter) : baseWhere;\n\n // `top` orders by reaction count via a correlated subquery,\n // then created_at DESC as a stable tiebreaker. The subquery\n // is bounded by the page size (limit 200 max), so the cost\n // stays linear in returned-row count rather than total\n // reactions across the table.\n const orderBy: SQL =\n order === \"top\"\n ? sql`(SELECT COUNT(*) FROM ${npReactions} WHERE ${npReactions.targetType} = 'comment' AND ${npReactions.targetId} = ${npComments.id}) DESC, ${npComments.createdAt} DESC`\n : order === \"oldest\"\n ? asc(npComments.createdAt)\n : desc(npComments.createdAt);\n\n // Phase 21.11 — LEFT JOIN against `np_members` so the response\n // carries the author's status (most callers want to render an\n // `(imported)` chip without a second round trip). The join is\n // bounded by `limit` (≤200), so the cost is the page-size lookup\n // rather than a table scan.\n const joinedRows = (await db\n .select({\n comment: npComments,\n authorStatus: npMembers.status,\n })\n .from(npComments)\n .leftJoin(npMembers, eq(npComments.memberId, npMembers.id))\n .where(where)\n .orderBy(orderBy)\n .limit(limit)\n .offset(offset)) as Array<{\n comment: NpCommentRow;\n authorStatus: string | null;\n }>;\n const rows: NpCommentRow[] = joinedRows.map(({ comment, authorStatus }) => ({\n ...comment,\n authorStatus,\n }));\n\n const [totalRow] = (await db.select({ total: count() }).from(npComments).where(where)) as Array<{\n total: number | string;\n }>;\n\n return { comments: rows, totalDocs: Number(totalRow?.total ?? 0) };\n}\n\nexport interface NpCommentUpdateInput {\n commentId: string;\n memberId: string;\n bodyMd: string;\n}\n\nexport async function updateComment(input: NpCommentUpdateInput): Promise<NpCommentRow> {\n validateBody(input.bodyMd);\n const db = getDb();\n const [existing] = (await db\n .select()\n .from(npComments)\n .where(eq(npComments.id, input.commentId))\n .limit(1)) as NpCommentRow[];\n if (!existing) throw new NpNotFoundError(\"comment\", input.commentId);\n\n // Reject edits to soft-deleted comments. `deleteComment` clears\n // `bodyMd`/`bodyHtml` to honor erasure expectations; allowing the\n // owner to edit-back content would defeat that and let moderation\n // views surface text the user expected to disappear. (#50)\n if (existing.status === \"deleted\") {\n throw new NpValidationError(\"Invalid state\", [\n { field: \"comment\", message: \"Cannot edit a deleted comment\" },\n ]);\n }\n\n // Owner edits via `edit-own`; mods via `edit-any-comment`.\n const ownerCan = await memberCan(input.memberId, \"edit-own\", {\n type: \"comment\",\n id: existing.id,\n ownerId: existing.memberId,\n scopes: commentScopes(existing),\n });\n const modCan = ownerCan\n ? false\n : await memberCan(input.memberId, \"edit-any-comment\", {\n type: \"comment\",\n id: existing.id,\n ownerId: existing.memberId,\n scopes: commentScopes(existing),\n });\n if (!ownerCan && !modCan) {\n throw new NpForbiddenError(\"comment\", \"update\");\n }\n\n // Re-run profanity → spam on the new body. Pre-fix `updateComment`\n // skipped moderation entirely, so a member could create a clean\n // visible comment then PATCH it to spam / banned language and\n // the row stayed visible. Mirrors the create-time gate (#123):\n // - reject → 400, no write\n // - flag → status forced to `pending` so mods triage the edit\n // - pass → status untouched\n // Mods don't get an automatic bypass; if a moderator needs to\n // commit otherwise-banned text intentionally (rare), they can\n // staff-restore the row afterward.\n const ctx = {\n memberId: input.memberId,\n targetType: existing.targetType,\n targetId: existing.targetId,\n parentId: existing.parentId,\n };\n let profanityFlag: { reason: string | null; metadata: Record<string, unknown> | null } | null =\n null;\n try {\n const verdict = await getProfanityAdapter().check(input.bodyMd, ctx);\n if (verdict.kind === \"reject\") {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"bodyMd\",\n message: verdict.reason ?? \"Comment contains prohibited language\",\n },\n ]);\n }\n if (verdict.kind === \"flag\") {\n profanityFlag = {\n reason: verdict.reason ?? null,\n metadata: verdict.metadata ?? null,\n };\n }\n } catch (err) {\n if (err instanceof NpValidationError) throw err;\n getLogger().warn(\"profanity adapter threw on comment edit — treating as pass\", {\n error: err instanceof Error ? err.message : String(err),\n commentId: input.commentId,\n });\n }\n let spamFlag: { reason: string | null; metadata: Record<string, unknown> | null } | null = null;\n try {\n const verdict = await getSpamAdapter().check(input.bodyMd, ctx);\n if (verdict.kind === \"reject\") {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"bodyMd\",\n message: verdict.reason ?? \"Comment was rejected by the site's spam filter\",\n },\n ]);\n }\n if (verdict.kind === \"flag\") {\n spamFlag = {\n reason: verdict.reason ?? null,\n metadata: verdict.metadata ?? null,\n };\n }\n } catch (err) {\n if (err instanceof NpValidationError) throw err;\n getLogger().warn(\"spam adapter threw on comment edit — treating as pass\", {\n error: err instanceof Error ? err.message : String(err),\n commentId: input.commentId,\n });\n }\n const editFlaggedBy: Array<\"profanity\" | \"spam\"> = [];\n if (profanityFlag) editFlaggedBy.push(\"profanity\");\n if (spamFlag) editFlaggedBy.push(\"spam\");\n\n const html = renderCommentMarkdown(input.bodyMd);\n const updateValues: Record<string, unknown> = {\n bodyMd: input.bodyMd,\n bodyHtml: html,\n editedAt: new Date(),\n };\n if (editFlaggedBy.length > 0) {\n updateValues.status = \"pending\";\n }\n const [updated] = (await db\n .update(npComments)\n .set(updateValues)\n .where(eq(npComments.id, input.commentId))\n .returning()) as NpCommentRow[];\n if (!updated) throw new Error(\"Comment update returned no row\");\n\n if (editFlaggedBy.length > 0) {\n await recordAuditEvent({\n actor: { kind: \"member\", memberId: input.memberId },\n action: \"comment.flag\",\n targetType: \"comment\",\n targetId: updated.id,\n payload: {\n event: \"update\",\n sources: editFlaggedBy,\n profanity: profanityFlag,\n spam: spamFlag,\n },\n });\n }\n\n // Phase 16.2 — @mention fan-out on edit. Only newly-added handles\n // notify (delta vs the prior body), so retoggling a single\n // unrelated word doesn't re-notify the same recipients. Skipped\n // on edits that flipped the row to `pending` (spam/profanity gate\n // matches the create-time policy: don't notify on content the\n // public can't see yet).\n if (updated.status === \"visible\") {\n const previousHandles = new Set(extractMentionHandles(existing.bodyMd));\n await fanOutMentionNotifications({\n actorMemberId: input.memberId,\n kind: \"comment.mention\",\n source: input.bodyMd,\n previousHandles,\n payload: {\n commentId: updated.id,\n targetType: existing.targetType,\n targetId: existing.targetId,\n },\n });\n }\n return updated;\n}\n\nexport interface NpCommentDeleteInput {\n commentId: string;\n memberId: string;\n}\n\nexport async function deleteComment(input: NpCommentDeleteInput): Promise<void> {\n const db = getDb();\n const [existing] = (await db\n .select()\n .from(npComments)\n .where(eq(npComments.id, input.commentId))\n .limit(1)) as NpCommentRow[];\n if (!existing) throw new NpNotFoundError(\"comment\", input.commentId);\n\n const ownerCan = await memberCan(input.memberId, \"delete-own\", {\n type: \"comment\",\n id: existing.id,\n ownerId: existing.memberId,\n scopes: commentScopes(existing),\n });\n const modCan = ownerCan\n ? false\n : await memberCan(input.memberId, \"delete-any-comment\", {\n type: \"comment\",\n id: existing.id,\n ownerId: existing.memberId,\n scopes: commentScopes(existing),\n });\n if (!ownerCan && !modCan) {\n throw new NpForbiddenError(\"comment\", \"delete\");\n }\n\n // Soft-delete: keep the row so reply chains stay intact and audit\n // can resolve \"who said what\" later. Body fields are blanked so the\n // text is actually gone from the read path.\n await db\n .update(npComments)\n .set({ status: \"deleted\", bodyMd: \"\", bodyHtml: \"\", editedAt: new Date() })\n .where(eq(npComments.id, input.commentId));\n}\n\nexport interface NpCommentHideInput {\n commentId: string;\n memberId: string;\n reason?: string | null;\n}\n\nexport async function hideComment(input: NpCommentHideInput): Promise<void> {\n const db = getDb();\n const [existing] = (await db\n .select()\n .from(npComments)\n .where(eq(npComments.id, input.commentId))\n .limit(1)) as NpCommentRow[];\n if (!existing) throw new NpNotFoundError(\"comment\", input.commentId);\n\n const ok = await memberCan(input.memberId, \"hide-comment\", {\n type: \"comment\",\n id: existing.id,\n ownerId: existing.memberId,\n scopes: commentScopes(existing),\n });\n if (!ok) throw new NpForbiddenError(\"comment\", \"hide\");\n\n await db\n .update(npComments)\n .set({\n status: \"hidden\",\n hiddenByMemberId: input.memberId,\n hiddenReason: input.reason ?? null,\n })\n .where(eq(npComments.id, input.commentId));\n\n await recordAuditEvent({\n actor: { kind: \"member\", memberId: input.memberId },\n action: \"comment.hide\",\n targetType: \"comment\",\n targetId: existing.id,\n payload: { reason: input.reason ?? null, collection: existing.targetType },\n });\n}\n\nexport interface NpCommentRestoreInput {\n commentId: string;\n memberId: string;\n}\n\nexport async function restoreComment(input: NpCommentRestoreInput): Promise<void> {\n const db = getDb();\n const [existing] = (await db\n .select()\n .from(npComments)\n .where(eq(npComments.id, input.commentId))\n .limit(1)) as NpCommentRow[];\n if (!existing) throw new NpNotFoundError(\"comment\", input.commentId);\n if (existing.status !== \"hidden\") {\n throw new NpValidationError(\"Invalid state\", [\n { field: \"status\", message: `Comment is \"${existing.status}\", not \"hidden\"` },\n ]);\n }\n\n const ok = await memberCan(input.memberId, \"restore-comment\", {\n type: \"comment\",\n id: existing.id,\n ownerId: existing.memberId,\n scopes: commentScopes(existing),\n });\n if (!ok) throw new NpForbiddenError(\"comment\", \"restore\");\n\n await db\n .update(npComments)\n .set({\n status: \"visible\",\n hiddenByUserId: null,\n hiddenByMemberId: null,\n hiddenReason: null,\n })\n .where(eq(npComments.id, input.commentId));\n\n await recordAuditEvent({\n actor: { kind: \"member\", memberId: input.memberId },\n action: \"comment.restore\",\n targetType: \"comment\",\n targetId: existing.id,\n payload: { collection: existing.targetType },\n });\n}\n\n/**\n * Staff-side helpers: bypass the member permission resolver entirely.\n * The API layer routes here when the principal is a staff user with\n * sufficient role (admin/editor/moderator). No `memberId` required;\n * the action is always allowed.\n */\n/**\n * Issue #364 — staff comment moderation was id-only. The list / read\n * paths were already site-scoped, but a staff user with the global\n * `community.moderate` capability and a foreign comment id could\n * hide / restore / delete content in another tenant. The loader now\n * pins the loaded row's `siteId` against the request site; callers\n * include `siteId` in their update predicate so the read-check and\n * the write cannot drift apart.\n */\nasync function loadCommentForStaffOp(commentId: string): Promise<{\n row: NpCommentRow;\n siteId: string;\n}> {\n const db = getDb();\n const [existing] = (await db\n .select()\n .from(npComments)\n .where(eq(npComments.id, commentId))\n .limit(1)) as NpCommentRow[];\n if (!existing) throw new NpNotFoundError(\"comment\", commentId);\n const requestSiteId = await requireSiteId();\n if (existing.siteId !== requestSiteId) {\n throw new NpForbiddenError(\"comment\", \"cross-site\");\n }\n return { row: existing, siteId: requestSiteId };\n}\n\nexport async function staffHideComment(\n commentId: string,\n staffUserId: string,\n reason?: string | null,\n): Promise<void> {\n const { row: existing, siteId } = await loadCommentForStaffOp(commentId);\n const db = getDb();\n await db\n .update(npComments)\n .set({\n status: \"hidden\",\n hiddenByUserId: staffUserId,\n hiddenByMemberId: null,\n hiddenReason: reason ?? null,\n })\n .where(and(eq(npComments.id, commentId), eq(npComments.siteId, siteId)));\n await recordAuditEvent({\n actor: { kind: \"staff\", userId: staffUserId },\n action: \"comment.hide\",\n targetType: \"comment\",\n targetId: commentId,\n payload: { reason: reason ?? null, byStaff: true },\n });\n // Hiding the comment usually penalizes the author. Adapters return\n // 0 if they don't want to (e.g. the hide is purely admin cleanup\n // that shouldn't affect reputation).\n await applyReputation(existing.memberId, {\n kind: \"comment.hidden\",\n commentId,\n memberId: existing.memberId,\n byStaff: true,\n reason: reason ?? null,\n });\n}\n\nexport async function staffRestoreComment(commentId: string, staffUserId: string): Promise<void> {\n const { row: existing, siteId } = await loadCommentForStaffOp(commentId);\n // A \"deleted\" comment had its body wiped — flipping it back to visible\n // would surface a ghost row (author + timestamp intact, body empty).\n // Only `hidden` is reversible; member-side `restoreComment` enforces\n // the same invariant.\n if (existing.status !== \"hidden\") {\n throw new NpValidationError(\"Invalid state\", [\n {\n field: \"status\",\n message: `Comment is \"${existing.status}\", not \"hidden\"`,\n },\n ]);\n }\n const db = getDb();\n await db\n .update(npComments)\n .set({\n status: \"visible\",\n hiddenByUserId: null,\n hiddenByMemberId: null,\n hiddenReason: null,\n })\n .where(and(eq(npComments.id, commentId), eq(npComments.siteId, siteId)));\n await recordAuditEvent({\n actor: { kind: \"staff\", userId: staffUserId },\n action: \"comment.restore\",\n targetType: \"comment\",\n targetId: commentId,\n payload: { byStaff: true },\n });\n}\n\nexport async function staffDeleteComment(commentId: string, staffUserId: string): Promise<void> {\n const { row: existing, siteId } = await loadCommentForStaffOp(commentId);\n const db = getDb();\n await db\n .update(npComments)\n .set({ status: \"deleted\", bodyMd: \"\", bodyHtml: \"\" })\n .where(and(eq(npComments.id, commentId), eq(npComments.siteId, siteId)));\n await recordAuditEvent({\n actor: { kind: \"staff\", userId: staffUserId },\n action: \"comment.delete\",\n targetType: \"comment\",\n targetId: commentId,\n payload: { byStaff: true },\n });\n await applyReputation(existing.memberId, {\n kind: \"comment.deleted\",\n commentId,\n memberId: existing.memberId,\n byStaff: true,\n });\n}\n","import { and, count, eq } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npComments, npReactions } from \"../db/schema/community.js\";\nimport { NpForbiddenError, NpNotFoundError, NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\nimport { withMemberWrite } from \"./can.js\";\nimport { type CommunityScope } from \"./roles.js\";\nimport { createNotification } from \"./notifications.js\";\nimport { applyReputation } from \"./reputation.js\";\nimport { getCommunitySettings } from \"./settings.js\";\n\n/**\n * Reactions service. `kind` is gated by both:\n * 1. `KIND_RE` — a syntactic check (lowercase token, ≤30 chars)\n * that runs on every add/remove call without a DB round-trip.\n * 2. The site's reaction allow-list, persisted in\n * `np_settings.community.reactionKinds` and edited from the\n * admin community settings page. v1 ships with `[\"like\"]` as\n * the only allowed kind. Removal is NOT gated against the\n * allow-list — if a site retires a reaction, members can still\n * undo their old reactions of that kind.\n */\n\nexport const DEFAULT_REACTION_KINDS = [\"like\"] as const;\nconst KIND_RE = /^[a-z][a-z0-9_-]{0,29}$/;\n\nexport interface NpReactionRow {\n id: string;\n targetType: string;\n targetId: string;\n memberId: string;\n kind: string;\n createdAt: Date;\n}\n\nexport interface NpReactToInput {\n targetType: string;\n targetId: string;\n memberId: string;\n kind: string;\n}\n\nfunction validateKind(kind: string): void {\n if (!KIND_RE.test(kind)) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"kind\",\n message: \"kind must match [a-z][a-z0-9_-]{0,29}\",\n },\n ]);\n }\n}\n\n/**\n * Adds a reaction. Idempotent: if `(target_type, target_id, member_id,\n * kind)` already exists, returns the existing row instead of bumping\n * the unique-constraint into an error. The first time a member reacts\n * to a comment we also fire a notification to the comment author.\n */\nexport async function addReaction(input: NpReactToInput): Promise<NpReactionRow> {\n validateKind(input.kind);\n\n const settings = await getCommunitySettings();\n if (!settings.reactionKinds.includes(input.kind)) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"kind\",\n message: `Reaction kind '${input.kind}' is not allowed on this site`,\n },\n ]);\n }\n\n // #311 — withMemberWrite enforces the ban gate by structure.\n // #340 — for comment targets we now derive the parent\n // collection so collection-scoped bans block reactions\n // consistently with comments. The lookup is a single PK\n // select; doAddReaction re-reads the row downstream for\n // siteId, but the redundancy is negligible vs. introducing\n // a one-call helper. Other target types (profile, etc.)\n // stay site-wide-only for now.\n const scopes = await deriveScopesFor(input);\n return withMemberWrite(input.memberId, scopes, async () => {\n return doAddReaction(input);\n });\n}\n\nasync function deriveScopesFor(\n input: NpReactToInput,\n): Promise<ReadonlyArray<{ type: CommunityScope; id: string }>> {\n if (input.targetType !== \"comment\") return [];\n const db = getDb();\n const [comment] = (await db\n .select({ targetType: npComments.targetType })\n .from(npComments)\n .where(eq(npComments.id, input.targetId))\n .limit(1)) as Array<{ targetType: string }>;\n if (!comment) return [];\n return [{ type: \"collection\", id: comment.targetType }];\n}\n\nasync function doAddReaction(input: NpReactToInput): Promise<NpReactionRow> {\n const db = getDb();\n\n // Phase 18 — derive site_id from the target so the reaction\n // is grouped with its target's tenant. Today only `comment`\n // targets are wired; the lookup is a single PK select. Falls\n // back to the request resolver / default site so legacy\n // single-tenant rows still get a valid value.\n //\n // Issue #215 — also enforce that the request's tenant matches\n // the target's. A member acting on site A can't react to\n // site B's content just by passing B's UUID; without this,\n // the reaction lands under B (correct grouping) but still\n // fans out a notification through A's request context, and\n // we expose no UI affordance that would justify the cross-\n // tenant call. Reject outright.\n const requestSiteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n let targetSiteId: string;\n if (input.targetType === \"comment\") {\n const [t] = (await db\n .select({ siteId: npComments.siteId })\n .from(npComments)\n .where(eq(npComments.id, input.targetId))\n .limit(1)) as Array<{ siteId: string }>;\n targetSiteId = t?.siteId ?? requestSiteId;\n } else {\n targetSiteId = requestSiteId;\n }\n if (targetSiteId !== requestSiteId) {\n throw new NpForbiddenError(\"reaction\", \"cross-site\");\n }\n\n // Idempotent insert via ON CONFLICT. The previous select-then-insert\n // pattern lost a race when two identical clicks arrived in parallel —\n // both selects found nothing, both inserts ran, one hit the unique\n // constraint with a 23505 surface as 500. (#48)\n //\n // `onConflictDoNothing` returns nothing for the conflict, so we\n // re-select the existing row when our insert was the loser. The\n // `inserted` flag tells us which path won — the notification only\n // fires when our insert actually created a new reaction, keeping\n // the \"first-time only\" semantic.\n const inserted = (await db\n .insert(npReactions)\n .values({\n targetType: input.targetType,\n targetId: input.targetId,\n memberId: input.memberId,\n kind: input.kind,\n siteId: targetSiteId,\n })\n .onConflictDoNothing()\n .returning()) as NpReactionRow[];\n\n let row: NpReactionRow;\n if (inserted.length > 0) {\n row = inserted[0]!;\n } else {\n const [existing] = (await db\n .select()\n .from(npReactions)\n .where(\n and(\n eq(npReactions.targetType, input.targetType),\n eq(npReactions.targetId, input.targetId),\n eq(npReactions.memberId, input.memberId),\n eq(npReactions.kind, input.kind),\n ),\n )\n .limit(1)) as NpReactionRow[];\n if (!existing) throw new Error(\"Reaction conflict but row not found\");\n return existing;\n }\n\n // Fan out a notification + apply reputation delta to the recipient.\n // Self-reactions are filtered for both — neither makes sense.\n if (input.targetType === \"comment\") {\n const [comment] = (await db\n .select({ memberId: npComments.memberId })\n .from(npComments)\n .where(eq(npComments.id, input.targetId))\n .limit(1)) as Array<{ memberId: string }>;\n if (comment && comment.memberId !== input.memberId) {\n await createNotification({\n memberId: comment.memberId,\n kind: \"reaction.received\",\n actorMemberId: input.memberId,\n payload: {\n reactorId: input.memberId,\n targetType: input.targetType,\n targetId: input.targetId,\n reactionKind: input.kind,\n },\n });\n await applyReputation(comment.memberId, {\n kind: \"reaction.received\",\n reactionKind: input.kind,\n recipientId: comment.memberId,\n reactorId: input.memberId,\n targetType: input.targetType,\n targetId: input.targetId,\n });\n }\n }\n\n return row;\n}\n\nexport async function removeReaction(input: NpReactToInput): Promise<void> {\n validateKind(input.kind);\n const db = getDb();\n // Look up the reaction's recipient BEFORE deleting so the\n // reputation event has the right context. We only emit\n // `reaction.removed` when there was actually something to remove\n // (i.e. the row existed and the reactor isn't the recipient).\n //\n // Issue #362 — also pin the request's tenant against the target's\n // and include `siteId` in the delete predicate. `addReaction`\n // already rejects cross-site adds; without the same gate here, a\n // member on site A could name a site B comment UUID and remove\n // their site B reaction (and apply the reputation reversal in the\n // wrong site context). The siteId in the predicate is\n // defence-in-depth: even if the pre-check passes against a stale\n // resolver value, the row only deletes when both ids agree.\n const requestSiteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n let recipientId: string | null = null;\n if (input.targetType === \"comment\") {\n const [comment] = (await db\n .select({ memberId: npComments.memberId, siteId: npComments.siteId })\n .from(npComments)\n .where(eq(npComments.id, input.targetId))\n .limit(1)) as Array<{ memberId: string; siteId: string }>;\n if (comment && comment.siteId !== requestSiteId) {\n throw new NpForbiddenError(\"reaction\", \"cross-site\");\n }\n if (comment && comment.memberId !== input.memberId) {\n recipientId = comment.memberId;\n }\n }\n\n // Use `.returning()` so we know whether the delete actually\n // removed a row — repeated/no-op DELETEs (e.g. a client re-trying\n // an unreact) must NOT emit a phantom `reaction.removed` event,\n // otherwise a member could drain a recipient's reputation by\n // hammering the endpoint without ever having reacted.\n const deleted = (await db\n .delete(npReactions)\n .where(\n and(\n eq(npReactions.targetType, input.targetType),\n eq(npReactions.targetId, input.targetId),\n eq(npReactions.memberId, input.memberId),\n eq(npReactions.kind, input.kind),\n eq(npReactions.siteId, requestSiteId),\n ),\n )\n .returning({ id: npReactions.id })) as Array<{ id: string }>;\n\n if (recipientId && deleted.length > 0) {\n await applyReputation(recipientId, {\n kind: \"reaction.removed\",\n reactionKind: input.kind,\n recipientId,\n reactorId: input.memberId,\n targetType: input.targetType,\n targetId: input.targetId,\n });\n }\n}\n\n/**\n * Per-target counts grouped by kind. Returns `{ like: 12 }`-style\n * objects; missing kinds are absent (caller defaults to 0).\n */\nexport async function countReactions(\n targetType: string,\n targetId: string,\n): Promise<Record<string, number>> {\n const db = getDb();\n const rows = (await db\n .select({ kind: npReactions.kind, total: count() })\n .from(npReactions)\n .where(and(eq(npReactions.targetType, targetType), eq(npReactions.targetId, targetId)))\n .groupBy(npReactions.kind)) as Array<{ kind: string; total: number | string }>;\n const out: Record<string, number> = {};\n for (const row of rows) out[row.kind] = Number(row.total);\n return out;\n}\n\n/**\n * Returns the kinds the given member has reacted with on a target.\n * Used by the site UI to render the like button as toggled-on.\n */\nexport async function listMemberReactions(\n targetType: string,\n targetId: string,\n memberId: string,\n): Promise<string[]> {\n const db = getDb();\n const rows = (await db\n .select({ kind: npReactions.kind })\n .from(npReactions)\n .where(\n and(\n eq(npReactions.targetType, targetType),\n eq(npReactions.targetId, targetId),\n eq(npReactions.memberId, memberId),\n ),\n )) as Array<{ kind: string }>;\n return rows.map((r) => r.kind);\n}\n\n/**\n * Internal helper — assert that the target exists for the given kind.\n * Today only `comment` is supported. The polymorphic shape leaves\n * room for `thread` / `reply` once a thread schema lands; the forum\n * plugin shipped without one (it reuses `np_comments` under the\n * `discussions` collection), so widening this surface is on hold\n * until a separate threads design.\n */\nexport async function assertReactableExists(targetType: string, targetId: string): Promise<void> {\n if (targetType !== \"comment\") {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"targetType\",\n message: `Reactions on '${targetType}' aren't supported yet — only 'comment' is wired today.`,\n },\n ]);\n }\n const db = getDb();\n const [comment] = (await db\n .select({ id: npComments.id, status: npComments.status })\n .from(npComments)\n .where(eq(npComments.id, targetId))\n .limit(1)) as Array<{ id: string; status: string }>;\n if (!comment) throw new NpNotFoundError(\"comment\", targetId);\n if (comment.status === \"deleted\") {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"targetId\", message: \"Cannot react to a deleted comment\" },\n ]);\n }\n}\n","import { and, eq } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npFollows, npMembers } from \"../db/schema/community.js\";\nimport { NpNotFoundError, NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\nimport { withMemberWrite } from \"./can.js\";\nimport { createNotification } from \"./notifications.js\";\n\n/**\n * Follow graph service. v1 supports `member` follows; `thread` and\n * `tag` lands when those subjects exist. Self-follow is rejected so\n * the recommended-follows / \"people you follow\" reads don't have to\n * special-case it.\n */\n\nconst SUPPORTED_TARGETS = [\"member\", \"thread\", \"tag\"] as const;\ntype FollowTarget = (typeof SUPPORTED_TARGETS)[number];\n\nexport interface NpFollowRow {\n id: string;\n followerId: string;\n targetType: string;\n targetId: string;\n createdAt: Date;\n}\n\nexport interface NpFollowInput {\n followerId: string;\n targetType: FollowTarget;\n targetId: string;\n}\n\nfunction assertSupportedTarget(targetType: string): asserts targetType is FollowTarget {\n if (!SUPPORTED_TARGETS.includes(targetType as FollowTarget)) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"targetType\",\n message: `targetType must be one of: ${SUPPORTED_TARGETS.join(\", \")}`,\n },\n ]);\n }\n}\n\nexport async function follow(input: NpFollowInput): Promise<NpFollowRow> {\n assertSupportedTarget(input.targetType);\n if (input.targetType === \"member\" && input.targetId === input.followerId) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"targetId\", message: \"Members can't follow themselves.\" },\n ]);\n }\n\n // #311 — withMemberWrite enforces the ban gate by structure: a\n // future write path that forgets the gate can't compile against\n // this helper. Site-wide bans block follows (no obvious scope\n // chain for a polymorphic follow target).\n return withMemberWrite(input.followerId, [], async () => {\n return doFollow(input);\n });\n}\n\nasync function doFollow(input: NpFollowInput): Promise<NpFollowRow> {\n\n const db = getDb();\n\n // Validate the target exists so a typo doesn't quietly insert a\n // dangling follow row. `thread` / `tag` targets had no validation\n // path because those subjects don't exist yet — that meant members\n // could spam the follow graph with arbitrary strings (#75). Until\n // those surfaces ship, refuse follows for them.\n if (input.targetType === \"member\") {\n const [target] = (await db\n .select({ id: npMembers.id, status: npMembers.status })\n .from(npMembers)\n .where(eq(npMembers.id, input.targetId))\n .limit(1)) as Array<{ id: string; status: string }>;\n if (!target) throw new NpNotFoundError(\"member\", input.targetId);\n if (target.status !== \"active\") {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"targetId\", message: \"Cannot follow a non-active member.\" },\n ]);\n }\n } else {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"targetType\",\n message: `Following ${input.targetType} targets is not supported yet`,\n },\n ]);\n }\n\n // Idempotent: insert with `onConflictDoNothing` so two concurrent\n // follow toggles don't surface a unique-constraint 500 to the\n // race-loser. The schema's `np_follows_unique` enforces\n // `(follower, targetType, targetId)` uniqueness — without\n // `onConflict` the loser of a race would bubble the raw\n // pg 23505 instead of the intended idempotent success (#124,\n // mirrors the reactions write path).\n // Phase 18 — site_id is part of the unique key now, so a\n // global member can hold parallel follow rows on different\n // tenants. The site comes from the request resolver (the\n // click happened on this tenant); falls back to the default\n // site for callers without a resolved site (scripts, jobs).\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const [inserted] = (await db\n .insert(npFollows)\n .values({\n followerId: input.followerId,\n targetType: input.targetType,\n targetId: input.targetId,\n siteId,\n })\n .onConflictDoNothing()\n .returning()) as NpFollowRow[];\n\n if (inserted) {\n // Fresh insert — notify the followed member.\n if (input.targetType === \"member\") {\n await createNotification({\n memberId: input.targetId,\n kind: \"follow.received\",\n actorMemberId: input.followerId,\n payload: { followerId: input.followerId },\n });\n }\n return inserted;\n }\n\n // Conflict path: the row already existed (or a concurrent caller\n // just inserted it). Re-select and return without re-firing the\n // notification — the original insertion already did that.\n const [existing] = (await db\n .select()\n .from(npFollows)\n .where(\n and(\n eq(npFollows.followerId, input.followerId),\n eq(npFollows.targetType, input.targetType),\n eq(npFollows.targetId, input.targetId),\n eq(npFollows.siteId, siteId),\n ),\n )\n .limit(1)) as NpFollowRow[];\n if (!existing) {\n // Unreachable in practice — the conflict means a row exists.\n // If we genuinely don't see it, something is racing us with a\n // delete; surface a generic error rather than fabricate a row.\n throw new Error(\"Follow insert hit conflict but re-select returned no row\");\n }\n return existing;\n}\n\nexport async function unfollow(input: NpFollowInput): Promise<void> {\n assertSupportedTarget(input.targetType);\n const db = getDb();\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n await db\n .delete(npFollows)\n .where(\n and(\n eq(npFollows.followerId, input.followerId),\n eq(npFollows.targetType, input.targetType),\n eq(npFollows.targetId, input.targetId),\n eq(npFollows.siteId, siteId),\n ),\n );\n}\n\nexport async function isFollowing(input: NpFollowInput): Promise<boolean> {\n assertSupportedTarget(input.targetType);\n const db = getDb();\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const [row] = (await db\n .select({ id: npFollows.id })\n .from(npFollows)\n .where(\n and(\n eq(npFollows.followerId, input.followerId),\n eq(npFollows.targetType, input.targetType),\n eq(npFollows.targetId, input.targetId),\n eq(npFollows.siteId, siteId),\n ),\n )\n .limit(1)) as Array<{ id: string }>;\n return Boolean(row);\n}\n\n/**\n * \"Who am I following?\" — paged. Used by the site UI to populate a\n * member's profile or settings page.\n */\nexport async function listFollowing(\n followerId: string,\n options: { targetType?: FollowTarget; limit?: number; offset?: number } = {},\n): Promise<NpFollowRow[]> {\n const db = getDb();\n const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);\n const offset = Math.max(options.offset ?? 0, 0);\n // Phase 18 — scope to current site. A member who follows on\n // tenant A and tenant B should see two separate \"Following\"\n // lists, one per site. Falls back to the default site when\n // the resolver isn't wired (scripts).\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const where = options.targetType\n ? and(\n eq(npFollows.followerId, followerId),\n eq(npFollows.targetType, options.targetType),\n eq(npFollows.siteId, siteId),\n )\n : and(eq(npFollows.followerId, followerId), eq(npFollows.siteId, siteId));\n const rows = (await db\n .select()\n .from(npFollows)\n .where(where)\n .limit(limit)\n .offset(offset)) as NpFollowRow[];\n return rows;\n}\n","import { can } from \"../auth/capabilities.js\";\nimport { type NpPrincipal } from \"../auth/principal.js\";\n\nimport { memberCan } from \"./can.js\";\nimport type { MemberAction, MemberCanTarget } from \"./can.js\";\n\n/**\n * Unified permission check. Staff routes pass `{ kind: \"staff\", user }`;\n * member routes pass `{ kind: \"member\", memberId }`. Staff with\n * `admin`, `editor`, or `moderator` role short-circuit to allow all\n * community-mod actions — they're trusted by virtue of being CMS\n * staff. Other staff roles (author, viewer) and members fall through\n * to the member-side resolver, which checks role grants in\n * `np_member_roles`.\n *\n * `edit-own` / `delete-own` actions still require ownership even for\n * staff — the API layer should already check ownership for self-only\n * routes, but the ownership rule here is belt-and-braces.\n */\nexport type Principal = NpPrincipal;\n\nexport async function principalCan(\n principal: Principal,\n action: MemberAction,\n target: MemberCanTarget,\n): Promise<boolean> {\n // Owner-only actions: stay strict. A staff user can't `edit-own`\n // a row they don't own; that branch is for owner-self editing.\n // Mod-style \"edit somebody else's content\" goes through\n // `edit-any-comment` etc., which staff bypasses below.\n const ownerOnly = action === \"edit-own\" || action === \"delete-own\";\n\n switch (principal.kind) {\n case \"staff\":\n // Staff don't own member-authored content. Owner-only\n // shortcuts are denied to staff outright; non-owner-only\n // actions short-circuit on community.moderate.\n if (ownerOnly) return false;\n return can(principal.user, \"community.moderate\");\n case \"member\":\n return memberCan(principal.memberId, action, target);\n default: {\n // Exhaustiveness check — adding a new Principal kind\n // without handling it here is a compile error.\n const _exhaustive: never = principal;\n void _exhaustive;\n return false;\n }\n }\n}\n","import { and, count, desc, eq, isNotNull, isNull } from \"drizzle-orm\";\nimport type { PgTable } from \"drizzle-orm/pg-core\";\n\nimport { getCollectionRegistration, getCollectionTable } from \"../collections/registry.js\";\nimport { getDb } from \"../db/runtime.js\";\nimport { npComments, npMembers, npReports } from \"../db/schema/community.js\";\nimport { NpForbiddenError, NpNotFoundError, NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId, requireSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\nimport { recordAuditEvent } from \"./audit.js\";\nimport { withMemberWrite } from \"./can.js\";\nimport type { Principal } from \"./principal.js\";\n\nconst MAX_REASON_LENGTH = 1000;\nconst SUPPORTED_TARGETS = [\"comment\", \"thread\", \"reply\", \"member\"] as const;\ntype ReportTarget = (typeof SUPPORTED_TARGETS)[number];\n\nexport interface NpReportRow {\n id: string;\n reporterId: string;\n targetType: string;\n targetId: string;\n reason: string;\n resolvedAt: Date | null;\n resolvedByUserId: string | null;\n resolvedByMemberId: string | null;\n resolution: string | null;\n siteId: string;\n createdAt: Date;\n}\n\nexport interface FileReportInput {\n reporterId: string;\n targetType: ReportTarget;\n targetId: string;\n reason: string;\n}\n\nfunction validateTargetType(value: string): asserts value is ReportTarget {\n if (!(SUPPORTED_TARGETS as readonly string[]).includes(value)) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"targetType\",\n message: `targetType must be one of: ${SUPPORTED_TARGETS.join(\", \")}`,\n },\n ]);\n }\n}\n\n/**\n * Members file reports against a piece of community content. The\n * reason is free-form; mods triage it via `listReports` and\n * `resolveReport`.\n */\nexport async function fileReport(input: FileReportInput): Promise<NpReportRow> {\n validateTargetType(input.targetType);\n const targetId = input.targetId.trim();\n if (targetId.length === 0) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"targetId\", message: \"targetId required\" },\n ]);\n }\n const reason = input.reason.trim();\n if (reason.length === 0) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"reason\", message: \"Report reason required\" },\n ]);\n }\n if (reason.length > MAX_REASON_LENGTH) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"reason\", message: `Reason must be ≤ ${MAX_REASON_LENGTH} characters` },\n ]);\n }\n\n // #311 — withMemberWrite enforces the ban gate by structure.\n // Site-wide bans block every community write including reports\n // (#53); no obvious scope chain for a polymorphic report target.\n return withMemberWrite(input.reporterId, [], async () => {\n return doFileReport(input, targetId, reason);\n });\n}\n\nasync function doFileReport(\n input: FileReportInput,\n targetId: string,\n reason: string,\n): Promise<NpReportRow> {\n // Verify the target actually exists. Without this, members can fill\n // the moderation queue with reports against UUIDs that point at\n // nothing — and the audit log captures the phantom target id too,\n // making forensic review noisy. (#52)\n //\n // Issue #215 — `assertReportTargetExists` now also returns the\n // target's canonical site so we can reject cross-tenant reports.\n // A member on site A who guessed at a comment id on site B\n // shouldn't be able to file a report under either tenant — this\n // path stays single-tenant.\n const target = await assertReportTargetExists(input.targetType, targetId);\n\n const db = getDb();\n // Phase 18 — file the report under the current tenant so the\n // mod queue surfaces it on the right site.\n // #272 — write: must NOT silently fall through; a misfiled\n // report would surface in the wrong moderator's queue.\n const siteId = await requireSiteId();\n if (target.siteId !== null && target.siteId !== siteId) {\n throw new NpForbiddenError(\"report\", \"cross-site\");\n }\n const [row] = (await db\n .insert(npReports)\n .values({\n reporterId: input.reporterId,\n targetType: input.targetType,\n targetId,\n reason,\n siteId,\n })\n .returning()) as NpReportRow[];\n if (!row) throw new Error(\"Report insert returned no row\");\n\n await recordAuditEvent({\n actor: { kind: \"member\", memberId: input.reporterId },\n action: \"report.filed\",\n targetType: input.targetType,\n targetId,\n payload: { reportId: row.id, reason },\n });\n\n return row;\n}\n\nexport interface ListReportsOptions {\n /** Default: only unresolved. Pass `\"all\"` to include resolved. */\n status?: \"unresolved\" | \"resolved\" | \"all\";\n /** Filter to a specific target type. */\n targetType?: string;\n /**\n * Phase 18 — site scope. `undefined` (default) → use the\n * request resolver's site. Pass an explicit string to view\n * another tenant's queue (super-admin) or `null` to skip\n * the filter entirely.\n */\n siteId?: string | null;\n limit?: number;\n offset?: number;\n}\n\nexport interface ListReportsResult {\n reports: NpReportRow[];\n totalDocs: number;\n}\n\nexport async function listReports(options: ListReportsOptions = {}): Promise<ListReportsResult> {\n const db = getDb();\n const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);\n const offset = Math.max(options.offset ?? 0, 0);\n\n const filters = [];\n if (options.status === \"resolved\") filters.push(isNotNull(npReports.resolvedAt));\n else if (options.status === \"all\") {\n /* no-op */\n } else filters.push(isNull(npReports.resolvedAt));\n if (options.targetType) filters.push(eq(npReports.targetType, options.targetType));\n\n // Phase 18 — scope to current tenant so mods on tenant A\n // don't see tenant B's queue. Pass `siteId: null` to skip\n // (super-admin cross-tenant triage); otherwise use the\n // resolver. Mirrors the pattern from Phase 17 audit.\n if (options.siteId !== null) {\n const resolvedSite = options.siteId !== undefined ? options.siteId : await getCurrentSiteId();\n if (resolvedSite !== null) {\n filters.push(eq(npReports.siteId, resolvedSite));\n }\n }\n\n const where = filters.length > 0 ? and(...filters) : undefined;\n\n const reports = (await db\n .select()\n .from(npReports)\n .where(where)\n .orderBy(desc(npReports.createdAt))\n .limit(limit)\n .offset(offset)) as NpReportRow[];\n\n const [totalRow] = (await db.select({ total: count() }).from(npReports).where(where)) as Array<{\n total: number;\n }>;\n\n return { reports, totalDocs: Number(totalRow?.total ?? 0) };\n}\n\nexport interface ResolveReportInput {\n reportId: string;\n /** Free-form short label: e.g. `\"hidden\"`, `\"banned\"`, `\"dismissed\"`. */\n resolution: string;\n actor: Principal;\n}\n\n/**\n * Marks a report resolved. Caller is responsible for taking the\n * actual moderation action (hide, ban, etc.) — this only flips the\n * report row and writes an audit entry.\n */\nexport async function resolveReport(input: ResolveReportInput): Promise<NpReportRow> {\n const resolution = input.resolution.trim();\n if (resolution.length === 0) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"resolution\", message: \"Resolution label required\" },\n ]);\n }\n\n const db = getDb();\n // Issue #363 — `listReports` was already site-scoped, but\n // `resolveReport` fetched and updated by id only. A moderator who\n // obtained a foreign report id (e.g. from logs of a tenant they\n // also belong to, or by guessing) could mark it resolved and\n // write the audit event in their own request context. Fix:\n // require the request site, reject when the loaded row's siteId\n // diverges, AND include `siteId` in the update predicate so the\n // read-check and the write cannot drift.\n const requestSiteId = await requireSiteId();\n const [existing] = (await db\n .select()\n .from(npReports)\n .where(eq(npReports.id, input.reportId))\n .limit(1)) as NpReportRow[];\n if (!existing) throw new NpNotFoundError(\"report\", input.reportId);\n if (existing.siteId !== requestSiteId) {\n throw new NpForbiddenError(\"report\", \"cross-site\");\n }\n if (existing.resolvedAt) {\n throw new NpValidationError(\"Invalid state\", [\n { field: \"report\", message: \"Report already resolved\" },\n ]);\n }\n\n const resolvedByUserId = input.actor.kind === \"staff\" ? input.actor.user.id : null;\n const resolvedByMemberId = input.actor.kind === \"member\" ? input.actor.memberId : null;\n\n const [updated] = (await db\n .update(npReports)\n .set({\n resolvedAt: new Date(),\n resolvedByUserId,\n resolvedByMemberId,\n resolution,\n })\n .where(and(eq(npReports.id, input.reportId), eq(npReports.siteId, requestSiteId)))\n .returning()) as NpReportRow[];\n if (!updated) throw new Error(\"Report update returned no row\");\n\n await recordAuditEvent({\n actor:\n input.actor.kind === \"staff\"\n ? { kind: \"staff\", userId: input.actor.user.id }\n : { kind: \"member\", memberId: input.actor.memberId },\n action: \"report.resolved\",\n targetType: existing.targetType,\n targetId: existing.targetId,\n payload: { reportId: existing.id, resolution },\n });\n\n return updated;\n}\n\n/**\n * Verify the report's target row actually exists.\n *\n * - `comment` / `reply` — both stored in `np_comments`\n * (the forum plugin's replies are just comments under\n * a discussion thread). Lookup the comment row.\n * - `member` — direct lookup against `np_members`.\n * - `thread` — Phase 9.9 enabled. The forum plugin\n * stores threads as rows in the `discussions` collection\n * (Phase 9.4 decision: no thread-specific tables, reuse\n * the codegen pipeline). We resolve the table at runtime\n * so the report flow works whether the discussions\n * collection is named `discussions`, `posts`, or anything\n * else — sites that register a different forum slug just\n * pass that through as `targetType`. Falls back to a\n * clear \"no such collection\" error when unregistered.\n */\n/**\n * Issue #215 — verify the target exists AND surface its canonical\n * site id so the caller can reject cross-tenant report attempts.\n * Returns the target's `siteId` (or `null` for `member` targets,\n * which aren't site-scoped today). The site comparison happens at\n * the call site so the error message stays specific to \"reports\".\n */\nasync function assertReportTargetExists(\n targetType: string,\n targetId: string,\n): Promise<{ siteId: string | null }> {\n const db = getDb();\n if (targetType === \"comment\" || targetType === \"reply\") {\n const [row] = (await db\n .select({ id: npComments.id, siteId: npComments.siteId })\n .from(npComments)\n .where(eq(npComments.id, targetId))\n .limit(1)) as Array<{ id: string; siteId: string }>;\n if (!row) throw new NpNotFoundError(targetType, targetId);\n return { siteId: row.siteId };\n }\n if (targetType === \"member\") {\n const [row] = (await db\n .select({ id: npMembers.id })\n .from(npMembers)\n .where(eq(npMembers.id, targetId))\n .limit(1)) as Array<{ id: string }>;\n if (!row) throw new NpNotFoundError(\"member\", targetId);\n // Members aren't site-scoped (one np_members row can have\n // memberships across sites); skip the cross-site check.\n return { siteId: null };\n }\n if (targetType === \"thread\") {\n // Resolve to a registered collection that opts in to\n // member-write thread semantics. We try `discussions`\n // first (the forum plugin's default slug); future\n // multi-forum setups can register different slugs and\n // the plugin's report-emission path can supply them.\n const slug = \"discussions\";\n let registered: ReturnType<typeof getCollectionRegistration> | null;\n try {\n registered = getCollectionRegistration(slug);\n } catch {\n registered = null;\n }\n if (!registered) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"targetType\",\n message:\n \"Reports against threads require the forum plugin's `discussions` collection to be registered.\",\n },\n ]);\n }\n const table = getCollectionTable(slug) as PgTable;\n const idCol = (table as unknown as Record<string, unknown>).id;\n const siteCol = (table as unknown as Record<string, unknown>).siteId;\n const [row] = (await db\n .select({ id: idCol as never, siteId: siteCol as never })\n .from(table)\n .where(eq(idCol as never, targetId))\n .limit(1)) as Array<{ id: string; siteId: string | null }>;\n if (!row) throw new NpNotFoundError(\"thread\", targetId);\n return { siteId: row.siteId ?? null };\n }\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"targetType\",\n message: `Reports against \"${targetType}\" are not supported`,\n },\n ]);\n}\n\n/** Cheap \"is anything in the queue?\" probe for the admin badge. */\nexport async function unresolvedReportCount(): Promise<number> {\n const db = getDb();\n // Phase 18 — count only the current tenant's queue.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const [row] = (await db\n .select({ total: count() })\n .from(npReports)\n .where(and(eq(npReports.siteId, siteId), isNull(npReports.resolvedAt)))) as Array<{\n total: number;\n }>;\n return Number(row?.total ?? 0);\n}\n","import { and, desc, eq, gt, isNull, or } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npBans } from \"../db/schema/community.js\";\nimport { NpForbiddenError, NpNotFoundError, NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId, requireSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\nimport { recordAuditEvent } from \"./audit.js\";\nimport type { Principal } from \"./principal.js\";\n\n/**\n * Ban service. The 9.1a schema already had `np_bans`; this layer\n * adds the issue / list / revoke flow plus audit logging.\n *\n * Scope rules in v1:\n * - `site` — issuer must be staff (admin / editor / moderator).\n * - `category`, `collection` — issuer must be a community-mod or\n * a staff mod. We don't currently verify the issuer holds the\n * matching scoped grant; the API layer is responsible for that\n * check via `principalCan` before calling `issueBan`. The audit\n * log records the issuer either way for forensic review.\n */\n\nexport type BanScope = \"site\" | \"category\" | \"collection\";\nexport type BanKind = \"temporary\" | \"permanent\";\n\nexport interface NpBanRow {\n id: string;\n memberId: string;\n scopeType: BanScope;\n scopeId: string | null;\n kind: BanKind;\n expiresAt: Date | null;\n reason: string | null;\n byUserId: string | null;\n byMemberId: string | null;\n /** Tenant the ban belongs to. Phase 18 added the column; the type was incomplete until #364. */\n siteId: string;\n createdAt: Date;\n}\n\nexport interface IssueBanInput {\n memberId: string;\n scopeType: BanScope;\n scopeId?: string | null;\n kind: BanKind;\n /** Required when `kind === \"temporary\"`. */\n expiresAt?: Date | null;\n reason?: string | null;\n actor: Principal;\n}\n\nexport async function issueBan(input: IssueBanInput): Promise<NpBanRow> {\n if (input.kind === \"temporary\" && !(input.expiresAt instanceof Date)) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"expiresAt\", message: \"Temporary bans require an expiresAt timestamp\" },\n ]);\n }\n if (input.scopeType !== \"site\" && !input.scopeId) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"scopeId\", message: \"Scoped bans require a scopeId\" },\n ]);\n }\n\n const db = getDb();\n const byUserId = input.actor.kind === \"staff\" ? input.actor.user.id : null;\n const byMemberId = input.actor.kind === \"member\" ? input.actor.memberId : null;\n\n // Phase 18 — site this ban applies to. For `scope_type='site'`\n // bans this column IS the site identifier; for category /\n // collection bans it scopes the slug to a particular tenant\n // (the same `posts` collection slug exists on every site).\n // #272 — write paths must NOT silently fall through to the\n // default site. A ban issued without site context would land\n // on tenant A's records when the staff member intended tenant B.\n const siteId = await requireSiteId();\n const [row] = (await db\n .insert(npBans)\n .values({\n memberId: input.memberId,\n scopeType: input.scopeType,\n scopeId: input.scopeId ?? null,\n kind: input.kind,\n expiresAt: input.expiresAt ?? null,\n reason: input.reason ?? null,\n byUserId,\n byMemberId,\n siteId,\n })\n .returning()) as NpBanRow[];\n if (!row) throw new Error(\"Ban insert returned no row\");\n\n await recordAuditEvent({\n actor:\n input.actor.kind === \"staff\"\n ? { kind: \"staff\", userId: input.actor.user.id }\n : { kind: \"member\", memberId: input.actor.memberId },\n action: \"member.ban\",\n targetType: \"member\",\n targetId: input.memberId,\n payload: {\n banId: row.id,\n scopeType: row.scopeType,\n scopeId: row.scopeId,\n kind: row.kind,\n expiresAt: row.expiresAt?.toISOString() ?? null,\n reason: row.reason,\n },\n });\n\n return row;\n}\n\nexport async function listBansForMember(memberId: string): Promise<NpBanRow[]> {\n const db = getDb();\n // Active bans only — expired/revoked rows aren't shown by default.\n // Staff who want to see history can hit the audit log.\n // The `or()` helper wraps its branches in parens; a raw `sql` template\n // would let Postgres' AND-binds-tighter-than-OR rule re-associate\n // the predicate and leak active temp bans across members.\n // Phase 18 — scope to the current tenant so a ban issued on\n // tenant A doesn't surface in tenant B's mod surface.\n // #272 — read path: falling back to the default site is\n // intentional. A worker-side reconciler running without site\n // context should still see the default tenant's bans rather\n // than crash.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const now = new Date();\n return (await db\n .select()\n .from(npBans)\n .where(\n and(\n eq(npBans.memberId, memberId),\n eq(npBans.siteId, siteId),\n or(isNull(npBans.expiresAt), gt(npBans.expiresAt, now)),\n ),\n )\n .orderBy(desc(npBans.createdAt)));\n}\n\nexport interface RevokeBanInput {\n banId: string;\n actor: Principal;\n}\n\n/**\n * \"Revoking\" a ban means deleting the row outright. The audit log\n * preserves the history (issue + revoke each leave an entry), so we\n * don't need a soft-delete column.\n */\nexport async function revokeBan(input: RevokeBanInput): Promise<void> {\n // Issue #364 — load + delete were id-only. Now require the\n // request site, reject when the loaded row is in a different\n // tenant, and pin `siteId` in the delete predicate so the\n // read-check and the write cannot drift.\n const db = getDb();\n const requestSiteId = await requireSiteId();\n const [existing] = (await db\n .select()\n .from(npBans)\n .where(eq(npBans.id, input.banId))\n .limit(1)) as NpBanRow[];\n if (!existing) throw new NpNotFoundError(\"ban\", input.banId);\n if (existing.siteId !== requestSiteId) {\n throw new NpForbiddenError(\"ban\", \"cross-site\");\n }\n\n await db\n .delete(npBans)\n .where(and(eq(npBans.id, input.banId), eq(npBans.siteId, requestSiteId)));\n\n await recordAuditEvent({\n actor:\n input.actor.kind === \"staff\"\n ? { kind: \"staff\", userId: input.actor.user.id }\n : { kind: \"member\", memberId: input.actor.memberId },\n action: \"member.unban\",\n targetType: \"member\",\n targetId: existing.memberId,\n payload: { banId: existing.id, scopeType: existing.scopeType, scopeId: existing.scopeId },\n });\n}\n","import { and, desc, eq, gt, isNull, or } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMemberRoles } from \"../db/schema/community.js\";\nimport { NpConflictError, NpNotFoundError, NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId, requireSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\nimport { recordAuditEvent } from \"./audit.js\";\nimport { getCommunityRole } from \"./roles.js\";\nimport type { CommunityScope } from \"./roles.js\";\n\n/**\n * Member role grant service. Wraps `np_member_roles` writes with\n * registry validation, audit logging, and friendly errors for the\n * `(member, role, scope_type, scope_id)` unique conflict that\n * Postgres surfaces as a 23505 raw error.\n *\n * Read path (`memberCan` in `community/can.ts`) already filters by\n * `expires_at IS NULL OR expires_at > now`, so an expired grant\n * disappears from the resolver automatically — `listMemberRoleGrants`\n * mirrors that filter so the admin UI doesn't show ghost rows.\n *\n * Permission gating is the API layer's job (today: admin-only). The\n * core helpers don't re-check, so a privileged programmatic caller\n * can grant on behalf of any actor.\n */\n\nexport interface NpMemberRoleGrantRow {\n id: string;\n memberId: string;\n role: string;\n scopeType: CommunityScope;\n scopeId: string | null;\n grantedBy: string | null;\n grantedAt: Date;\n expiresAt: Date | null;\n /** Tenant the grant belongs to. Phase 18 added the column; the type was incomplete until #364. */\n siteId: string;\n}\n\nexport interface GrantMemberRoleInput {\n memberId: string;\n role: string;\n scopeType: CommunityScope;\n /** Required when `scopeType !== \"site\"`; ignored otherwise. */\n scopeId?: string | null;\n /** Optional time-boxed grant. `null` = perpetual. */\n expiresAt?: Date | null;\n /** Staff user issuing the grant — recorded on the row + audit. */\n grantedByUserId: string;\n}\n\nexport async function grantMemberRole(input: GrantMemberRoleInput): Promise<NpMemberRoleGrantRow> {\n // Validate the role + scope pair is in the registry. Without this\n // a typo silently writes a row that `memberCan` will never match\n // — the grant looks active in the admin UI but does nothing.\n const definition = getCommunityRole(input.role, input.scopeType);\n if (!definition) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"role\",\n message: `Unknown role '${input.role}' for scope '${input.scopeType}'`,\n },\n ]);\n }\n\n const scopeId = input.scopeType === \"site\" ? null : (input.scopeId ?? \"\").trim();\n if (input.scopeType !== \"site\" && !scopeId) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"scopeId\", message: \"scopeId required for non-site grants\" },\n ]);\n }\n if (input.expiresAt instanceof Date && input.expiresAt.getTime() <= Date.now()) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"expiresAt\", message: \"expiresAt must be in the future\" },\n ]);\n }\n\n const db = getDb();\n\n // Pre-check for an existing active grant matching the same\n // `(member, role, scope_type, scope_id)` tuple. The schema's\n // `np_member_roles_grant_uq` unique constraint catches duplicates\n // for non-null scope_ids natively, but site-wide grants have\n // `scope_id = NULL` and the constraint's `NULLS NOT DISTINCT`\n // clause depends on whether the DB was migrated post-PG-15-syntax.\n // The pre-check makes the conflict deterministic regardless of\n // constraint state and gives the API a clean 409 path.\n const normalizedScopeId = scopeId === \"\" ? null : scopeId;\n // Phase 18 — site this grant applies to. For\n // `scope_type='site'` this column IS the site identifier;\n // for category / collection / thread grants it scopes the\n // slug to a tenant.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const existing = (await db\n .select({ id: npMemberRoles.id })\n .from(npMemberRoles)\n .where(\n and(\n eq(npMemberRoles.memberId, input.memberId),\n eq(npMemberRoles.role, input.role),\n eq(npMemberRoles.scopeType, input.scopeType),\n eq(npMemberRoles.siteId, siteId),\n normalizedScopeId === null\n ? isNull(npMemberRoles.scopeId)\n : eq(npMemberRoles.scopeId, normalizedScopeId),\n ),\n )\n .limit(1)) as Array<{ id: string }>;\n if (existing.length > 0) {\n throw new NpConflictError(`Member already has this role grant in scope '${input.scopeType}'.`);\n }\n\n // Race fallback: even with the pre-check, two concurrent grants\n // could slip through. The DB constraint catches that for\n // non-null scopes; the catch block re-maps the error.\n let row: NpMemberRoleGrantRow;\n try {\n const [inserted] = (await db\n .insert(npMemberRoles)\n .values({\n memberId: input.memberId,\n role: input.role,\n scopeType: input.scopeType,\n scopeId: normalizedScopeId,\n siteId,\n grantedBy: input.grantedByUserId,\n expiresAt: input.expiresAt ?? null,\n })\n .returning()) as NpMemberRoleGrantRow[];\n if (!inserted) throw new Error(\"Grant insert returned no row\");\n row = inserted;\n } catch (err) {\n // pg-node surfaces the unique-violation as a `DatabaseError`\n // with `code: \"23505\"`. Drizzle re-throws it untouched, so we\n // either match the SQLSTATE on the unwrapped object or\n // fall back to the message text (some adapters wrap the\n // error and only the message survives).\n const code = (err as { code?: string } | null)?.code;\n const message = err instanceof Error ? err.message : \"\";\n if (code === \"23505\" || /unique|23505|duplicate key/i.test(message)) {\n throw new NpConflictError(\n `Member already has this role grant in scope '${input.scopeType}'.`,\n );\n }\n throw err;\n }\n\n await recordAuditEvent({\n actor: { kind: \"staff\", userId: input.grantedByUserId },\n action: \"member.role.grant\",\n targetType: \"member\",\n targetId: input.memberId,\n payload: {\n grantId: row.id,\n role: row.role,\n scopeType: row.scopeType,\n scopeId: row.scopeId,\n expiresAt: row.expiresAt?.toISOString() ?? null,\n },\n });\n\n return row;\n}\n\n/**\n * List currently-active grants for a member. Mirrors the\n * `memberCan` filter so expired rows are hidden.\n */\nexport async function listMemberRoleGrants(memberId: string): Promise<NpMemberRoleGrantRow[]> {\n const db = getDb();\n // Phase 18 — show only grants on the current tenant. A\n // member who's a community-mod on tenant A and not on\n // tenant B should see exactly one grant when admin pages\n // load on each.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const now = new Date();\n return (await db\n .select()\n .from(npMemberRoles)\n .where(\n and(\n eq(npMemberRoles.memberId, memberId),\n eq(npMemberRoles.siteId, siteId),\n or(isNull(npMemberRoles.expiresAt), gt(npMemberRoles.expiresAt, now)),\n ),\n )\n .orderBy(desc(npMemberRoles.grantedAt)));\n}\n\nexport interface RevokeMemberRoleInput {\n grantId: string;\n revokedByUserId: string;\n}\n\n/**\n * Revoke = hard delete. Audit trail preserves history. Mirrors\n * `revokeBan`'s semantic — the grant either exists and counts, or\n * it doesn't; soft-deleted rows would only confuse the resolver.\n */\nexport async function revokeMemberRole(input: RevokeMemberRoleInput): Promise<void> {\n // Issue #364 — delete was id-only. Now pin `siteId` in the\n // delete predicate so a staff user with a foreign grant id can't\n // revoke a grant in another tenant. NOT_FOUND on miss covers both\n // \"no such grant\" and \"grant exists but in another site\" — the\n // distinction is intentional: leaking which case applies would\n // confirm the foreign grant's existence.\n const db = getDb();\n const requestSiteId = await requireSiteId();\n const deleted = (await db\n .delete(npMemberRoles)\n .where(and(eq(npMemberRoles.id, input.grantId), eq(npMemberRoles.siteId, requestSiteId)))\n .returning()) as NpMemberRoleGrantRow[];\n if (deleted.length === 0) {\n // Use NOT_FOUND so the API maps to 404 — distinguishes \"you\n // raced another revoke\" from \"the grant was never there in\n // the first place\" only via response timing, but at least\n // the operator sees the right status code.\n throw new NpNotFoundError(\"memberRoleGrant\", input.grantId);\n }\n const [existing] = deleted;\n await recordAuditEvent({\n actor: { kind: \"staff\", userId: input.revokedByUserId },\n action: \"member.role.revoke\",\n targetType: \"member\",\n targetId: existing.memberId,\n payload: {\n grantId: existing.id,\n role: existing.role,\n scopeType: existing.scopeType,\n scopeId: existing.scopeId,\n },\n });\n}\n","import { and, eq, isNull, ne } from \"drizzle-orm\";\nimport type { PgTable } from \"drizzle-orm/pg-core\";\n\nimport {\n deleteDocument,\n getAllCollectionSlugs,\n getCollectionConfig,\n getCollectionTable,\n getDb,\n} from \"../collections/index.js\";\nimport type { NpAuthUser } from \"../config/types.js\";\nimport { NpNotFoundError } from \"../errors.js\";\nimport { deleteMedia } from \"../media/service.js\";\nimport { npComments, npMembers } from \"../db/schema/community.js\";\nimport { npMedia } from \"../db/schema/media.js\";\n\nimport { recordAuditEvent } from \"./audit.js\";\nimport { staffDeleteComment } from \"./comments.js\";\n\n/**\n * Aggregate result of a member content purge. Comments are\n * counted as deleted regardless of soft-vs-hard semantic (the\n * underlying `staffDeleteComment` is a soft delete that wipes the\n * body). Documents are reported per-collection because the staff\n * UI typically wants to call out \"X discussions, Y posts\" rather\n * than a flat total. Media has a `skipped` bucket because\n * `deleteMedia` refuses rows that are still referenced from a\n * doc (`np_media_refs`) — those need to be unlinked first; the\n * mod can re-run after the reference is gone.\n */\nexport interface NpMemberPurgeResult {\n comments: number;\n documents: Record<string, number>;\n media: { deleted: number; skipped: number };\n}\n\n/**\n * Wipes everything a single member authored: comments, top-level\n * docs in any collection that opted into `community.memberWrite`,\n * and uploaded media. Used by the moderation tooling to clean up\n * after a spam wave or a banned account.\n *\n * Failure mode is idempotent rather than atomic — if a transient\n * error interrupts the purge mid-way, the operator re-runs and\n * the helper skips items already removed (it always re-queries\n * the live state before each loop). The aggregate audit event\n * records the actual counts performed, not the intent.\n *\n * Out of scope (deliberately): banning, identity revocation,\n * follower / following links, reputation reset. Each of those is\n * a separate moderation action with its own UI; bundling them\n * into a single \"purge\" hides intent.\n */\nexport async function purgeMemberContent(\n memberId: string,\n staffUser: NpAuthUser,\n): Promise<NpMemberPurgeResult> {\n // Refuse to act on a member that doesn't exist — saves the\n // operator a confusing zero-count response when the id is a\n // typo. Mirrors the 404 surface from the identities-admin\n // helpers.\n const db = getDb();\n const [memberRow] = (await db\n .select({ id: npMembers.id })\n .from(npMembers)\n .where(eq(npMembers.id, memberId))\n .limit(1)) as Array<{ id: string }>;\n if (!memberRow) {\n throw new NpNotFoundError(\"member\", memberId);\n }\n\n // 1. Comments. Filter out already-deleted rows so re-running\n // the purge after a partial failure doesn't re-fire delete\n // events on tombstones.\n const liveComments = (await db\n .select({ id: npComments.id })\n .from(npComments)\n .where(\n and(eq(npComments.memberId, memberId), ne(npComments.status, \"deleted\")),\n )) as Array<{ id: string }>;\n let commentsDeleted = 0;\n for (const row of liveComments) {\n try {\n await staffDeleteComment(row.id, staffUser.id);\n commentsDeleted += 1;\n } catch (err) {\n if (err instanceof NpNotFoundError) continue;\n throw err;\n }\n }\n\n // 2. Member-authored docs in member-write collections. Iterate\n // every collection that opted in via `community.memberWrite.create`\n // so the call is automatically wired to whatever set of\n // collections a site has registered (no hardcoded \"discussions\").\n const documents: Record<string, number> = {};\n for (const slug of getAllCollectionSlugs()) {\n let config;\n try {\n config = getCollectionConfig(slug);\n } catch {\n continue;\n }\n if (!config.community?.memberWrite?.create) continue;\n\n const table = getCollectionTable(slug) as PgTable;\n const memberAuthorCol = (table as unknown as Record<string, unknown>)\n .memberAuthorId;\n const idCol = (table as unknown as Record<string, unknown>).id;\n if (!memberAuthorCol || !idCol) continue;\n\n const rows = (await db\n .select({ id: idCol as never })\n .from(table)\n .where(eq(memberAuthorCol as never, memberId))) as Array<{ id: string }>;\n\n let perCollection = 0;\n for (const row of rows) {\n try {\n await deleteDocument(slug, row.id, staffUser);\n perCollection += 1;\n } catch (err) {\n if (err instanceof NpNotFoundError) continue;\n throw err;\n }\n }\n if (perCollection > 0) documents[slug] = perCollection;\n }\n\n // 3. Media. `deleteMedia` does its own reference check —\n // rows referenced from `np_media_refs` (still embedded in\n // a doc body, etc.) come back with `deleted: false` and\n // `references` populated. Count those separately so the\n // operator knows manual cleanup is still needed.\n const mediaDb = getDb();\n const liveMedia = (await mediaDb\n .select({ id: npMedia.id })\n .from(npMedia)\n .where(\n and(eq(npMedia.uploadedByMemberId, memberId), isNull(npMedia.deletedAt)),\n )) as Array<{ id: string }>;\n let mediaDeleted = 0;\n let mediaSkipped = 0;\n for (const row of liveMedia) {\n const result = await deleteMedia(row.id);\n if (result.deleted) mediaDeleted += 1;\n else mediaSkipped += 1;\n }\n\n // 4. Aggregate audit row. Each staff-delete already wrote its\n // own per-target audit event (`comment.delete`,\n // `content:afterDelete`, etc.); this one summarizes the\n // operator's intent so the audit log shows a single\n // `member.content.purge` row alongside the grain-level\n // individual events.\n await recordAuditEvent({\n actor: { kind: \"staff\", userId: staffUser.id },\n action: \"member.content.purge\",\n targetType: \"member\",\n targetId: memberId,\n payload: {\n comments: commentsDeleted,\n documents,\n media: { deleted: mediaDeleted, skipped: mediaSkipped },\n },\n });\n\n return {\n comments: commentsDeleted,\n documents,\n media: { deleted: mediaDeleted, skipped: mediaSkipped },\n };\n}\n","import { and, desc, eq, count } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { NpForbiddenError, NpNotFoundError, NpValidationError } from \"../errors.js\";\nimport { npRevisions } from \"../db/schema/system.js\";\nimport type { NpAuthUser, NpSaveResult } from \"../config/types.js\";\nimport { getCollectionConfig } from \"./registry.js\";\nimport { getDocumentById, saveDocument } from \"./pipeline.js\";\nimport { getDb } from \"../db/runtime.js\";\n\nexport type NpRevisionStatus = \"draft\" | \"published\" | \"autosave\";\n\nexport interface NpRevisionSummary {\n id: string;\n collection: string;\n documentId: string;\n version: number;\n status: NpRevisionStatus;\n changedFields: string[];\n authorId: string | null;\n createdAt: Date;\n}\n\nexport interface NpRevision extends NpRevisionSummary {\n snapshot: Record<string, unknown>;\n}\n\nexport interface NpRevisionListOptions {\n limit?: number;\n offset?: number;\n}\n\nexport interface NpRevisionListResult {\n revisions: NpRevisionSummary[];\n total: number;\n}\n\ninterface DrizzleDb {\n select: NodePgDatabase<Record<string, unknown>>[\"select\"];\n}\n\nfunction normalizeLimit(limit: number | undefined): number {\n if (!limit || limit < 1) return 20;\n return Math.min(Math.floor(limit), 100);\n}\n\nfunction normalizeOffset(offset: number | undefined): number {\n if (!offset || offset < 0) return 0;\n return Math.floor(offset);\n}\n\nfunction assertVersionsEnabled(collection: string): void {\n const config = getCollectionConfig(collection);\n if (!config.versions) {\n throw new NpValidationError(\"Revisions not enabled\", [\n {\n field: \"collection\",\n message: `Collection \"${collection}\" has no versions config — enable versions.drafts to persist revisions.`,\n },\n ]);\n }\n}\n\n/**\n * Revisions can include draft / autosave snapshots that the public\n * site never serves. Authorizing revision reads with `access.read`\n * was leaking those to any user who could read the published\n * document — for `posts`/`pages` that's anyone, including a\n * logged-in viewer account. (#58)\n *\n * Switch the gate to `access.update`: only users who could PUBLISH\n * the document get to peek at its history. When the collection\n * doesn't define `access.update`, fall back to a hard staff-role\n * floor so we never silently relax the check.\n */\nasync function assertReadAccess(\n collection: string,\n user: NpAuthUser | null,\n doc: Record<string, unknown> | null,\n): Promise<void> {\n const config = getCollectionConfig(collection);\n if (!user) {\n throw new NpForbiddenError(collection, \"read-revision\");\n }\n\n if (config.access?.update) {\n const allowed = await config.access.update({ user, doc: doc ?? undefined });\n if (!allowed) {\n throw new NpForbiddenError(collection, \"read-revision\");\n }\n return;\n }\n\n // No update gate defined — require admin/editor (the staff roles that\n // can author content). `viewer`/`author` are stricter than `access\n // .read` would have been.\n if (user.role !== \"admin\" && user.role !== \"editor\") {\n throw new NpForbiddenError(collection, \"read-revision\");\n }\n}\n\nfunction toRevisionSnapshot(value: unknown): Record<string, unknown> {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new NpValidationError(\"Invalid revision snapshot\", [\n { field: \"snapshot\", message: \"Snapshot must be a JSON object\" },\n ]);\n }\n\n return value as Record<string, unknown>;\n}\n\nexport async function listRevisions(\n collection: string,\n documentId: string,\n options: NpRevisionListOptions = {},\n user: NpAuthUser | null = null,\n): Promise<NpRevisionListResult> {\n assertVersionsEnabled(collection);\n // Load the doc so `access.update` (per #58) gets the actual row\n // instead of `null`. Collections that gate access by ownership /\n // category need the doc to make a sensible decision.\n const targetDoc = await getDocumentById(collection, documentId, user ?? undefined);\n await assertReadAccess(collection, user, targetDoc);\n\n const db = getDb() as unknown as DrizzleDb;\n const limit = normalizeLimit(options.limit);\n const offset = normalizeOffset(options.offset);\n\n const filter = and(\n eq(npRevisions.collection, collection),\n eq(npRevisions.documentId, documentId),\n );\n\n const rows = (await db\n .select({\n id: npRevisions.id,\n collection: npRevisions.collection,\n documentId: npRevisions.documentId,\n version: npRevisions.version,\n status: npRevisions.status,\n changedFields: npRevisions.changedFields,\n authorId: npRevisions.authorId,\n createdAt: npRevisions.createdAt,\n })\n .from(npRevisions)\n .where(filter)\n .orderBy(desc(npRevisions.version))\n .limit(limit)\n .offset(offset)) as Array<{\n id: string;\n collection: string;\n documentId: string;\n version: number;\n status: NpRevisionStatus;\n changedFields: string[];\n authorId: string | null;\n createdAt: Date;\n }>;\n\n const [totalRow] = (await db\n .select({ total: count() })\n .from(npRevisions)\n .where(filter)) as Array<{ total: number | string }>;\n\n return {\n revisions: rows.map((row) => ({\n ...row,\n changedFields: row.changedFields ?? [],\n })),\n total: Number(totalRow?.total ?? 0),\n };\n}\n\nexport async function getRevision(\n collection: string,\n documentId: string,\n revisionId: string,\n user: NpAuthUser | null = null,\n): Promise<NpRevision> {\n assertVersionsEnabled(collection);\n // Load the doc so `access.update` (per #58) gets the actual row.\n const targetDoc = await getDocumentById(collection, documentId, user ?? undefined);\n await assertReadAccess(collection, user, targetDoc);\n\n const db = getDb() as unknown as DrizzleDb;\n\n const [row] = (await db\n .select()\n .from(npRevisions)\n .where(\n and(\n eq(npRevisions.id, revisionId),\n eq(npRevisions.collection, collection),\n eq(npRevisions.documentId, documentId),\n ),\n )\n .limit(1)) as Array<{\n id: string;\n collection: string;\n documentId: string;\n version: number;\n status: NpRevisionStatus;\n changedFields: string[];\n snapshot: Record<string, unknown>;\n authorId: string | null;\n createdAt: Date;\n }>;\n\n if (!row) {\n throw new NpNotFoundError(\"revision\", revisionId);\n }\n\n return {\n id: row.id,\n collection: row.collection,\n documentId: row.documentId,\n version: row.version,\n status: row.status,\n changedFields: row.changedFields ?? [],\n snapshot: toRevisionSnapshot(row.snapshot),\n authorId: row.authorId,\n createdAt: row.createdAt,\n };\n}\n\nexport async function restoreRevision(\n collection: string,\n documentId: string,\n revisionId: string,\n user: NpAuthUser,\n): Promise<NpSaveResult> {\n const revision = await getRevision(collection, documentId, revisionId, user);\n\n return saveDocument(collection, documentId, revision.snapshot, user, {\n status: revision.status === \"published\" ? \"published\" : \"draft\",\n });\n}\n","import { sql, type SQL } from \"drizzle-orm\";\nimport type { PgTable } from \"drizzle-orm/pg-core\";\n\nimport { npMembers } from \"../db/schema/community.js\";\nimport {\n getAllCollectionSlugs,\n getCollectionConfig,\n getCollectionTable,\n} from \"./registry.js\";\nimport { getDb } from \"../db/runtime.js\";\n\n/**\n * Cross-collection pending queue (Phase 9.7e). Lists every\n * member-authored row that landed `status = \"pending\"` — the ones\n * waiting on a staff promote (9.7d) or staff delete. Only collections\n * that opt into `community.memberWrite.create` are scanned; the rest\n * either have no member-author column at all or aren't part of the\n * member-write surface.\n *\n * Phase 12.11 — replaced the v1 fan-out (one round trip per\n * collection, no SQL `LIMIT`, JS-side merge + page) with a single\n * `UNION ALL` query whose outer `LIMIT/OFFSET` runs at the database.\n * The per-collection `(status, member_author_id)` is still\n * status-indexed (`np_c_<slug>_status_idx`), so each branch of the\n * union narrows fast; the database does the cross-collection\n * `ORDER BY created_at DESC` + paging. A site with dozens of\n * member-writable surfaces no longer fans out N+2 round trips, and\n * an unattended collection accumulating thousands of pendings can't\n * blow heap by being read fully into memory.\n */\nexport interface NpPendingDocSummary {\n id: string;\n collectionSlug: string;\n /** From the doc's `title` field, when it has one. Never empty. */\n title: string;\n slug: string | null;\n status: \"pending\";\n createdAt: Date;\n /**\n * Resolved from `np_members` via `member_author_id`. Null when the\n * member was deleted after authoring (FK is `ON DELETE SET NULL`)\n * — mods see the audit trail for the original actor in that case.\n */\n memberAuthor: {\n id: string;\n handle: string;\n displayName: string;\n } | null;\n}\n\nexport interface NpListPendingDocsOptions {\n /** Restrict to one collection. Useful for per-collection queue\n * pages; omit for the global queue. */\n collectionSlug?: string;\n limit?: number;\n offset?: number;\n}\n\nexport interface NpListPendingDocsResult {\n docs: NpPendingDocSummary[];\n totalDocs: number;\n}\n\nfunction getTableColumn(table: PgTable, name: string): unknown {\n return (table as unknown as Record<string, unknown>)[name];\n}\n\n/**\n * Build one branch of the pending-queue UNION ALL: a SELECT against\n * one member-write collection's table that projects the columns the\n * outer query needs (`collection_slug`, `id`, `title`, `doc_slug`,\n * `created_at`, `member_author_id`). Collections without a `title`\n * column are skipped by the caller — there's nothing to display in\n * the queue without it. Collections without a `slug` column emit\n * `NULL::text` so the UNION ALL row shapes match.\n */\nfunction buildPendingBranch(slug: string): SQL | null {\n let config;\n try {\n config = getCollectionConfig(slug);\n } catch {\n return null;\n }\n if (!config.community?.memberWrite?.create) return null;\n\n const table = getCollectionTable(slug) as PgTable;\n const titleCol = getTableColumn(table, \"title\");\n if (!titleCol) return null;\n const slugCol = getTableColumn(table, \"slug\");\n // The literal collection slug is bound as a parameter (no\n // injection risk) so the same prepared statement shape can be\n // reused across calls. `NULL::text` keeps the column type stable\n // for collections without a `slug` field — UNION ALL requires\n // type-aligned columns at each position.\n return sql`\n SELECT\n ${slug}::text AS collection_slug,\n id,\n title,\n ${slugCol ? sql`slug` : sql`NULL::text`} AS doc_slug,\n created_at,\n member_author_id\n FROM ${table}\n WHERE status = 'pending' AND member_author_id IS NOT NULL\n `;\n}\n\nexport async function listPendingMemberDocs(\n options: NpListPendingDocsOptions = {},\n): Promise<NpListPendingDocsResult> {\n const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);\n const offset = Math.max(options.offset ?? 0, 0);\n\n const slugs = options.collectionSlug\n ? [options.collectionSlug]\n : getAllCollectionSlugs();\n\n const db = getDb();\n\n const branches: SQL[] = [];\n for (const slug of slugs) {\n const branch = buildPendingBranch(slug);\n if (branch) branches.push(branch);\n }\n\n // Empty pool: caller asked for a slug that isn't member-writable\n // (or the registry is empty). UNION ALL of zero subqueries is\n // invalid SQL; bail with the empty envelope rather than crash.\n if (branches.length === 0) {\n return { docs: [], totalDocs: 0 };\n }\n\n const union = sql.join(branches, sql` UNION ALL `);\n\n const [countRow] = (\n (await db.execute(\n sql`SELECT count(*)::int AS total FROM (${union}) p`,\n )) as unknown as { rows: Array<{ total: number | string }> }\n ).rows;\n const totalDocs = Number(countRow?.total ?? 0);\n\n const result = (await db.execute(sql`\n SELECT\n p.collection_slug AS collection_slug,\n p.id AS id,\n p.title AS title,\n p.doc_slug AS doc_slug,\n p.created_at AS created_at,\n m.id AS member_id,\n m.handle AS member_handle,\n m.display_name AS member_display_name\n FROM (${union}) p\n LEFT JOIN ${npMembers} m ON m.id = p.member_author_id\n ORDER BY p.created_at DESC\n LIMIT ${limit} OFFSET ${offset}\n `)) as unknown as {\n rows: Array<{\n collection_slug: string;\n id: string;\n title: string | null;\n doc_slug: string | null;\n created_at: Date | string;\n member_id: string | null;\n member_handle: string | null;\n member_display_name: string | null;\n }>;\n };\n\n const docs: NpPendingDocSummary[] = result.rows.map((row) => ({\n id: row.id,\n collectionSlug: row.collection_slug,\n title:\n typeof row.title === \"string\" && row.title.length > 0\n ? row.title\n : \"(untitled)\",\n slug: row.doc_slug,\n status: \"pending\",\n createdAt:\n row.created_at instanceof Date ? row.created_at : new Date(row.created_at),\n memberAuthor:\n row.member_id && row.member_handle && row.member_display_name\n ? {\n id: row.member_id,\n handle: row.member_handle,\n displayName: row.member_display_name,\n }\n : null,\n }));\n\n return { docs, totalDocs };\n}\n","import { eq } from \"drizzle-orm\";\nimport type { AnyPgColumn, PgTable } from \"drizzle-orm/pg-core\";\n\nimport { findDocuments } from \"./pipeline.js\";\nimport { getDb } from \"../db/runtime.js\";\nimport {\n getAllCollectionSlugs,\n getCollectionConfig,\n getCollectionTable,\n} from \"./registry.js\";\nimport { buildWeightedSearchVectorSql } from \"./search.js\";\nimport { getSearchAdapter } from \"./search-adapter.js\";\n\nexport interface SearchCollectionsOptions {\n q: string;\n collections?: string[];\n limit?: number;\n offset?: number;\n /**\n * Extra where-filter applied on top of the default `{ status: \"published\" }`\n * for each collection. Pass `{}` to disable the status filter (caller should\n * only do this for authenticated admin contexts).\n */\n where?: Record<string, unknown>;\n /**\n * Phase 12.4 — restrict i18n collections to one locale. Non-\n * i18n collections ignore this (no `locale` column to match).\n * Public site search reads this from the URL's locale prefix\n * so visitors browsing in `/ko/` only see Korean hits.\n */\n locale?: string;\n}\n\nexport interface SearchResultItem {\n collection: string;\n doc: Record<string, unknown>;\n}\n\nexport interface SearchResult {\n results: SearchResultItem[];\n total: number;\n perCollection: Record<string, number>;\n}\n\nconst DEFAULT_LIMIT = 10;\nconst MAX_LIMIT = 50;\n\nfunction normalizeLimit(limit: number | undefined): number {\n if (!limit || limit < 1) return DEFAULT_LIMIT;\n return Math.min(Math.floor(limit), MAX_LIMIT);\n}\n\nfunction hasSearchVectorColumn(table: PgTable): boolean {\n return (table as unknown as Record<string, unknown>).searchVector !== undefined;\n}\n\n/**\n * Cross-collection full-text search using the existing `search_vector` column\n * on each collection table. Built on top of `findDocuments` so it inherits\n * the ts_rank ordering, access-control read checks, and pagination.\n *\n * Results are merged in per-collection slug order; for an MVP the within-\n * collection ranking is authoritative. A future version can do a UNION across\n * tables if global ranking becomes a priority.\n */\nexport async function searchCollections(\n opts: SearchCollectionsOptions,\n): Promise<SearchResult> {\n const query = opts.q.trim();\n if (query.length === 0) {\n return { results: [], total: 0, perCollection: {} };\n }\n\n const slugs = opts.collections ?? getAllCollectionSlugs();\n const limit = normalizeLimit(opts.limit);\n const offset = opts.offset ?? 0;\n const baseWhere = opts.where ?? { status: \"published\" };\n\n // Phase 10.6 — pluggable adapter. When a site has installed an\n // external search engine (Algolia / Meilisearch / OpenSearch),\n // delegate to that. The adapter can return `null` to fall\n // through to the pg path (e.g. for collections it doesn't\n // index). Throws are fail-open: log + treat as null. The\n // adapter is responsible for keeping its index fresh — the\n // pipeline already fires `content:afterCreate / :afterUpdate /\n // :afterDelete` hooks (9.7o made them Principal-aware), so a\n // plugin subscribes to those for indexing without needing any\n // new framework plumbing.\n const adapter = getSearchAdapter();\n if (adapter) {\n try {\n const adapterResult = await adapter.search({\n q: query,\n collections: opts.collections,\n limit,\n offset,\n locale: opts.locale,\n });\n if (adapterResult) return adapterResult;\n } catch (err) {\n const { getLogger } = await import(\"../observability/logger.js\");\n getLogger().warn(\"search adapter threw — falling back to pg tsvector\", {\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n const results: SearchResultItem[] = [];\n const perCollection: Record<string, number> = {};\n let total = 0;\n\n for (const slug of slugs) {\n let table: PgTable;\n try {\n table = getCollectionTable(slug) as PgTable;\n } catch {\n continue;\n }\n if (!hasSearchVectorColumn(table)) continue;\n\n // Phase 12.4 — fold locale into the per-collection where\n // clause for i18n collections. findDocuments() already\n // ignores `locale` on non-i18n collections (the column\n // doesn't exist), so we forward it unconditionally without\n // a config check here.\n const config = getCollectionConfig(slug);\n const collectionLocale =\n config.i18n && opts.locale ? opts.locale : undefined;\n\n const page = await findDocuments(slug, {\n search: query,\n where: baseWhere,\n limit,\n page: 1,\n ...(collectionLocale ? { locale: collectionLocale } : {}),\n });\n\n perCollection[slug] = page.totalDocs;\n total += page.totalDocs;\n for (const doc of page.docs) {\n results.push({ collection: slug, doc });\n }\n }\n\n return {\n results: results.slice(offset, offset + limit),\n total,\n perCollection,\n };\n}\n\nexport interface ReindexResult {\n collection: string;\n processed: number;\n}\n\nfunction getTableColumn(table: PgTable, key: string): AnyPgColumn {\n const column = (table as unknown as Record<string, unknown>)[key];\n if (!column) {\n throw new Error(`Column '${key}' not found on collection table.`);\n }\n return column as AnyPgColumn;\n}\n\n/**\n * Rebuilds the `search_vector` column for every row in a collection. Useful\n * after bulk imports or for recovering from corrupted vectors. Idempotent —\n * safe to run against a live collection while writes continue.\n */\nexport async function reindexCollection(slug: string): Promise<ReindexResult> {\n const config = getCollectionConfig(slug);\n const table = getCollectionTable(slug) as PgTable;\n if (!hasSearchVectorColumn(table)) {\n return { collection: slug, processed: 0 };\n }\n\n const db = getDb();\n const idCol = getTableColumn(table, \"id\");\n const rows = (await db.select().from(table)) as Array<Record<string, unknown>>;\n\n let processed = 0;\n for (const row of rows) {\n // Phase 10.7 — match the pipeline's write path:\n // 1. Wrap in `to_tsvector('english', $)` so Postgres\n // tokenizes the source text rather than parsing it\n // as raw tsvector syntax (the colon-content bug\n // that 11.x fixed for createMainDocument /\n // updateMainDocument). Reindex was a parallel write\n // path missing the same fix.\n // 2. Apply the weighted setweight() composition so\n // title fields outrank body fields, matching the\n // pipeline.\n const weighted = buildWeightedSearchVectorSql(config, row);\n await db\n .update(table)\n .set({ searchVector: weighted })\n .where(eq(idCol, row.id as string));\n processed += 1;\n }\n\n return { collection: slug, processed };\n}\n","import type { SearchResult } from \"./search-api.js\";\n\n/**\n * Phase 10.6 — pluggable search adapter. Same shape as the\n * spam / profanity / reputation adapters from Phase 9.6+:\n * a single global slot, opt-in via `setSearchAdapter`, default\n * is \"no adapter\" which keeps the existing pg `tsvector` path\n * authoritative.\n *\n * Plugins implement this interface to delegate search to an\n * external engine — Algolia, Meilisearch, OpenSearch, etc.\n * The plugin is responsible for KEEPING the engine's index\n * fresh; that's done by subscribing to the `content:afterCreate` /\n * `:afterUpdate` / `:afterDelete` hooks the framework already\n * fires (9.7o made those Principal-aware) — no new plumbing\n * in the pipeline. The adapter ONLY handles the read path.\n *\n * Returning `null` / `undefined` from `search()` means \"I don't\n * know how to handle this query; fall through to the pg\n * default.\" Useful when an adapter only indexes certain\n * collections, or wants to defer to pg under specific\n * conditions (e.g. very short queries).\n *\n * Errors thrown by the adapter are fail-open: the framework\n * logs a warning and falls back to pg. Sites that want\n * fail-closed wrap their adapter in try/catch and return\n * `null` on error.\n */\nexport interface NpSearchAdapterContext {\n /** Trimmed query string (already non-empty by the time this runs). */\n q: string;\n /** Subset of collection slugs the caller asked to search. */\n collections?: string[];\n /** Page size cap, already normalized. */\n limit: number;\n /** Skip count, already normalized. */\n offset: number;\n /**\n * Phase 12.4 — locale to scope results to. When set, the\n * framework expects only docs in this locale (for i18n\n * collections) plus all docs from non-i18n collections. The\n * default pg path applies a `locale = $1` filter on i18n\n * collections; external adapters typically rebuild the index\n * with one document per (sourceId, locale) and filter on the\n * locale field. Adapters that don't support per-locale\n * filtering can return `null` to fall through to pg.\n */\n locale?: string;\n}\n\nexport interface NpSearchAdapter {\n /**\n * Implementation hook. Return a `SearchResult` to override the\n * default pg tsvector path, or `null` / `undefined` to fall\n * through. Throws are fail-open (logged + treated as null).\n */\n search(\n ctx: NpSearchAdapterContext,\n ): Promise<SearchResult | null | undefined> | SearchResult | null | undefined;\n}\n\nlet currentAdapter: NpSearchAdapter | null = null;\n\n/**\n * Replace the global search adapter. Call once at app boot,\n * typically from a plugin's `setup()`. The framework holds at\n * most one adapter; sites that want to layer multiple engines\n * (e.g. blog → Algolia, products → Meilisearch) compose them\n * inside a single adapter and dispatch on `ctx.collections`.\n */\nexport function setSearchAdapter(adapter: NpSearchAdapter): void {\n if (typeof adapter?.search !== \"function\") {\n throw new Error(\"setSearchAdapter: adapter must implement search()\");\n }\n currentAdapter = adapter;\n}\n\nexport function getSearchAdapter(): NpSearchAdapter | null {\n return currentAdapter;\n}\n\n/** Reset to no adapter. Tests use this between cases. */\nexport function resetSearchAdapter(): void {\n currentAdapter = null;\n}\n","import { eq, sql } from \"drizzle-orm\";\nimport type { PgTable } from \"drizzle-orm/pg-core\";\n\nimport { NpNotFoundError, NpValidationError } from \"../errors.js\";\nimport { getI18nConfig } from \"../i18n/registry.js\";\nimport type { NpAuthUser } from \"../config/types.js\";\n\nimport {\n getAllCollectionSlugs,\n getCollectionConfig,\n getCollectionTable,\n} from \"./registry.js\";\nimport { getDocumentById, saveDocument } from \"./pipeline.js\";\nimport { getDb } from \"../db/runtime.js\";\n\ninterface TranslationRow {\n id: string;\n locale: string;\n slug: string;\n status: string;\n title?: unknown;\n updatedAt?: Date | string | null;\n translationGroupId: string;\n}\n\nfunction getTableColumn(table: PgTable, name: string): unknown {\n const column = (table as unknown as Record<string, unknown>)[name];\n if (!column) {\n throw new Error(`Column \"${name}\" not found on table`);\n }\n return column;\n}\n\n/**\n * Phase 12.3 — list every locale variant linked to the given\n * document. The `translationGroupId` keys the sibling lookup;\n * the originating row is included so callers can render a\n * \"current row + others\" tab strip without a separate query.\n *\n * Returns rows in the locale order from `getI18nConfig()` so\n * the UI's tab order matches the configured locale list rather\n * than insertion order.\n */\nexport async function findTranslations(\n collection: string,\n docId: string,\n): Promise<TranslationRow[]> {\n const config = getCollectionConfig(collection);\n if (!config.i18n) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"collection\",\n message: `Collection \"${collection}\" is not i18n-enabled`,\n },\n ]);\n }\n const table = getCollectionTable(collection) as PgTable;\n const db = getDb();\n const source = await getDocumentById(collection, docId);\n if (!source) throw new NpNotFoundError(collection, docId);\n const groupId = (source as { translationGroupId?: string }).translationGroupId;\n if (!groupId) {\n throw new Error(\n `Doc ${docId} in collection \"${collection}\" has no translationGroupId`,\n );\n }\n\n const rows = (await db\n .select()\n .from(table)\n .where(\n eq(getTableColumn(table, \"translationGroupId\") as never, groupId),\n )) as Array<Record<string, unknown>>;\n\n const ordering = getI18nConfig()?.locales ?? [];\n const rank = (locale: string): number => {\n const i = ordering.indexOf(locale);\n return i === -1 ? Number.MAX_SAFE_INTEGER : i;\n };\n\n return rows\n .map(\n (r): TranslationRow => ({\n id: String(r.id),\n locale: String(r.locale),\n slug: String(r.slug),\n status: String(r.status),\n title: r.title,\n updatedAt: r.updatedAt as Date | string | null,\n translationGroupId: String(r.translationGroupId),\n }),\n )\n .sort((a, b) => rank(a.locale) - rank(b.locale));\n}\n\n/**\n * Phase 12.3 — copy a doc into a new locale. The source row's\n * data is reused minus `id` / `slug` / `locale` / status; the\n * new row gets the source's `translationGroupId` so the two\n * link as siblings. The pipeline's locale validation runs as\n * usual (rejects unknown locales).\n *\n * The copy lands as a draft (`status: \"draft\"`) — translators\n * almost always want to review before publishing. Promote\n * normally via the existing publish flow once the translation\n * is ready.\n *\n * Slug is intentionally NOT copied: `applySlugField` will\n * derive a fresh one from the title (or whatever the configured\n * `useField` is) so the (locale, slug) unique index doesn't\n * collide if the source already used the slug in that locale.\n * Callers can override post-create via the regular update path.\n */\nexport async function createTranslation(\n collection: string,\n sourceDocId: string,\n targetLocale: string,\n user: NpAuthUser,\n): Promise<{ id: string }> {\n const config = getCollectionConfig(collection);\n if (!config.i18n) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"collection\",\n message: `Collection \"${collection}\" is not i18n-enabled`,\n },\n ]);\n }\n const i18n = getI18nConfig();\n if (!i18n) {\n throw new Error(\"i18n config is not initialised\");\n }\n if (!i18n.locales.includes(targetLocale)) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"targetLocale\",\n message: `Locale \"${targetLocale}\" is not configured`,\n },\n ]);\n }\n\n const source = await getDocumentById(collection, sourceDocId);\n if (!source) throw new NpNotFoundError(collection, sourceDocId);\n\n const sourceLocale = (source as { locale?: string }).locale;\n if (sourceLocale === targetLocale) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"targetLocale\",\n message: `Source row is already in locale \"${targetLocale}\"`,\n },\n ]);\n }\n\n // Reject duplicate translations — the (translationGroupId,\n // locale) shouldn't repeat. A second `createTranslation`\n // call for the same target should noop or 409 rather than\n // accidentally creating two rows the unique index would\n // happily accept (since slug differs).\n const existing = await findTranslations(collection, sourceDocId);\n if (existing.some((r) => r.locale === targetLocale)) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"targetLocale\",\n message: `A \"${targetLocale}\" translation already exists for this document`,\n },\n ]);\n }\n\n const groupId = (source as { translationGroupId?: string }).translationGroupId;\n if (!groupId) {\n throw new Error(\n `Doc ${sourceDocId} in collection \"${collection}\" has no translationGroupId`,\n );\n }\n\n // Strip framework-managed columns; saveDocument re-derives\n // them. Preserves user-authored fields (title / body / blocks\n // / etc.) so the translator has a starting point rather than\n // a blank form.\n const { id, slug, locale, status, _status, createdAt, updatedAt,\n createdBy, updatedBy, searchVector, translationGroupId, ...content } =\n source;\n void id; void slug; void locale; void status; void _status;\n void createdAt; void updatedAt; void createdBy; void updatedBy;\n void searchVector; void translationGroupId;\n\n const result = await saveDocument(\n collection,\n null,\n {\n ...content,\n locale: targetLocale,\n translationGroupId: groupId,\n },\n user,\n { status: \"draft\" },\n );\n\n return { id: result.doc.id as string };\n}\n\n/**\n * Phase 12.6 — translation completeness snapshot for the\n * admin Locales tab.\n *\n * Walks every i18n-enabled collection and counts:\n * - `totalGroups` — distinct `translation_group_id` values\n * (one per \"logical document\"; if the source has 5 base\n * pages, that's 5 groups regardless of locale spread)\n * - `counts[locale]` — actual rows per locale\n * - `missing[locale]` — `totalGroups - counts[locale]`,\n * i.e. how many groups still need this locale\n *\n * Returns `null` when i18n isn't configured. Non-i18n\n * collections are silently skipped — they don't have the\n * `translation_group_id` column and the dashboard isn't\n * meaningful for them.\n *\n * One SQL round-trip per i18n collection (two GROUP BYs in a\n * single query). For 1–2 i18n collections this is well under\n * the cost of the existing dashboard widgets.\n */\nexport interface NpTranslationProgressLocaleStats {\n count: number;\n missing: number;\n}\n\nexport interface NpCollectionTranslationProgress {\n collection: string;\n totalGroups: number;\n perLocale: Record<string, NpTranslationProgressLocaleStats>;\n}\n\nexport interface NpTranslationProgress {\n defaultLocale: string;\n locales: string[];\n collections: NpCollectionTranslationProgress[];\n}\n\nexport async function getTranslationProgress(): Promise<NpTranslationProgress | null> {\n const i18n = getI18nConfig();\n if (!i18n) return null;\n\n const db = getDb();\n const out: NpCollectionTranslationProgress[] = [];\n\n for (const slug of getAllCollectionSlugs()) {\n const config = getCollectionConfig(slug);\n if (!config.i18n) continue;\n const table = getCollectionTable(slug) as PgTable;\n const localeCol = getTableColumn(table, \"locale\");\n const groupCol = getTableColumn(table, \"translationGroupId\");\n\n // Two parallel queries: per-locale row counts, plus the\n // total group count. Could be fused into one CTE, but the\n // two-query form keeps the Drizzle expressions readable\n // and the cost is negligible for the volumes the admin UI\n // is reading.\n const localeRows = (await db\n .select({\n locale: localeCol as never,\n count: sql<number>`count(*)::int`,\n })\n .from(table)\n .groupBy(localeCol as never)) as Array<{\n locale: string;\n count: number;\n }>;\n\n const totalRows = (await db\n .select({\n groups: sql<number>`count(distinct ${groupCol})::int`,\n })\n .from(table)) as Array<{ groups: number }>;\n\n const totalGroups = totalRows[0]?.groups ?? 0;\n\n const counts: Record<string, number> = Object.fromEntries(\n i18n.locales.map((loc) => [loc, 0]),\n );\n for (const row of localeRows) {\n if (row.locale in counts) {\n counts[row.locale] = row.count;\n }\n }\n\n const perLocale: Record<string, NpTranslationProgressLocaleStats> = {};\n for (const loc of i18n.locales) {\n const count = counts[loc] ?? 0;\n perLocale[loc] = {\n count,\n missing: Math.max(0, totalGroups - count),\n };\n }\n\n out.push({\n collection: slug,\n totalGroups,\n perLocale,\n });\n }\n\n return {\n defaultLocale: i18n.defaultLocale,\n locales: i18n.locales,\n collections: out,\n };\n}\n"],"mappings":"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","names":["and","eq","eq","and","and","count","eq","eq","and","count","and","eq","eq","and","and","count","desc","eq","SUPPORTED_TARGETS","eq","and","desc","count","and","desc","eq","isNull","or","and","eq","or","isNull","desc","and","desc","eq","gt","isNull","or","and","eq","isNull","or","gt","desc","and","eq","isNull","ne","and","desc","eq","count","and","eq","desc","count","sql","sql","eq","normalizeLimit","getLogger","getTableColumn","eq","eq","sql","getTableColumn","eq","sql","count","eq","and","ne","isNull"]}
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-SBCVAC2Z.js";
4
4
  import {
5
5
  getLogger
6
- } from "./chunk-NFHS7CFV.js";
6
+ } from "./chunk-Q7MK5ZKG.js";
7
7
  import {
8
8
  getDb
9
9
  } from "./chunk-XANPEOJC.js";
@@ -65,4 +65,4 @@ export {
65
65
  recordAuditEvent,
66
66
  listAuditEvents
67
67
  };
68
- //# sourceMappingURL=chunk-ML2E3P3X.js.map
68
+ //# sourceMappingURL=chunk-5C22NDW4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/community/audit.ts"],"sourcesContent":["import { and, count, desc, eq, gte, lt } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npAuditEvents } from \"../db/schema/community.js\";\nimport { getLogger } from \"../observability/logger.js\";\nimport { getCurrentSiteId } from \"../sites/context.js\";\n\n/**\n * Append-only moderation audit log. Every hide / restore / ban /\n * role-grant write goes through here so admins can later answer\n * \"who took this action and when?\" without diffing application logs.\n *\n * Writes are best-effort: a failed audit insert MUST NOT prevent the\n * underlying mod action from succeeding (logged via the observability\n * hooks instead). Reads are paginated and indexed by target.\n */\n\nexport type AuditActorKind = \"staff\" | \"member\" | \"system\";\n\nexport interface AuditActor {\n kind: AuditActorKind;\n /** Set only for `kind: \"staff\"`. */\n userId?: string;\n /** Set only for `kind: \"member\"`. */\n memberId?: string;\n}\n\nexport interface RecordAuditEventInput {\n actor: AuditActor;\n action: string;\n targetType?: string;\n targetId?: string;\n payload?: Record<string, unknown>;\n /**\n * Phase 17 — site this event belongs to. When omitted the\n * writer reads `getCurrentSiteId()` so request-driven calls\n * automatically scope to the resolving tenant. Pass `null`\n * explicitly to record an unscoped event (super-admin\n * cross-site action, background job).\n */\n siteId?: string | null;\n}\n\nexport interface AuditEventRow {\n id: string;\n actorKind: AuditActorKind;\n actorUserId: string | null;\n actorMemberId: string | null;\n action: string;\n targetType: string | null;\n targetId: string | null;\n payload: Record<string, unknown>;\n siteId: string | null;\n createdAt: Date;\n}\n\nexport async function recordAuditEvent(input: RecordAuditEventInput): Promise<void> {\n const db = getDb();\n try {\n // Phase 17 — fill `site_id` from the request resolver when\n // the caller doesn't pin it explicitly. Resolver returns\n // null in non-request contexts (jobs, scripts), which we\n // record as a NULL site so super-admin queries can find\n // them via \"no site filter.\"\n const siteId = input.siteId === undefined ? await getCurrentSiteId() : input.siteId;\n await db.insert(npAuditEvents).values({\n actorKind: input.actor.kind,\n actorUserId: input.actor.userId ?? null,\n actorMemberId: input.actor.memberId ?? null,\n action: input.action,\n targetType: input.targetType ?? null,\n targetId: input.targetId ?? null,\n payload: input.payload ?? {},\n siteId,\n });\n } catch (err) {\n // Audit failures must not block the underlying mod action — but\n // they MUST surface, otherwise gaps in the forensic record go\n // unnoticed (column drift, FK violation, transient pg blip).\n getLogger().error(\"audit insert failed\", {\n error: err instanceof Error ? err.message : String(err),\n action: input.action,\n targetType: input.targetType ?? null,\n targetId: input.targetId ?? null,\n });\n }\n}\n\nexport interface ListAuditOptions {\n /** Filter to audit events targeting one specific row. */\n targetType?: string;\n targetId?: string;\n /** Filter to events caused by a specific actor. */\n actorUserId?: string;\n actorMemberId?: string;\n /**\n * Filter to events whose `action` matches. Common operational\n * query: \"show every ban issued this week\" →\n * `action=\"member.ban.issue\"` plus `since`.\n */\n action?: string;\n /** Lower-bound `created_at` (inclusive). */\n since?: Date;\n /** Upper-bound `created_at` (exclusive). */\n until?: Date;\n /**\n * Phase 17 — site filter. `undefined` means \"use current\n * request's site\" (the typical admin-page query). Pass an\n * explicit string to view another site's audit log\n * (super-admin cross-site triage). Pass `null` to skip the\n * filter entirely (every site's events).\n */\n siteId?: string | null;\n limit?: number;\n offset?: number;\n}\n\nexport async function listAuditEvents(\n options: ListAuditOptions = {},\n): Promise<{ events: AuditEventRow[]; totalDocs: number }> {\n const db = getDb();\n const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);\n const offset = Math.max(options.offset ?? 0, 0);\n\n const filters = [];\n if (options.targetType) filters.push(eq(npAuditEvents.targetType, options.targetType));\n if (options.targetId) filters.push(eq(npAuditEvents.targetId, options.targetId));\n if (options.actorUserId) filters.push(eq(npAuditEvents.actorUserId, options.actorUserId));\n if (options.actorMemberId) filters.push(eq(npAuditEvents.actorMemberId, options.actorMemberId));\n if (options.action) filters.push(eq(npAuditEvents.action, options.action));\n if (options.since) filters.push(gte(npAuditEvents.createdAt, options.since));\n if (options.until) filters.push(lt(npAuditEvents.createdAt, options.until));\n\n // Phase 17 — site scope.\n // `undefined` (default) → use the resolver's current site if\n // any. Pass `null` to skip filtering\n // (cross-site, super-admin).\n if (options.siteId !== null) {\n const resolvedSite = options.siteId !== undefined ? options.siteId : await getCurrentSiteId();\n if (resolvedSite !== null) {\n filters.push(eq(npAuditEvents.siteId, resolvedSite));\n }\n }\n\n const where = filters.length > 0 ? and(...filters) : undefined;\n\n const rows = (await db\n .select()\n .from(npAuditEvents)\n .where(where)\n .orderBy(desc(npAuditEvents.createdAt))\n .limit(limit)\n .offset(offset)) as AuditEventRow[];\n\n const [totalRow] = (await db\n .select({ total: count() })\n .from(npAuditEvents)\n .where(where)) as Array<{ total: number }>;\n const totalDocs = Number(totalRow?.total ?? 0);\n return { events: rows, totalDocs };\n}\n"],"mappings":";;;;;;;;;;;;;;AAAA,SAAS,KAAK,OAAO,MAAM,IAAI,KAAK,UAAU;AAwD9C,eAAsB,iBAAiB,OAA6C;AAClF,QAAM,KAAK,MAAM;AACjB,MAAI;AAMF,UAAM,SAAS,MAAM,WAAW,SAAY,MAAM,iBAAiB,IAAI,MAAM;AAC7E,UAAM,GAAG,OAAO,aAAa,EAAE,OAAO;AAAA,MACpC,WAAW,MAAM,MAAM;AAAA,MACvB,aAAa,MAAM,MAAM,UAAU;AAAA,MACnC,eAAe,MAAM,MAAM,YAAY;AAAA,MACvC,QAAQ,MAAM;AAAA,MACd,YAAY,MAAM,cAAc;AAAA,MAChC,UAAU,MAAM,YAAY;AAAA,MAC5B,SAAS,MAAM,WAAW,CAAC;AAAA,MAC3B;AAAA,IACF,CAAC;AAAA,EACH,SAAS,KAAK;AAIZ,cAAU,EAAE,MAAM,uBAAuB;AAAA,MACvC,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,MACtD,QAAQ,MAAM;AAAA,MACd,YAAY,MAAM,cAAc;AAAA,MAChC,UAAU,MAAM,YAAY;AAAA,IAC9B,CAAC;AAAA,EACH;AACF;AA+BA,eAAsB,gBACpB,UAA4B,CAAC,GAC4B;AACzD,QAAM,KAAK,MAAM;AACjB,QAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,QAAQ,SAAS,IAAI,CAAC,GAAG,GAAG;AAC5D,QAAM,SAAS,KAAK,IAAI,QAAQ,UAAU,GAAG,CAAC;AAE9C,QAAM,UAAU,CAAC;AACjB,MAAI,QAAQ,WAAY,SAAQ,KAAK,GAAG,cAAc,YAAY,QAAQ,UAAU,CAAC;AACrF,MAAI,QAAQ,SAAU,SAAQ,KAAK,GAAG,cAAc,UAAU,QAAQ,QAAQ,CAAC;AAC/E,MAAI,QAAQ,YAAa,SAAQ,KAAK,GAAG,cAAc,aAAa,QAAQ,WAAW,CAAC;AACxF,MAAI,QAAQ,cAAe,SAAQ,KAAK,GAAG,cAAc,eAAe,QAAQ,aAAa,CAAC;AAC9F,MAAI,QAAQ,OAAQ,SAAQ,KAAK,GAAG,cAAc,QAAQ,QAAQ,MAAM,CAAC;AACzE,MAAI,QAAQ,MAAO,SAAQ,KAAK,IAAI,cAAc,WAAW,QAAQ,KAAK,CAAC;AAC3E,MAAI,QAAQ,MAAO,SAAQ,KAAK,GAAG,cAAc,WAAW,QAAQ,KAAK,CAAC;AAM1E,MAAI,QAAQ,WAAW,MAAM;AAC3B,UAAM,eAAe,QAAQ,WAAW,SAAY,QAAQ,SAAS,MAAM,iBAAiB;AAC5F,QAAI,iBAAiB,MAAM;AACzB,cAAQ,KAAK,GAAG,cAAc,QAAQ,YAAY,CAAC;AAAA,IACrD;AAAA,EACF;AAEA,QAAM,QAAQ,QAAQ,SAAS,IAAI,IAAI,GAAG,OAAO,IAAI;AAErD,QAAM,OAAQ,MAAM,GACjB,OAAO,EACP,KAAK,aAAa,EAClB,MAAM,KAAK,EACX,QAAQ,KAAK,cAAc,SAAS,CAAC,EACrC,MAAM,KAAK,EACX,OAAO,MAAM;AAEhB,QAAM,CAAC,QAAQ,IAAK,MAAM,GACvB,OAAO,EAAE,OAAO,MAAM,EAAE,CAAC,EACzB,KAAK,aAAa,EAClB,MAAM,KAAK;AACd,QAAM,YAAY,OAAO,UAAU,SAAS,CAAC;AAC7C,SAAO,EAAE,QAAQ,MAAM,UAAU;AACnC;","names":[]}
@@ -168,4 +168,4 @@ export {
168
168
  validateCommunitySettingsPatch,
169
169
  updateCommunitySettings
170
170
  };
171
- //# sourceMappingURL=chunk-RKM4GDWM.js.map
171
+ //# sourceMappingURL=chunk-6MRTH734.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/community/settings.ts"],"sourcesContent":["import { and, eq } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npSettings } from \"../db/schema/system.js\";\nimport { NpValidationError } from \"../errors.js\";\n\n/**\n * Site-wide community settings, persisted in the generic `np_settings`\n * table under the `community` key. Sites that never visit the admin UI\n * inherit `DEFAULT_COMMUNITY_SETTINGS` — every read goes through\n * `getCommunitySettings()` which merges the stored value over the\n * defaults so adding a new field doesn't break existing installs.\n *\n * Validation runs on the write path only — readers trust whatever is\n * in the table because the only writer is the admin API which\n * pre-validates. Tests poke values directly into `np_settings` for\n * fault-injection cases.\n */\n/**\n * Per-member upload quota / rate limit. `null` on either field\n * means unlimited (the default — no quota). Both bounds count\n * non-deleted rows on `np_media` keyed by `uploaded_by_member_id`,\n * so admin purges (Phase 9.7l) free up quota the same way a\n * member self-deleting their content would. Staff uploads are\n * never gated.\n */\nexport interface NpMemberUploadQuota {\n /** Max uploads in the trailing 24h window. `null` = unlimited. */\n perDay: number | null;\n /** Lifetime cap on non-deleted member uploads. `null` = unlimited. */\n total: number | null;\n}\n\nexport interface NpCommunitySettings {\n /**\n * Allow-list of reaction `kind` strings. Members can only add\n * reactions whose kind is in this list; values that pass the\n * `KIND_RE` regex but aren't in the list are rejected with a 400.\n * Removal of an already-existing reaction is NOT gated — if a kind\n * is removed from the list, members can still un-react it.\n */\n reactionKinds: string[];\n /**\n * When false, `/api/members/register` refuses new sign-ups with a\n * 403. Existing members can still sign in. Sites that want\n * invite-only flows turn this off and provision via admin tooling.\n */\n registrationEnabled: boolean;\n /** Per-member upload limits. See `NpMemberUploadQuota`. */\n memberUploadQuota: NpMemberUploadQuota;\n}\n\nexport const DEFAULT_COMMUNITY_SETTINGS: NpCommunitySettings = {\n reactionKinds: [\"like\"],\n registrationEnabled: true,\n memberUploadQuota: { perDay: null, total: null },\n};\n\nconst SETTINGS_KEY = \"community\";\nconst KIND_RE = /^[a-z][a-z0-9_-]{0,29}$/;\nconst MAX_REACTION_KINDS = 32;\nconst MAX_QUOTA_VALUE = 1_000_000;\n\nfunction mergeWithDefaults(stored: unknown): NpCommunitySettings {\n if (!stored || typeof stored !== \"object\" || Array.isArray(stored)) {\n return { ...DEFAULT_COMMUNITY_SETTINGS };\n }\n const raw = stored as Record<string, unknown>;\n const reactionKinds =\n Array.isArray(raw.reactionKinds) && raw.reactionKinds.every((k) => typeof k === \"string\")\n ? (raw.reactionKinds)\n : DEFAULT_COMMUNITY_SETTINGS.reactionKinds;\n const registrationEnabled =\n typeof raw.registrationEnabled === \"boolean\"\n ? raw.registrationEnabled\n : DEFAULT_COMMUNITY_SETTINGS.registrationEnabled;\n const memberUploadQuota = readQuota(raw.memberUploadQuota);\n return { reactionKinds, registrationEnabled, memberUploadQuota };\n}\n\nfunction readQuota(raw: unknown): NpMemberUploadQuota {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) {\n return { ...DEFAULT_COMMUNITY_SETTINGS.memberUploadQuota };\n }\n const v = raw as Record<string, unknown>;\n const perDay =\n typeof v.perDay === \"number\" && Number.isFinite(v.perDay) && v.perDay >= 0\n ? Math.floor(v.perDay)\n : null;\n const total =\n typeof v.total === \"number\" && Number.isFinite(v.total) && v.total >= 0\n ? Math.floor(v.total)\n : null;\n return { perDay, total };\n}\n\nexport async function getCommunitySettings(): Promise<NpCommunitySettings> {\n const db = getDb();\n const { getCurrentSiteId } = await import(\"../sites/context.js\");\n const { NP_DEFAULT_SITE_ID } = await import(\"../sites/registry.js\");\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const [row] = (await db\n .select()\n .from(npSettings)\n .where(and(eq(npSettings.siteId, siteId), eq(npSettings.key, SETTINGS_KEY)))\n .limit(1)) as Array<{ value: unknown }>;\n return mergeWithDefaults(row?.value);\n}\n\n/**\n * Validates an incoming partial patch from the admin UI. Returns the\n * fully-merged settings object that should be persisted. Throws\n * `NpValidationError` with field-level errors on any malformed input.\n */\nexport function validateCommunitySettingsPatch(\n current: NpCommunitySettings,\n patch: unknown,\n): NpCommunitySettings {\n if (!patch || typeof patch !== \"object\" || Array.isArray(patch)) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"body\", message: \"Body must be a JSON object\" },\n ]);\n }\n const raw = patch as Record<string, unknown>;\n const errors: Array<{ field: string; message: string }> = [];\n let next: NpCommunitySettings = { ...current };\n\n if (\"reactionKinds\" in raw) {\n if (!Array.isArray(raw.reactionKinds)) {\n errors.push({ field: \"reactionKinds\", message: \"Must be an array of strings\" });\n } else if (raw.reactionKinds.length === 0) {\n // Empty list disables reactions entirely. Allowed deliberately —\n // sites that don't want reactions opt out by clearing the list.\n next = { ...next, reactionKinds: [] };\n } else if (raw.reactionKinds.length > MAX_REACTION_KINDS) {\n errors.push({\n field: \"reactionKinds\",\n message: `At most ${MAX_REACTION_KINDS} kinds`,\n });\n } else {\n const seen = new Set<string>();\n const cleaned: string[] = [];\n for (let i = 0; i < raw.reactionKinds.length; i++) {\n const k = raw.reactionKinds[i];\n if (typeof k !== \"string\" || !KIND_RE.test(k)) {\n errors.push({\n field: `reactionKinds[${i}]`,\n message: \"Each kind must match [a-z][a-z0-9_-]{0,29}\",\n });\n continue;\n }\n if (seen.has(k)) {\n errors.push({ field: `reactionKinds[${i}]`, message: `Duplicate kind '${k}'` });\n continue;\n }\n seen.add(k);\n cleaned.push(k);\n }\n if (errors.length === 0) next = { ...next, reactionKinds: cleaned };\n }\n }\n\n if (\"registrationEnabled\" in raw) {\n if (typeof raw.registrationEnabled !== \"boolean\") {\n errors.push({ field: \"registrationEnabled\", message: \"Must be a boolean\" });\n } else {\n next = { ...next, registrationEnabled: raw.registrationEnabled };\n }\n }\n\n if (\"memberUploadQuota\" in raw) {\n const q = raw.memberUploadQuota;\n if (!q || typeof q !== \"object\" || Array.isArray(q)) {\n errors.push({\n field: \"memberUploadQuota\",\n message: \"Must be an object with optional `perDay` / `total` keys\",\n });\n } else {\n const obj = q as Record<string, unknown>;\n const validateBound = (\n key: \"perDay\" | \"total\",\n ): number | null | undefined => {\n if (!(key in obj)) return undefined; // not patched — keep current\n const v = obj[key];\n if (v === null) return null;\n if (typeof v !== \"number\" || !Number.isFinite(v) || v < 0 || !Number.isInteger(v)) {\n errors.push({\n field: `memberUploadQuota.${key}`,\n message: \"Must be a non-negative integer or null\",\n });\n return undefined;\n }\n if (v > MAX_QUOTA_VALUE) {\n errors.push({\n field: `memberUploadQuota.${key}`,\n message: `At most ${MAX_QUOTA_VALUE}`,\n });\n return undefined;\n }\n return v;\n };\n const perDay = validateBound(\"perDay\");\n const total = validateBound(\"total\");\n if (perDay !== undefined || total !== undefined) {\n next = {\n ...next,\n memberUploadQuota: {\n perDay: perDay !== undefined ? perDay : next.memberUploadQuota.perDay,\n total: total !== undefined ? total : next.memberUploadQuota.total,\n },\n };\n }\n }\n }\n\n if (errors.length > 0) throw new NpValidationError(\"Invalid input\", errors);\n return next;\n}\n\nexport async function updateCommunitySettings(\n patch: unknown,\n updatedBy: string | null,\n): Promise<NpCommunitySettings> {\n const current = await getCommunitySettings();\n const next = validateCommunitySettingsPatch(current, patch);\n const db = getDb();\n // #272 — write: must NOT silently fall through. A staff member\n // on tenant A who saves community settings without a resolved\n // site context would otherwise overwrite the default tenant's\n // policy — silently and across tenants.\n const { requireSiteId } = await import(\"../sites/context.js\");\n const siteId = await requireSiteId();\n await db\n .insert(npSettings)\n .values({\n siteId,\n key: SETTINGS_KEY,\n value: next,\n updatedBy: updatedBy ?? null,\n updatedAt: new Date(),\n })\n .onConflictDoUpdate({\n target: [npSettings.siteId, npSettings.key],\n set: {\n value: next,\n updatedBy: updatedBy ?? null,\n updatedAt: new Date(),\n },\n });\n return next;\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runHook
3
- } from "./chunk-MLXKZK6G.js";
3
+ } from "./chunk-TSCXXBOM.js";
4
4
  import {
5
5
  getAllCollectionSlugs,
6
6
  getCollectionConfig,
@@ -80,4 +80,4 @@ async function publishScheduledDocuments(atTime = /* @__PURE__ */ new Date()) {
80
80
  export {
81
81
  publishScheduledDocuments
82
82
  };
83
- //# sourceMappingURL=chunk-PW43RCJK.js.map
83
+ //# sourceMappingURL=chunk-6OUWW6JF.js.map
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-OROPGO65.js";
4
4
  import {
5
5
  getLogger
6
- } from "./chunk-NFHS7CFV.js";
6
+ } from "./chunk-Q7MK5ZKG.js";
7
7
  import {
8
8
  getDb
9
9
  } from "./chunk-XANPEOJC.js";
@@ -78,4 +78,4 @@ export {
78
78
  pruneJobLogsOlderThan,
79
79
  countJobLogs
80
80
  };
81
- //# sourceMappingURL=chunk-QBIJZZ5V.js.map
81
+ //# sourceMappingURL=chunk-CGLJBRRX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/jobs/job-log.ts"],"sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\n\nimport { and, asc, eq, gte, lt } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { readEnvPositiveInt } from \"../config/env.js\";\nimport { npJobLogs } from \"../db/schema/system.js\";\nimport { type NpLogLevel, getLogger } from \"../observability/logger.js\";\n\n/**\n * Phase 20.3 — per-job log capture.\n *\n * Each handler invocation runs inside an AsyncLocalStorage context\n * keyed on the pg-boss job id. While inside the context,\n * `recordJobLog()` writes to `np_job_logs` stamped with that id;\n * outside the context it no-ops, so the helper is safe to import\n * from non-handler code (and from plugins that don't know whether\n * they're inside a handler).\n *\n * The framework's pg-boss adapter sets the context automatically\n * (see `pg-boss-adapter.ts` — every `boss.work()` callback is\n * wrapped in `runInJobContext`). Handlers don't have to do\n * anything to opt in — calls to `recordJobLog()` just work.\n */\n\ninterface JobLogContext {\n jobId: string;\n}\n\nconst jobLogStorage = new AsyncLocalStorage<JobLogContext>();\n\nexport function runInJobContext<T>(jobId: string, fn: () => Promise<T> | T): Promise<T> | T {\n return jobLogStorage.run({ jobId }, fn);\n}\n\nexport function getCurrentJobId(): string | null {\n const store = jobLogStorage.getStore();\n return store?.jobId ?? null;\n}\n\n/**\n * Record one log entry for the currently-running job. Async because\n * it writes to Postgres; callers can `void` the promise if they\n * don't need to wait. No-ops outside a job context (returns\n * immediately without touching the DB).\n *\n * Errors writing to the log table are swallowed via the framework\n * logger at `warn` — a logging failure must never cascade into a\n * job failure or shutdown loop.\n */\nexport async function recordJobLog(\n level: NpLogLevel,\n message: string,\n context?: Record<string, unknown>,\n): Promise<void> {\n const jobId = getCurrentJobId();\n if (!jobId) return;\n\n try {\n const db = getDb();\n await db.insert(npJobLogs).values({\n jobId,\n level,\n message,\n context: context ?? null,\n });\n } catch (err) {\n // Don't throw from a logging path — just surface to whatever\n // sink the framework logger is wired to.\n getLogger().warn(\"recordJobLog failed\", {\n jobId,\n level,\n message,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n}\n\nexport interface NpJobLogEntry {\n id: string;\n jobId: string;\n level: NpLogLevel;\n message: string;\n context: Record<string, unknown> | null;\n createdAt: Date;\n}\n\nexport interface ListJobLogsOptions {\n /** Cap on rows returned. Default 200, max 1000 to keep the admin UI snappy. */\n limit?: number;\n /** Skip this many rows for pagination. */\n offset?: number;\n}\n\n/**\n * Fetch log entries for one job in chronological order. Paged so\n * a runaway handler doesn't blow up the admin UI.\n */\nexport async function listJobLogs(\n jobId: string,\n options: ListJobLogsOptions = {},\n): Promise<NpJobLogEntry[]> {\n const limit = Math.min(Math.max(1, options.limit ?? 200), 1000);\n const offset = Math.max(0, options.offset ?? 0);\n const db = getDb();\n\n const rows = (await db\n .select()\n .from(npJobLogs)\n .where(eq(npJobLogs.jobId, jobId))\n .orderBy(asc(npJobLogs.createdAt))\n .limit(limit)\n .offset(offset)) as Array<{\n id: string;\n jobId: string;\n level: string;\n message: string;\n context: Record<string, unknown> | null;\n createdAt: Date;\n }>;\n\n return rows.map((row) => ({\n id: row.id,\n jobId: row.jobId,\n level: row.level as NpLogLevel,\n message: row.message,\n context: row.context,\n createdAt: row.createdAt,\n }));\n}\n\n/**\n * How long per-job log rows survive before the cleanup handler\n * deletes them. Compliance regimes (GDPR, SOX) frequently dictate\n * a specific window — override via `NP_JOB_LOG_RETENTION_DAYS`.\n */\nexport const DEFAULT_JOB_LOG_RETENTION_MS =\n readEnvPositiveInt(\"NP_JOB_LOG_RETENTION_DAYS\", 14) * 24 * 60 * 60 * 1000;\n\n/**\n * Delete log rows older than the cutoff. Safe to call from a\n * scheduled handler — does not touch logs for active or recent\n * jobs unless they pre-date the cutoff.\n *\n * Returns the row count deleted so the cron handler can log a\n * useful retention summary.\n */\nexport async function pruneJobLogsOlderThan(cutoff: Date): Promise<number> {\n const db = getDb();\n const deleted = (await db\n .delete(npJobLogs)\n .where(lt(npJobLogs.createdAt, cutoff))\n .returning({ id: npJobLogs.id })) as Array<{ id: string }>;\n return deleted.length;\n}\n\n/**\n * Count entries for a job — drives the admin badge \"37 log lines\"\n * without paying for the page payload until the operator expands.\n */\nexport async function countJobLogs(jobId: string, sinceCreatedAt?: Date): Promise<number> {\n const db = getDb();\n const where = sinceCreatedAt\n ? and(eq(npJobLogs.jobId, jobId), gte(npJobLogs.createdAt, sinceCreatedAt))\n : eq(npJobLogs.jobId, jobId);\n const rows = (await db.select({ id: npJobLogs.id }).from(npJobLogs).where(where)) as Array<{\n id: string;\n }>;\n return rows.length;\n}\n"],"mappings":";;;;;;;;;;;;;;AAAA,SAAS,yBAAyB;AAElC,SAAS,KAAK,KAAK,IAAI,KAAK,UAAU;AA2BtC,IAAM,gBAAgB,IAAI,kBAAiC;AAEpD,SAAS,gBAAmB,OAAe,IAA0C;AAC1F,SAAO,cAAc,IAAI,EAAE,MAAM,GAAG,EAAE;AACxC;AAEO,SAAS,kBAAiC;AAC/C,QAAM,QAAQ,cAAc,SAAS;AACrC,SAAO,OAAO,SAAS;AACzB;AAYA,eAAsB,aACpB,OACA,SACA,SACe;AACf,QAAM,QAAQ,gBAAgB;AAC9B,MAAI,CAAC,MAAO;AAEZ,MAAI;AACF,UAAM,KAAK,MAAM;AACjB,UAAM,GAAG,OAAO,SAAS,EAAE,OAAO;AAAA,MAChC;AAAA,MACA;AAAA,MACA;AAAA,MACA,SAAS,WAAW;AAAA,IACtB,CAAC;AAAA,EACH,SAAS,KAAK;AAGZ,cAAU,EAAE,KAAK,uBAAuB;AAAA,MACtC;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,IACxD,CAAC;AAAA,EACH;AACF;AAsBA,eAAsB,YACpB,OACA,UAA8B,CAAC,GACL;AAC1B,QAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,GAAG,QAAQ,SAAS,GAAG,GAAG,GAAI;AAC9D,QAAM,SAAS,KAAK,IAAI,GAAG,QAAQ,UAAU,CAAC;AAC9C,QAAM,KAAK,MAAM;AAEjB,QAAM,OAAQ,MAAM,GACjB,OAAO,EACP,KAAK,SAAS,EACd,MAAM,GAAG,UAAU,OAAO,KAAK,CAAC,EAChC,QAAQ,IAAI,UAAU,SAAS,CAAC,EAChC,MAAM,KAAK,EACX,OAAO,MAAM;AAShB,SAAO,KAAK,IAAI,CAAC,SAAS;AAAA,IACxB,IAAI,IAAI;AAAA,IACR,OAAO,IAAI;AAAA,IACX,OAAO,IAAI;AAAA,IACX,SAAS,IAAI;AAAA,IACb,SAAS,IAAI;AAAA,IACb,WAAW,IAAI;AAAA,EACjB,EAAE;AACJ;AAOO,IAAM,+BACX,mBAAmB,6BAA6B,EAAE,IAAI,KAAK,KAAK,KAAK;AAUvE,eAAsB,sBAAsB,QAA+B;AACzE,QAAM,KAAK,MAAM;AACjB,QAAM,UAAW,MAAM,GACpB,OAAO,SAAS,EAChB,MAAM,GAAG,UAAU,WAAW,MAAM,CAAC,EACrC,UAAU,EAAE,IAAI,UAAU,GAAG,CAAC;AACjC,SAAO,QAAQ;AACjB;AAMA,eAAsB,aAAa,OAAe,gBAAwC;AACxF,QAAM,KAAK,MAAM;AACjB,QAAM,QAAQ,iBACV,IAAI,GAAG,UAAU,OAAO,KAAK,GAAG,IAAI,UAAU,WAAW,cAAc,CAAC,IACxE,GAAG,UAAU,OAAO,KAAK;AAC7B,QAAM,OAAQ,MAAM,GAAG,OAAO,EAAE,IAAI,UAAU,GAAG,CAAC,EAAE,KAAK,SAAS,EAAE,MAAM,KAAK;AAG/E,SAAO,KAAK;AACd;","names":[]}