@nexpress/core 0.3.6 → 0.3.8
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.
- package/dist/{audit-43OLHR3U.js → audit-ZLNKBIDO.js} +3 -3
- package/dist/auth.js +4 -4
- package/dist/{can-UJ2NAOIR.js → can-U5F4JBZ7.js} +2 -2
- package/dist/{chunk-UMEFU7Y3.js → chunk-2O2KMHLO.js} +10 -10
- package/dist/chunk-2O2KMHLO.js.map +1 -0
- package/dist/{chunk-ELK6AVW5.js → chunk-2X3GBJOT.js} +2 -2
- package/dist/{chunk-ML2E3P3X.js → chunk-5C22NDW4.js} +2 -2
- package/dist/chunk-5C22NDW4.js.map +1 -0
- package/dist/{chunk-RKM4GDWM.js → chunk-6MRTH734.js} +1 -1
- package/dist/chunk-6MRTH734.js.map +1 -0
- package/dist/{chunk-STOLH4V2.js → chunk-6PFUXZJ6.js} +12 -12
- package/dist/chunk-6PFUXZJ6.js.map +1 -0
- package/dist/{chunk-WRDIRDH7.js → chunk-CD74WQK7.js} +76 -28
- package/dist/chunk-CD74WQK7.js.map +1 -0
- package/dist/{chunk-QBIJZZ5V.js → chunk-CGLJBRRX.js} +2 -2
- package/dist/chunk-CGLJBRRX.js.map +1 -0
- package/dist/{chunk-2VZZ7M26.js → chunk-EAYUAXW3.js} +3 -3
- package/dist/chunk-EAYUAXW3.js.map +1 -0
- package/dist/{chunk-2N53KKIL.js → chunk-EWVXP3GP.js} +2 -2
- package/dist/{chunk-CAS4Z6IN.js → chunk-I4FSVEJK.js} +1 -1
- package/dist/chunk-I4FSVEJK.js.map +1 -0
- package/dist/{chunk-KHTS6Y3E.js → chunk-JKTU67A7.js} +2 -2
- package/dist/{chunk-KHTS6Y3E.js.map → chunk-JKTU67A7.js.map} +1 -1
- package/dist/{chunk-LN6NTH6E.js → chunk-K4CJ3KXB.js} +3 -3
- package/dist/chunk-K4CJ3KXB.js.map +1 -0
- package/dist/{chunk-B7DTNT4O.js → chunk-MWLSXK6Y.js} +2 -2
- package/dist/{chunk-7KGF7JVJ.js → chunk-PPUHXOWZ.js} +2 -2
- package/dist/{chunk-NFHS7CFV.js → chunk-Q7MK5ZKG.js} +2 -2
- package/dist/{chunk-6UV2P5MW.js → chunk-TIWJVQOO.js} +3 -3
- package/dist/chunk-TIWJVQOO.js.map +1 -0
- package/dist/{chunk-L6VG7IK6.js → chunk-VBVLYFSZ.js} +2 -2
- package/dist/chunk-VBVLYFSZ.js.map +1 -0
- package/dist/{chunk-YYPSQMFY.js → chunk-VX3HM5TF.js} +2 -2
- package/dist/{chunk-RDTTK27V.js → chunk-XPD7EQML.js} +3 -3
- package/dist/chunk-XPD7EQML.js.map +1 -0
- package/dist/{chunk-RJ76SKWQ.js → chunk-XU2GJJ6Z.js} +1 -1
- package/dist/chunk-XU2GJJ6Z.js.map +1 -0
- package/dist/{chunk-WJJ5MBH5.js → chunk-YEOQJ7WW.js} +1 -1
- package/dist/chunk-YEOQJ7WW.js.map +1 -0
- package/dist/community.js +14 -14
- package/dist/{config-YFGOXHSR.js → config-2CV7KZ3D.js} +5 -5
- package/dist/{digest-ZODDTXA2.js → digest-IWHMJPXI.js} +4 -4
- package/dist/{host-SUX3SPOX.js → host-C5PGUXX7.js} +4 -4
- package/dist/i18n.js +2 -2
- package/dist/index.js +21 -21
- package/dist/index.js.map +1 -1
- package/dist/{job-log-N3IGI4NA.js → job-log-UY6ERPQZ.js} +3 -3
- package/dist/jobs.js +3 -3
- package/dist/{logger-2WUTTELV.js → logger-6ZGEKEMK.js} +2 -2
- package/dist/media.js +3 -3
- package/dist/{mentions-U4JACYI6.js → mentions-LQRZWAGO.js} +2 -2
- package/dist/{mutes-MNQP6ACF.js → mutes-PQA6U5X7.js} +2 -2
- package/dist/{notification-prefs-H4HFVCL7.js → notification-prefs-62NX2GBF.js} +2 -2
- package/dist/observability.js +2 -2
- package/dist/{reputation-ICIXDGPM.js → reputation-5DJLDBZY.js} +3 -3
- package/dist/{scheduled-T5WZ4I6O.js → scheduled-PF2HECSF.js} +5 -5
- package/dist/seo.js +4 -4
- package/dist/{settings-OZWM6L2K.js → settings-NBAP7E5E.js} +2 -2
- package/dist/{strings-4EWJYDOG.js → strings-O2M7VSKV.js} +3 -3
- package/package.json +1 -1
- package/dist/chunk-2VZZ7M26.js.map +0 -1
- package/dist/chunk-6UV2P5MW.js.map +0 -1
- package/dist/chunk-CAS4Z6IN.js.map +0 -1
- package/dist/chunk-L6VG7IK6.js.map +0 -1
- package/dist/chunk-LN6NTH6E.js.map +0 -1
- package/dist/chunk-ML2E3P3X.js.map +0 -1
- package/dist/chunk-QBIJZZ5V.js.map +0 -1
- package/dist/chunk-RDTTK27V.js.map +0 -1
- package/dist/chunk-RJ76SKWQ.js.map +0 -1
- package/dist/chunk-RKM4GDWM.js.map +0 -1
- package/dist/chunk-STOLH4V2.js.map +0 -1
- package/dist/chunk-UMEFU7Y3.js.map +0 -1
- package/dist/chunk-WJJ5MBH5.js.map +0 -1
- package/dist/chunk-WRDIRDH7.js.map +0 -1
- /package/dist/{audit-43OLHR3U.js.map → audit-ZLNKBIDO.js.map} +0 -0
- /package/dist/{can-UJ2NAOIR.js.map → can-U5F4JBZ7.js.map} +0 -0
- /package/dist/{chunk-ELK6AVW5.js.map → chunk-2X3GBJOT.js.map} +0 -0
- /package/dist/{chunk-2N53KKIL.js.map → chunk-EWVXP3GP.js.map} +0 -0
- /package/dist/{chunk-B7DTNT4O.js.map → chunk-MWLSXK6Y.js.map} +0 -0
- /package/dist/{chunk-7KGF7JVJ.js.map → chunk-PPUHXOWZ.js.map} +0 -0
- /package/dist/{chunk-NFHS7CFV.js.map → chunk-Q7MK5ZKG.js.map} +0 -0
- /package/dist/{chunk-YYPSQMFY.js.map → chunk-VX3HM5TF.js.map} +0 -0
- /package/dist/{config-YFGOXHSR.js.map → config-2CV7KZ3D.js.map} +0 -0
- /package/dist/{digest-ZODDTXA2.js.map → digest-IWHMJPXI.js.map} +0 -0
- /package/dist/{host-SUX3SPOX.js.map → host-C5PGUXX7.js.map} +0 -0
- /package/dist/{job-log-N3IGI4NA.js.map → job-log-UY6ERPQZ.js.map} +0 -0
- /package/dist/{logger-2WUTTELV.js.map → logger-6ZGEKEMK.js.map} +0 -0
- /package/dist/{mentions-U4JACYI6.js.map → mentions-LQRZWAGO.js.map} +0 -0
- /package/dist/{mutes-MNQP6ACF.js.map → mutes-PQA6U5X7.js.map} +0 -0
- /package/dist/{notification-prefs-H4HFVCL7.js.map → notification-prefs-62NX2GBF.js.map} +0 -0
- /package/dist/{reputation-ICIXDGPM.js.map → reputation-5DJLDBZY.js.map} +0 -0
- /package/dist/{scheduled-T5WZ4I6O.js.map → scheduled-PF2HECSF.js.map} +0 -0
- /package/dist/{settings-OZWM6L2K.js.map → settings-NBAP7E5E.js.map} +0 -0
- /package/dist/{strings-4EWJYDOG.js.map → strings-O2M7VSKV.js.map} +0 -0
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/community/notification-prefs.ts"],"sourcesContent":["import { eq } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers } from \"../db/schema/community.js\";\nimport { NpNotFoundError, NpValidationError } from \"../errors.js\";\n\n/**\n * Phase 16.3 — per-member notification preferences.\n *\n * The persisted shape is a JSONB blob on `np_members.notification_prefs`\n * so adding fields (digest cadence in 16.4, channel toggles later)\n * stays a typescript-only change. Today we honor:\n *\n * - `disabled: string[]` — kinds the member opted out of. The\n * `createNotification` gate consults this and silently drops\n * the row. Default empty (= every kind enabled).\n *\n * The vocabulary of `kinds` is defined here so the UI has a single\n * source of truth — settings page renders a toggle for each entry,\n * and the API only accepts kinds that appear in the list (so a\n * forged client can't disable arbitrary strings to bloat the JSONB).\n */\n\nexport interface NpNotificationKindMeta {\n kind: string;\n /** Short human label. */\n label: string;\n /** Description rendered next to the toggle. */\n description: string;\n}\n\n/**\n * Closed vocabulary of toggle-able kinds. New notification kinds\n * land here when they ship; plugins that want their own\n * preferences register entries via `registerNotificationKind`.\n */\nconst builtinKinds: NpNotificationKindMeta[] = [\n {\n kind: \"comment.reply\",\n label: \"Replies\",\n description: \"Someone replied to one of your comments.\",\n },\n {\n kind: \"comment.mention\",\n label: \"Mentions in comments\",\n description: \"Someone @-mentioned you in a comment.\",\n },\n {\n kind: \"document.mention\",\n label: \"Mentions in discussions\",\n description: \"Someone @-mentioned you in a discussion / thread.\",\n },\n {\n kind: \"reaction.received\",\n label: \"Reactions\",\n description: \"Someone reacted to your comment or document.\",\n },\n {\n kind: \"follow.received\",\n label: \"New followers\",\n description: \"Someone started following you.\",\n },\n];\n\nconst dynamicKinds: NpNotificationKindMeta[] = [];\n\n/** Plugin-extensible registration. Idempotent on `kind`. */\nexport function registerNotificationKind(meta: NpNotificationKindMeta): void {\n if (builtinKinds.some((k) => k.kind === meta.kind)) return;\n const idx = dynamicKinds.findIndex((k) => k.kind === meta.kind);\n if (idx >= 0) {\n dynamicKinds[idx] = meta;\n } else {\n dynamicKinds.push(meta);\n }\n}\n\n/** Returns the union of builtin + plugin-registered kinds. */\nexport function listNotificationKinds(): NpNotificationKindMeta[] {\n return [...builtinKinds, ...dynamicKinds];\n}\n\nexport type NpDigestCadence = \"off\" | \"daily\" | \"weekly\";\n\nconst DIGEST_CADENCES: readonly NpDigestCadence[] = [\"off\", \"daily\", \"weekly\"] as const;\n\nexport interface NpNotificationPrefs {\n /** Kinds the member opted out of. Empty / missing = all kinds enabled. */\n disabled: string[];\n /**\n * Phase 16.4 — email digest cadence. `off` (default) disables\n * the digest. `daily` and `weekly` opt the member into a\n * batched email of unread notifications, scheduled by the\n * `notifications:sendDigest` recurring job.\n */\n digest: NpDigestCadence;\n /**\n * Set when the digest sweep last sent an email to this member.\n * Used to scope each digest to \"unread since the last send\" so\n * members aren't repeatedly emailed about the same row. Stored\n * as ISO-8601 string in the JSONB blob; `null` for accounts\n * that have never received a digest.\n *\n * Issue #218 — superseded by `lastDigestAtBySite` once a member\n * receives a digest under the per-site fan-out path. The legacy\n * field is preserved for forward-compat reads (single-site\n * deploys still see + write it via the fallback chain) and as\n * a \"any digest, ever?\" marker for analytics.\n */\n lastDigestAt: string | null;\n /**\n * Issue #218 — per-(site, cadence) timestamp map. Replaces the\n * single `lastDigestAt` for multi-site deployments. Empty when\n * the member has never received a digest under the site-scoped\n * sweep.\n */\n lastDigestAtBySite: Record<string, Partial<Record<NpDigestCadence, string>>>;\n}\n\nconst EMPTY_PREFS: NpNotificationPrefs = {\n disabled: [],\n digest: \"off\",\n lastDigestAt: null,\n lastDigestAtBySite: {},\n};\n\nfunction normalizeDigest(raw: unknown): NpDigestCadence {\n return DIGEST_CADENCES.includes(raw as NpDigestCadence) ? (raw as NpDigestCadence) : \"off\";\n}\n\nfunction normalizeLastDigestAt(raw: unknown): string | null {\n return typeof raw === \"string\" && raw.length > 0 ? raw : null;\n}\n\nfunction normalizeLastDigestBySite(\n raw: unknown,\n): Record<string, Partial<Record<NpDigestCadence, string>>> {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return {};\n const out: Record<string, Partial<Record<NpDigestCadence, string>>> = {};\n for (const [siteId, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) continue;\n const inner: Partial<Record<NpDigestCadence, string>> = {};\n for (const [cadence, ts] of Object.entries(value as Record<string, unknown>)) {\n if (!DIGEST_CADENCES.includes(cadence as NpDigestCadence)) continue;\n if (typeof ts === \"string\" && ts.length > 0) {\n inner[cadence as NpDigestCadence] = ts;\n }\n }\n if (Object.keys(inner).length > 0) out[siteId] = inner;\n }\n return out;\n}\n\nfunction normalizePrefs(raw: unknown): NpNotificationPrefs {\n if (!raw || typeof raw !== \"object\") return { ...EMPTY_PREFS, lastDigestAtBySite: {} };\n const obj = raw as Record<string, unknown>;\n const disabled = Array.isArray(obj.disabled)\n ? obj.disabled.filter((k): k is string => typeof k === \"string\")\n : [];\n return {\n disabled,\n digest: normalizeDigest(obj.digest),\n lastDigestAt: normalizeLastDigestAt(obj.lastDigestAt),\n lastDigestAtBySite: normalizeLastDigestBySite(obj.lastDigestAtBySite),\n };\n}\n\nexport async function getMemberNotificationPrefs(memberId: string): Promise<NpNotificationPrefs> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const [row] = (await db\n .select({ prefs: npMembers.notificationPrefs })\n .from(npMembers)\n .where(eq(npMembers.id, memberId))\n .limit(1)) as Array<{ prefs: Record<string, unknown> }>;\n if (!row) throw new NpNotFoundError(\"member\", memberId);\n return normalizePrefs(row.prefs);\n}\n\nexport interface SetMemberNotificationPrefsInput {\n memberId: string;\n /**\n * Replacement deny-list. Only kinds listed in\n * `listNotificationKinds()` are accepted; unknown strings\n * raise NpValidationError so a forged client can't bloat the\n * JSONB or hide future framework kinds via a stale list.\n * Optional — when omitted the existing list is preserved.\n */\n disabled?: string[];\n /**\n * Phase 16.4 — email digest cadence. Optional; when omitted\n * the existing setting is preserved. `off` clears the\n * member's enrollment.\n */\n digest?: NpDigestCadence;\n}\n\nexport async function setMemberNotificationPrefs(\n input: SetMemberNotificationPrefsInput,\n): Promise<NpNotificationPrefs> {\n const known = new Set(listNotificationKinds().map((k) => k.kind));\n let cleanedDisabled: string[] | undefined;\n if (input.disabled !== undefined) {\n cleanedDisabled = [];\n const seen = new Set<string>();\n for (const raw of input.disabled) {\n if (typeof raw !== \"string\") {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"disabled\", message: \"Each entry must be a string\" },\n ]);\n }\n if (!known.has(raw)) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"disabled\", message: `Unknown notification kind: ${raw}` },\n ]);\n }\n if (seen.has(raw)) continue;\n seen.add(raw);\n cleanedDisabled.push(raw);\n }\n }\n if (input.digest !== undefined && !DIGEST_CADENCES.includes(input.digest)) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"digest\",\n message: `digest must be one of: ${DIGEST_CADENCES.join(\", \")}`,\n },\n ]);\n }\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n\n // Read-then-merge so we don't clobber other JSONB keys\n // (lastDigestAt, future channel toggles, etc.).\n const [existing] = (await db\n .select({ prefs: npMembers.notificationPrefs })\n .from(npMembers)\n .where(eq(npMembers.id, input.memberId))\n .limit(1)) as Array<{ prefs: Record<string, unknown> }>;\n if (!existing) throw new NpNotFoundError(\"member\", input.memberId);\n\n const merged: Record<string, unknown> = { ...(existing.prefs ?? {}) };\n if (cleanedDisabled !== undefined) merged.disabled = cleanedDisabled;\n if (input.digest !== undefined) merged.digest = input.digest;\n\n await db\n .update(npMembers)\n .set({ notificationPrefs: merged, updatedAt: new Date() })\n .where(eq(npMembers.id, input.memberId));\n\n return normalizePrefs(merged);\n}\n\n/**\n * Phase 16.4 — bookkeeping helper called by the digest sweep\n * after a successful email send. Stamps `lastDigestAt` so the\n * next run scopes its query to the correct window. Read-merge\n * to preserve other JSONB keys.\n *\n * Issue #218 — when a `siteId` + `cadence` pair is supplied,\n * the per-site / per-cadence map is updated so the next sweep\n * for that tenant scopes to the correct \"since\" window. The\n * legacy single `lastDigestAt` field is also stamped for\n * forward-compat with single-site deploys (and as a \"received\n * any digest, ever?\" marker for analytics).\n */\nexport async function recordDigestSent(\n memberId: string,\n sentAt: Date,\n scope?: { siteId: string; cadence: NpDigestCadence },\n): Promise<void> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const [existing] = (await db\n .select({ prefs: npMembers.notificationPrefs })\n .from(npMembers)\n .where(eq(npMembers.id, memberId))\n .limit(1)) as Array<{ prefs: Record<string, unknown> }>;\n if (!existing) return;\n const prior = existing.prefs ?? {};\n const merged: Record<string, unknown> = {\n ...prior,\n lastDigestAt: sentAt.toISOString(),\n };\n if (scope) {\n const priorBySite = normalizeLastDigestBySite(\n (prior as { lastDigestAtBySite?: unknown }).lastDigestAtBySite,\n );\n const siteSlot = { ...(priorBySite[scope.siteId] ?? {}) };\n siteSlot[scope.cadence] = sentAt.toISOString();\n merged.lastDigestAtBySite = { ...priorBySite, [scope.siteId]: siteSlot };\n }\n await db\n .update(npMembers)\n .set({ notificationPrefs: merged, updatedAt: new Date() })\n .where(eq(npMembers.id, memberId));\n}\n\n/**\n * Inbox-side gate consulted by `createNotification`. Returns\n * `false` when the recipient explicitly opted out of `kind`.\n * Errors fail-open (return `true`) so a transient DB blip\n * doesn't silently swallow notifications.\n */\nexport async function isNotificationKindEnabled(memberId: string, kind: string): Promise<boolean> {\n try {\n const prefs = await getMemberNotificationPrefs(memberId);\n return !prefs.disabled.includes(kind);\n } catch {\n return true;\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/community/reputation.ts","../src/community/reputation-adapter.ts"],"sourcesContent":["import { eq, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers } from \"../db/schema/community.js\";\nimport { getLogger } from \"../observability/logger.js\";\n\nimport {\n getReputationAdapter,\n type NpReputationEvent,\n} from \"./reputation-adapter.js\";\n\n/**\n * Calls the registered reputation adapter for `event`, then applies\n * the returned delta to the affected member's reputation atomically:\n *\n * UPDATE np_members SET reputation = reputation + $delta\n * WHERE id = $memberId\n *\n * Failure modes are intentionally fail-soft — a buggy adapter that\n * throws, returns a non-finite value, or hits a transient DB error\n * MUST NOT block the underlying community write (comment insert,\n * reaction toggle, etc.). The caller's transactional state is not\n * touched; we just log + skip.\n */\nexport async function applyReputation(\n memberId: string,\n event: NpReputationEvent,\n): Promise<void> {\n let delta: number;\n try {\n delta = await getReputationAdapter().apply(event);\n } catch (err) {\n getLogger().warn(\"reputation adapter threw — skipping update\", {\n error: err instanceof Error ? err.message : String(err),\n kind: event.kind,\n memberId,\n });\n return;\n }\n\n if (!Number.isFinite(delta)) {\n getLogger().warn(\"reputation adapter returned non-finite delta\", {\n kind: event.kind,\n memberId,\n delta,\n });\n return;\n }\n const truncated = Math.trunc(delta);\n if (truncated === 0) return;\n\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n try {\n await db\n .update(npMembers)\n .set({\n reputation: sql`${npMembers.reputation} + ${truncated}`,\n updatedAt: new Date(),\n })\n .where(eq(npMembers.id, memberId));\n } catch (err) {\n getLogger().warn(\"reputation update failed — skipping\", {\n error: err instanceof Error ? err.message : String(err),\n kind: event.kind,\n memberId,\n delta: truncated,\n });\n }\n}\n","/**\n * Pluggable reputation-rules hook. Sites install an adapter via\n * `setReputationAdapter()` to compute reputation deltas in response\n * to community events; the framework then atomically applies the\n * delta to `np_members.reputation`.\n *\n * Default adapter is \"no-op\" (every event returns 0) — existing\n * sites' reputation values stay at zero until they opt in.\n *\n * Adapter is single-method by design: a tagged-union `event` is the\n * only argument, the return value is a signed integer delta. This\n * keeps the API surface small while letting sites encode arbitrary\n * weighting (e.g. \"+5 for a like on a comment, −10 for a moderator\n * hide, −0 if the reactor is a brand-new account, etc.\").\n *\n * Adapters can be sync or async — the framework awaits the result.\n * Throwing aborts only the reputation update, not the underlying\n * community write (fail-soft via observability hook, same pattern\n * as the spam adapter).\n */\nexport type NpReputationEvent =\n /** A new visible comment was inserted. Flagged / hidden / deleted\n * comments do NOT emit this event. */\n | {\n kind: \"comment.created\";\n commentId: string;\n memberId: string;\n targetType: string;\n targetId: string;\n }\n /** Mod (or member with the right grant) hid a comment. Adapters\n * typically penalize the author. */\n | {\n kind: \"comment.hidden\";\n commentId: string;\n memberId: string;\n byStaff: boolean;\n reason?: string | null;\n }\n /** Mod-side hard delete (`staffDeleteComment`). The body is wiped;\n * this is harsher than `hidden` and adapters usually penalize\n * more. */\n | {\n kind: \"comment.deleted\";\n commentId: string;\n memberId: string;\n byStaff: boolean;\n }\n /** Someone reacted to the recipient's content (comment / thread /\n * reply). `recipientId` is the content author; `reactorId` is the\n * member who clicked the reaction. Self-reactions are filtered\n * before the event fires. */\n | {\n kind: \"reaction.received\";\n reactionKind: string;\n recipientId: string;\n reactorId: string;\n targetType: string;\n targetId: string;\n }\n /** Reactor undid their reaction. Symmetric to `reaction.received`;\n * adapters typically return the negative of the corresponding\n * positive delta. */\n | {\n kind: \"reaction.removed\";\n reactionKind: string;\n recipientId: string;\n reactorId: string;\n targetType: string;\n targetId: string;\n }\n /** A member created a top-level document in a collection that\n * opted into `community.memberWrite.create` (Phase 9.7a). Fires\n * after the row + revision are persisted; adapters can credit\n * reputation for thread / post creation just like comments. */\n | {\n kind: \"document.created\";\n collectionSlug: string;\n documentId: string;\n memberId: string;\n }\n /** Author deleted their own document (`memberWrite.delete`,\n * Phase 9.7b). Symmetric to `document.created`; adapters\n * typically debit the original credit so a member can't farm\n * reputation by churn-creating and deleting threads. Mod-side\n * deletes are NOT covered here — those go through the staff\n * path which doesn't emit this event. */\n | {\n kind: \"document.deleted\";\n collectionSlug: string;\n documentId: string;\n memberId: string;\n };\n\nexport interface NpReputationAdapter {\n /** Returns the integer delta to apply to the affected member's\n * reputation. Sign matters: positive credits, negative debits.\n * Non-integer values are truncated; non-finite (NaN/Infinity)\n * values are skipped. Returning 0 is the no-op path. */\n apply(event: NpReputationEvent): number | Promise<number>;\n}\n\nconst NOOP_ADAPTER: NpReputationAdapter = { apply: () => 0 };\nlet currentAdapter: NpReputationAdapter = NOOP_ADAPTER;\n\nexport function setReputationAdapter(adapter: NpReputationAdapter): void {\n if (typeof adapter?.apply !== \"function\") {\n throw new Error(\"setReputationAdapter: adapter must implement apply()\");\n }\n currentAdapter = adapter;\n}\n\nexport function getReputationAdapter(): NpReputationAdapter {\n return currentAdapter;\n}\n\n/** Reset to the no-op adapter. Tests use this between cases. */\nexport function resetReputationAdapter(): void {\n currentAdapter = NOOP_ADAPTER;\n}\n"],"mappings":";;;;;;;;;;;AAAA,SAAS,IAAI,WAAW;;;ACsGxB,IAAM,eAAoC,EAAE,OAAO,MAAM,EAAE;AAC3D,IAAI,iBAAsC;AAEnC,SAAS,qBAAqB,SAAoC;AACvE,MAAI,OAAO,SAAS,UAAU,YAAY;AACxC,UAAM,IAAI,MAAM,sDAAsD;AAAA,EACxE;AACA,mBAAiB;AACnB;AAEO,SAAS,uBAA4C;AAC1D,SAAO;AACT;AAGO,SAAS,yBAA+B;AAC7C,mBAAiB;AACnB;;;AD9FA,eAAsB,gBACpB,UACA,OACe;AACf,MAAI;AACJ,MAAI;AACF,YAAQ,MAAM,qBAAqB,EAAE,MAAM,KAAK;AAAA,EAClD,SAAS,KAAK;AACZ,cAAU,EAAE,KAAK,mDAA8C;AAAA,MAC7D,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,MACtD,MAAM,MAAM;AAAA,MACZ;AAAA,IACF,CAAC;AACD;AAAA,EACF;AAEA,MAAI,CAAC,OAAO,SAAS,KAAK,GAAG;AAC3B,cAAU,EAAE,KAAK,gDAAgD;AAAA,MAC/D,MAAM,MAAM;AAAA,MACZ;AAAA,MACA;AAAA,IACF,CAAC;AACD;AAAA,EACF;AACA,QAAM,YAAY,KAAK,MAAM,KAAK;AAClC,MAAI,cAAc,EAAG;AAErB,QAAM,KAAK,MAAM;AACjB,MAAI;AACF,UAAM,GACH,OAAO,SAAS,EAChB,IAAI;AAAA,MACH,YAAY,MAAM,UAAU,UAAU,MAAM,SAAS;AAAA,MACrD,WAAW,oBAAI,KAAK;AAAA,IACtB,CAAC,EACA,MAAM,GAAG,UAAU,IAAI,QAAQ,CAAC;AAAA,EACrC,SAAS,KAAK;AACZ,cAAU,EAAE,KAAK,4CAAuC;AAAA,MACtD,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,MACtD,MAAM,MAAM;AAAA,MACZ;AAAA,MACA,OAAO;AAAA,IACT,CAAC;AAAA,EACH;AACF;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/community/digest.ts"],"sourcesContent":["import { and, desc, eq, gt, isNull, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers, npNotifications } from \"../db/schema/community.js\";\nimport { getEmailAdapter } from \"../email/service.js\";\nimport { getLogger } from \"../observability/logger.js\";\nimport { listSites, NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\nimport { type NpDigestCadence, recordDigestSent } from \"./notification-prefs.js\";\n\n/**\n * Phase 16.4 — email digest fan-out. The `notifications:sendDigest`\n * recurring job calls `runDigestSweep(cadence)` on a daily and a\n * weekly schedule; the function fetches every active member who\n * opted into that cadence, builds an inbox summary scoped to \"since\n * last digest\" (falling back to the cadence window when the member\n * has never received one), renders an email through the configured\n * `NpEmailAdapter`, and stamps `lastDigestAt` on success.\n *\n * The job is idempotent enough for production use: a sweep that\n * runs twice for the same window won't re-email members because\n * `lastDigestAt` advances on the first send. Failures inside the\n * loop are logged-and-continued — one stuck member doesn't block\n * the rest of the sweep.\n */\n\nexport interface NpDigestNotificationSummary {\n id: string;\n kind: string;\n payload: Record<string, unknown>;\n createdAt: Date;\n}\n\nexport interface NpDigestEmailContent {\n subject: string;\n text: string;\n html: string;\n}\n\nexport interface BuildDigestEmailInput {\n member: { displayName: string; handle: string };\n notifications: NpDigestNotificationSummary[];\n cadence: NpDigestCadence;\n /** Site display name; defaults to \"your site\" so the noop adapter is still readable. */\n siteName?: string;\n}\n\nconst LABELS: Record<string, string> = {\n \"comment.reply\": \"New reply on your comment\",\n \"comment.mention\": \"You were mentioned in a comment\",\n \"document.mention\": \"You were mentioned in a discussion\",\n \"reaction.received\": \"Someone reacted to your content\",\n \"follow.received\": \"Someone followed you\",\n};\n\nfunction labelFor(kind: string): string {\n return LABELS[kind] ?? `Notification (${kind})`;\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\");\n}\n\n/**\n * Pure renderer; exposed so plugins / tests can call it without\n * the DB read path.\n */\nexport function buildDigestEmail(input: BuildDigestEmailInput): NpDigestEmailContent {\n const site = input.siteName ?? \"your site\";\n const cadenceWord = input.cadence === \"weekly\" ? \"weekly\" : \"daily\";\n const total = input.notifications.length;\n const subject =\n total === 1\n ? `Your ${cadenceWord} digest from ${site}: 1 notification`\n : `Your ${cadenceWord} digest from ${site}: ${total} notifications`;\n\n const lines = input.notifications.map((n) => {\n const label = labelFor(n.kind);\n const when = n.createdAt.toISOString();\n return `- ${label} (${when})`;\n });\n const text = [\n `Hi @${input.member.handle},`,\n \"\",\n `You have ${total} unread notification${total === 1 ? \"\" : \"s\"} from the last ${cadenceWord} window:`,\n \"\",\n ...lines,\n \"\",\n `Manage your digest settings: /members/me/notifications`,\n ].join(\"\\n\");\n\n const items = input.notifications\n .map((n) => {\n const label = escapeHtml(labelFor(n.kind));\n const when = escapeHtml(n.createdAt.toISOString());\n return `<li><strong>${label}</strong> <span style=\"color:#64748b\">— ${when}</span></li>`;\n })\n .join(\"\");\n const html = [\n `<p>Hi @${escapeHtml(input.member.handle)},</p>`,\n `<p>You have ${total} unread notification${total === 1 ? \"\" : \"s\"} from the last ${cadenceWord} window:</p>`,\n `<ul>${items}</ul>`,\n `<p style=\"color:#64748b;font-size:0.9rem\">`,\n `Manage your digest settings at `,\n `<a href=\"/members/me/notifications\">/members/me/notifications</a>.`,\n `</p>`,\n ].join(\"\");\n\n return { subject, text, html };\n}\n\ninterface MemberDigestRow {\n id: string;\n email: string;\n handle: string;\n displayName: string;\n prefs: Record<string, unknown>;\n}\n\nfunction fallbackWindow(cadence: NpDigestCadence, now: Date): Date {\n const ms = cadence === \"weekly\" ? 7 * 24 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000;\n return new Date(now.getTime() - ms);\n}\n\n/**\n * Pulls every active member whose `notification_prefs.digest`\n * matches `cadence`. The JSONB filter uses Postgres `->>`\n * extraction; the `digest` field is a small string, indexes are\n * unnecessary at v1 scale.\n */\nasync function listMembersForCadence(\n db: NodePgDatabase<Record<string, unknown>>,\n cadence: Exclude<NpDigestCadence, \"off\">,\n): Promise<MemberDigestRow[]> {\n const rows = (await db\n .select({\n id: npMembers.id,\n email: npMembers.email,\n handle: npMembers.handle,\n displayName: npMembers.displayName,\n prefs: npMembers.notificationPrefs,\n status: npMembers.status,\n })\n .from(npMembers)\n .where(\n and(\n eq(npMembers.status, \"active\"),\n sql`${npMembers.notificationPrefs} ->> 'digest' = ${cadence}`,\n ),\n )) as Array<MemberDigestRow & { status: string }>;\n return rows.map((r) => ({\n id: r.id,\n email: r.email,\n handle: r.handle,\n displayName: r.displayName,\n prefs: r.prefs,\n }));\n}\n\nasync function fetchUnreadSince(\n db: NodePgDatabase<Record<string, unknown>>,\n memberId: string,\n siteId: string,\n since: Date,\n): Promise<NpDigestNotificationSummary[]> {\n const rows = (await db\n .select({\n id: npNotifications.id,\n kind: npNotifications.kind,\n payload: npNotifications.payload,\n createdAt: npNotifications.createdAt,\n })\n .from(npNotifications)\n .where(\n and(\n eq(npNotifications.memberId, memberId),\n // Issue #218 — scope to the site we're sweeping. Without\n // this the digest mixed inboxes across tenants and the\n // recipient saw notifications from sites they don't even\n // know exist.\n eq(npNotifications.siteId, siteId),\n // Unread + within the window. If the member already read\n // everything in the inbox the digest would be noise, so we\n // skip silently (caller increments `skipped` when the list\n // comes back empty).\n gt(npNotifications.createdAt, since),\n isNull(npNotifications.readAt),\n ),\n )\n .orderBy(desc(npNotifications.createdAt))\n .limit(50)) as NpDigestNotificationSummary[];\n return rows;\n}\n\nexport interface RunDigestSweepInput {\n cadence: \"daily\" | \"weekly\";\n /** Defaults to `new Date()`. Tests override for determinism. */\n now?: Date;\n /** Site name woven into subject + body. Defaults to `\"your site\"`. */\n siteName?: string;\n}\n\nexport interface RunDigestSweepResult {\n considered: number;\n sent: number;\n skipped: number;\n failed: number;\n}\n\nexport async function runDigestSweep(input: RunDigestSweepInput): Promise<RunDigestSweepResult> {\n const now = input.now ?? new Date();\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const adapter = getEmailAdapter();\n const log = getLogger();\n\n // Issue #218 — fan-out per site. The previous implementation\n // ran a single sweep that mixed every tenant's inbox into one\n // digest and stamped one global `lastDigestAt`; advancing it\n // for tenant A would suppress tenant B's next digest entirely.\n // We now iterate the site registry and run an independent\n // sweep per (site, member) — same email cadence, but each\n // recipient gets one email per site they have unread\n // notifications on.\n const sites = await listSites();\n const candidateSites = sites.length > 0 ? sites : [{ id: NP_DEFAULT_SITE_ID, name: \"\" }];\n const members = await listMembersForCadence(db, input.cadence);\n\n let considered = 0;\n let sent = 0;\n let skipped = 0;\n let failed = 0;\n\n for (const site of candidateSites) {\n for (const member of members) {\n considered += 1;\n const since = lastDigestSinceFor(member, site.id, input.cadence, now);\n\n const notifications = await fetchUnreadSince(db, member.id, site.id, since);\n if (notifications.length === 0) {\n skipped += 1;\n continue;\n }\n\n const email = buildDigestEmail({\n member: { displayName: member.displayName, handle: member.handle },\n notifications,\n cadence: input.cadence,\n // Caller-supplied `siteName` is an explicit override\n // (single-tenant deploys, tests pinning a friendly\n // brand name); the per-site `name` is the natural\n // multi-tenant default.\n siteName:\n input.siteName && input.siteName.length > 0\n ? input.siteName\n : typeof site.name === \"string\" && site.name.length > 0\n ? site.name\n : undefined,\n });\n\n try {\n await adapter.send({\n to: member.email,\n subject: email.subject,\n text: email.text,\n html: email.html,\n });\n await recordDigestSent(member.id, now, { siteId: site.id, cadence: input.cadence });\n sent += 1;\n } catch (err) {\n failed += 1;\n log.warn(\"digest send failed\", {\n memberId: member.id,\n siteId: site.id,\n cadence: input.cadence,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n }\n\n return { considered, sent, skipped, failed };\n}\n\n/**\n * Issue #218 — pick the right \"since\" cutoff for one (site,\n * member, cadence) sweep. Reads precedence:\n * 1. `lastDigestAtBySite[siteId][cadence]` — the per-site\n * timestamp the new sweep writes after each successful send.\n * 2. legacy `lastDigestAt` — single-tenant deploys without\n * site-scoped writes still keep their existing window.\n * 3. fallback window (24h / 7d) — a member who has never\n * received any digest.\n */\nfunction lastDigestSinceFor(\n member: MemberDigestRow,\n siteId: string,\n cadence: NpDigestCadence,\n now: Date,\n): Date {\n const prefs = (member.prefs ?? {});\n const bySite = prefs.lastDigestAtBySite as\n | Record<string, Partial<Record<string, string>>>\n | undefined;\n const perSite = bySite?.[siteId]?.[cadence];\n if (typeof perSite === \"string\") {\n const parsed = new Date(perSite);\n if (Number.isFinite(parsed.getTime())) return parsed;\n }\n if (typeof prefs.lastDigestAt === \"string\") {\n const parsed = new Date(prefs.lastDigestAt);\n if (Number.isFinite(parsed.getTime())) return parsed;\n }\n return fallbackWindow(cadence, now);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/community/audit.ts"],"sourcesContent":["import { and, count, desc, eq, gte, lt } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\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() as unknown as NodePgDatabase<Record<string, unknown>>;\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() as unknown as NodePgDatabase<Record<string, unknown>>;\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;AAyD9C,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":[]}
|
|
@@ -1 +0,0 @@
|
|
|
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\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\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() as unknown as NodePgDatabase<Record<string, unknown>>;\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() as unknown as NodePgDatabase<Record<string, unknown>>;\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() as unknown as NodePgDatabase<Record<string, unknown>>;\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;AA4BtC,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":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/community/mentions.ts","../src/community/notifications.ts"],"sourcesContent":["import { inArray } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers } from \"../db/schema/community.js\";\n\nimport { createNotification } from \"./notifications.js\";\n\n/**\n * Phase 16.2 — @mention extraction + notification fan-out.\n *\n * The mention vocabulary mirrors the handle constraint enforced\n * during registration (`/^[a-z0-9][a-z0-9_-]{2,29}$/`). The matcher\n * uses a negative lookbehind so `email@host.com` doesn't trigger a\n * mention, plus a negative lookahead so `@alice-` (handle followed\n * by a hyphen that's not part of the handle) is rejected — handles\n * end at non-handle characters, never mid-symbol.\n *\n * Fan-out semantics:\n * - Self-mentions are skipped (the author already knows).\n * - Caller-supplied `exclude` set lets the comment write path\n * skip the parent author so they don't get both `comment.reply`\n * AND `comment.mention`.\n * - Caller-supplied `previousHandles` lets the edit path only\n * notify newly-added mentions (otherwise toggling a single\n * other word in a comment would re-notify everyone).\n * - Inactive / banned / deleted members are filtered out at\n * resolve time.\n * - Mute is enforced inside `createNotification` (the\n * recipient's mute list drops actor-keyed notifications).\n */\n\n/** Source-of-truth handle pattern, kept in sync with `apps/web` register routes. */\nexport const MENTION_HANDLE_RE = /^[a-z0-9][a-z0-9_-]{2,29}$/;\n\nconst MENTION_PATTERN = /(?<![A-Za-z0-9_])@([a-z0-9][a-z0-9_-]{2,29})(?![A-Za-z0-9_-])/g;\n\nexport interface NpMentionTarget {\n id: string;\n handle: string;\n}\n\n/**\n * Extract unique mention handles from plain text or markdown source.\n * Order is preserved (first appearance wins) so a UI that wants to\n * display \"you mentioned @alice and @bob\" gets the same order as\n * the body text.\n */\nexport function extractMentionHandles(source: string): string[] {\n if (!source) return [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const match of source.matchAll(MENTION_PATTERN)) {\n const handle = match[1]?.toLowerCase();\n if (!handle || seen.has(handle)) continue;\n seen.add(handle);\n out.push(handle);\n }\n return out;\n}\n\n/**\n * Walk a Lexical-shaped rich-text payload, concatenate its text\n * nodes, and run the mention extractor over the joined result.\n * Mirrors the search-index walker (`collections/search.ts`) so a\n * mention split across two adjacent text spans (e.g. `@` and\n * `alice` in different runs because of formatting toggles) still\n * resolves correctly — text nodes are joined without separators.\n */\nexport function extractMentionHandlesFromRichText(content: unknown): string[] {\n if (!content || typeof content !== \"object\") return [];\n const root = (content as { root?: { children?: unknown } }).root;\n if (!root || !Array.isArray(root.children)) return [];\n const parts: string[] = [];\n walkRichTextNodes(root.children, parts);\n return extractMentionHandles(parts.join(\"\"));\n}\n\nfunction walkRichTextNodes(nodes: unknown[], parts: string[]): void {\n for (const node of nodes) {\n if (!node || typeof node !== \"object\") continue;\n const n = node as Record<string, unknown>;\n if (typeof n.text === \"string\") parts.push(n.text);\n if (Array.isArray(n.children)) walkRichTextNodes(n.children, parts);\n }\n}\n\n/**\n * Scan a collection-document data payload (the same shape passed\n * to `createMemberDocument` / `updateMemberDocument`) and pull\n * out every mention handle it contains. String values are scanned\n * with the markdown extractor; object values shaped like Lexical\n * rich text (`{ root: { children: [...] } }`) are walked. Other\n * values are ignored.\n *\n * Field names are not assumed: any string or rich-text field\n * contributes. The mention pattern is anchored to `@<handle>`\n * with handle-shape constraints, so unrelated string fields\n * (`category: \"news\"`) won't trigger false positives.\n */\nexport function extractMentionHandlesFromDocData(data: Record<string, unknown>): string[] {\n if (!data || typeof data !== \"object\") return [];\n const seen = new Set<string>();\n for (const value of Object.values(data)) {\n if (typeof value === \"string\") {\n for (const h of extractMentionHandles(value)) seen.add(h);\n continue;\n }\n if (value && typeof value === \"object\") {\n const root = (value as { root?: { children?: unknown } }).root;\n if (root && Array.isArray(root.children)) {\n for (const h of extractMentionHandlesFromRichText(value)) seen.add(h);\n }\n }\n }\n return Array.from(seen);\n}\n\n/**\n * Resolve handles to active member ids. Inactive / banned /\n * deleted members are filtered out so a mention of an account\n * the site no longer wants to notify is silently dropped (rather\n * than raising an error to the writer — the writer can't tell the\n * difference between \"typo\" and \"account closed\", and either way\n * the right behaviour is \"no notification\").\n *\n * Lookups are case-insensitive on the handle (the storage column\n * stores the canonical lowercased form).\n */\nexport async function resolveMentionedMembers(handles: string[]): Promise<NpMentionTarget[]> {\n if (handles.length === 0) return [];\n const lower = Array.from(new Set(handles.map((h) => h.toLowerCase())));\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const rows = (await db\n .select({ id: npMembers.id, handle: npMembers.handle, status: npMembers.status })\n .from(npMembers)\n .where(inArray(npMembers.handle, lower))) as Array<{\n id: string;\n handle: string;\n status: string;\n }>;\n return rows.filter((r) => r.status === \"active\").map((r) => ({ id: r.id, handle: r.handle }));\n}\n\nexport interface FanOutMentionsInput {\n /** The author whose write triggered the fan-out. Self-mentions are skipped. */\n actorMemberId: string;\n /** Notification `kind` (e.g. `\"comment.mention\"`, `\"discussion.mention\"`). */\n kind: string;\n /**\n * Plain text or markdown to scan. Either `source` or `content`\n * (or both) must be provided; if both are set the handles are\n * unioned.\n */\n source?: string;\n /** Lexical-shaped rich-text JSON to scan. */\n content?: unknown;\n /**\n * Collection-document data payload to scan. All string +\n * rich-text fields contribute. Useful for the\n * `createMemberDocument` / `updateMemberDocument` paths.\n */\n data?: Record<string, unknown>;\n /**\n * Recipients that already received a notification for this same\n * event (e.g. the parent author got `comment.reply`). They are\n * skipped to avoid the \"two pings for one comment\" pattern.\n */\n exclude?: ReadonlySet<string>;\n /** Merged into the notification payload. `mentionedMemberId` is added automatically. */\n payload?: Record<string, unknown>;\n /**\n * Edit path: handles that were present in the prior revision\n * are skipped so toggling unrelated words doesn't re-notify\n * everyone already mentioned.\n */\n previousHandles?: ReadonlySet<string>;\n}\n\n/**\n * Fan-out mention notifications. Returns the number of\n * notifications actually inserted (mute / inactive / self / dedup\n * exclusions all reduce the count).\n */\nexport async function fanOutMentionNotifications(input: FanOutMentionsInput): Promise<number> {\n const handles = new Set<string>();\n if (input.source) {\n for (const h of extractMentionHandles(input.source)) handles.add(h);\n }\n if (input.content !== undefined) {\n for (const h of extractMentionHandlesFromRichText(input.content)) handles.add(h);\n }\n if (input.data) {\n for (const h of extractMentionHandlesFromDocData(input.data)) handles.add(h);\n }\n if (input.previousHandles) {\n for (const prev of input.previousHandles) handles.delete(prev);\n }\n if (handles.size === 0) return 0;\n\n const targets = await resolveMentionedMembers(Array.from(handles));\n let fired = 0;\n for (const t of targets) {\n if (t.id === input.actorMemberId) continue;\n if (input.exclude?.has(t.id)) continue;\n const row = await createNotification({\n memberId: t.id,\n kind: input.kind,\n actorMemberId: input.actorMemberId,\n payload: {\n ...(input.payload ?? {}),\n mentionedMemberId: t.id,\n mentionedHandle: t.handle,\n },\n });\n if (row) fired += 1;\n }\n return fired;\n}\n","import { and, count, desc, eq, isNull, inArray } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npNotifications } from \"../db/schema/community.js\";\nimport { NpForbiddenError, NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId, requireSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\n/**\n * Per-member notification inbox. v1 is synchronous: every event that\n * generates a notification writes a row immediately. The inbox is\n * in-app only — email fan-out and per-member frequency preferences\n * are out of scope for the shipped roadmap.\n *\n * `kind` is a free-form string. The current vocabulary:\n * - `comment.reply` — your comment got a reply\n * - `reaction.received` — someone reacted to your content\n * - `follow.received` — someone followed you\n * Plugins can write their own kinds; the recipient UI fans them out\n * to whichever rendering it knows.\n */\n\nexport interface NpNotificationRow {\n id: string;\n memberId: string;\n kind: string;\n payload: Record<string, unknown>;\n readAt: Date | null;\n createdAt: Date;\n}\n\nexport interface CreateNotificationInput {\n /** The recipient — whose inbox this lands in. */\n memberId: string;\n kind: string;\n payload?: Record<string, unknown>;\n /**\n * Phase 16.1 — the member whose action triggered the\n * notification (e.g. the comment author, the reactor, the\n * follower). When set, the recipient's mute list is\n * consulted: if the recipient has muted the actor, the\n * notification is silently dropped. Returns `null` from\n * the call site.\n *\n * Optional because some kinds are actor-less (system\n * notices, scheduled reminders).\n */\n actorMemberId?: string | null;\n}\n\nexport async function createNotification(\n input: CreateNotificationInput,\n): Promise<NpNotificationRow | null> {\n // Mute check — defer the import to avoid a notifications →\n // mutes circular at module load. Mutes module imports\n // nothing back from here, but TypeScript sometimes flags\n // the cycle anyway depending on resolver order.\n if (input.actorMemberId && input.actorMemberId !== input.memberId) {\n const { isMuted } = await import(\"./mutes.js\");\n const muted = await isMuted({\n memberId: input.memberId,\n targetId: input.actorMemberId,\n });\n if (muted) return null;\n }\n\n // Phase 16.3 — recipient-controlled kind toggle. Fails open\n // on read error (transient DB blip shouldn't silently swallow\n // notifications). Deferred import for the same reason as\n // mutes.\n {\n const { isNotificationKindEnabled } = await import(\"./notification-prefs.js\");\n const enabled = await isNotificationKindEnabled(input.memberId, input.kind);\n if (!enabled) return null;\n }\n\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Phase 18 — site comes from the request resolver. The\n // notification belongs to the tenant where the actor's\n // action happened (a reaction on tenant A → notification\n // shows up in the recipient's tenant-A inbox).\n // #272 — write: must NOT silently fall through; an actor on\n // tenant A would otherwise create a notification on the\n // default tenant.\n const siteId = await requireSiteId();\n const [row] = (await db\n .insert(npNotifications)\n .values({\n memberId: input.memberId,\n kind: input.kind,\n payload: input.payload ?? {},\n siteId,\n })\n .returning()) as NpNotificationRow[];\n if (!row) throw new Error(\"Notification insert returned no row\");\n return row;\n}\n\nexport interface ListNotificationsOptions {\n /** Default 50, max 200. */\n limit?: number;\n /** Default 0. */\n offset?: number;\n /** When true, returns only unread. */\n unreadOnly?: boolean;\n}\n\nexport interface NpNotificationListResult {\n notifications: NpNotificationRow[];\n totalDocs: number;\n unread: number;\n}\n\nexport async function listNotifications(\n memberId: string,\n options: ListNotificationsOptions = {},\n): Promise<NpNotificationListResult> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);\n const offset = Math.max(options.offset ?? 0, 0);\n\n // Phase 18 — inbox is per-site. A member who's active on\n // multiple tenants sees a separate notification list on each.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const baseWhere = and(eq(npNotifications.memberId, memberId), eq(npNotifications.siteId, siteId));\n const where = options.unreadOnly ? and(baseWhere, isNull(npNotifications.readAt)) : baseWhere;\n\n const rows = (await db\n .select()\n .from(npNotifications)\n .where(where)\n .orderBy(desc(npNotifications.createdAt))\n .limit(limit)\n .offset(offset)) as NpNotificationRow[];\n\n const [totalRow] = (await db\n .select({ total: count() })\n .from(npNotifications)\n .where(where)) as Array<{ total: number | string }>;\n\n const [unreadRow] = (await db\n .select({ total: count() })\n .from(npNotifications)\n .where(and(baseWhere, isNull(npNotifications.readAt)))) as Array<{\n total: number | string;\n }>;\n\n return {\n notifications: rows,\n totalDocs: Number(totalRow?.total ?? 0),\n unread: Number(unreadRow?.total ?? 0),\n };\n}\n\nexport async function unreadNotificationCount(memberId: string): Promise<number> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Phase 18 — count only notifications on the current site.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const [row] = (await db\n .select({ total: count() })\n .from(npNotifications)\n .where(\n and(\n eq(npNotifications.memberId, memberId),\n eq(npNotifications.siteId, siteId),\n isNull(npNotifications.readAt),\n ),\n )) as Array<{ total: number | string }>;\n return Number(row?.total ?? 0);\n}\n\nexport interface MarkReadInput {\n memberId: string;\n notificationIds: string[];\n}\n\nexport async function markNotificationsRead(input: MarkReadInput): Promise<number> {\n if (input.notificationIds.length === 0) return 0;\n if (input.notificationIds.length > 200) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"notificationIds\", message: \"Up to 200 ids per request\" },\n ]);\n }\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Issue #219 — scope the update to the current site so a member\n // active on multiple tenants can't mark IDs read across tenants\n // by passing a site-A request that names site-B notification ids.\n // The caller's existing `memberId` predicate covered ownership\n // but not tenant; without this, unread counts on the other site\n // would silently drop. Using `returning({ id })` also gives us\n // an exact count instead of a follow-up SELECT — replaces the\n // pre-existing best-effort COUNT round trip.\n // #272 — write: must NOT silently fall through.\n const siteId = await requireSiteId();\n const updated = (await db\n .update(npNotifications)\n .set({ readAt: new Date() })\n .where(\n and(\n eq(npNotifications.memberId, input.memberId),\n eq(npNotifications.siteId, siteId),\n inArray(npNotifications.id, input.notificationIds),\n isNull(npNotifications.readAt),\n ),\n )\n .returning({ id: npNotifications.id })) as Array<{ id: string }>;\n return updated.length;\n}\n\nexport async function markAllNotificationsRead(memberId: string): Promise<number> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Phase 18 — \"mark all read\" only marks the current site's\n // inbox so a member doesn't accidentally clear another\n // tenant's unread count when toggling on this one.\n // #272 — write: must NOT silently fall through.\n const siteId = await requireSiteId();\n const before = await unreadNotificationCount(memberId);\n await db\n .update(npNotifications)\n .set({ readAt: new Date() })\n .where(\n and(\n eq(npNotifications.memberId, memberId),\n eq(npNotifications.siteId, siteId),\n isNull(npNotifications.readAt),\n ),\n );\n return before;\n}\n\n/**\n * Internal sanity check used by the API: throws when one principal\n * tries to read another member's notification. Centralised here\n * because every per-id route gets the same rule.\n */\nexport async function assertOwnsNotification(\n memberId: string,\n notificationId: string,\n): Promise<void> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const [row] = (await db\n .select({ memberId: npNotifications.memberId })\n .from(npNotifications)\n .where(eq(npNotifications.id, notificationId))\n .limit(1)) as Array<{ memberId: string }>;\n if (!row || row.memberId !== memberId) {\n throw new NpForbiddenError(\"notification\", \"read\");\n }\n}\n"],"mappings":"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","names":["inArray","inArray"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/community/can.ts","../src/community/roles.ts"],"sourcesContent":["import { and, eq, gt, isNull, or } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { NpForbiddenError } from \"../errors.js\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npBans, npMemberRoles } from \"../db/schema/community.js\";\nimport { getCurrentSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\nimport { type CommunityCapability, type CommunityScope, getCommunityRole } from \"./roles.js\";\n\n/**\n * Active-ban probe shared by `memberCan` and direct write-path\n * callers. The community write services (`createComment`,\n * `addReaction`, `fileReport`, `follow`) call `assertNotBanned`\n * straight away — they never went through `memberCan`, so without\n * this gate banned members could still write community content\n * even though their bans were recorded. (#53)\n *\n * Ban-match rules:\n * - `site` ban → blocks every write.\n * - `category` / `collection` ban → blocks when the action's scope\n * chain contains the matching scope.\n *\n * The `or()` helper is required for the `expires_at IS NULL OR\n * expires_at > now` clause; the previous raw `sql` template let\n * Postgres' AND-binds-tighter-than-OR rule re-associate and leak\n * other members' bans (same precedence trap as #006 in 9.5\n * postmortem).\n */\nexport async function isMemberBanned(\n memberId: string,\n scopes: ReadonlyArray<{ type: CommunityScope; id: string }> = [],\n db?: NodePgDatabase<Record<string, unknown>>,\n now: Date = new Date(),\n): Promise<boolean> {\n const handle = db ?? (getDb() as unknown as NodePgDatabase<Record<string, unknown>>);\n // Phase 18 — bans are tenant-scoped. A site-wide ban on\n // tenant A doesn't block writes on tenant B; the ban row\n // includes `site_id` and we filter by the resolver's\n // current value.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const bans = (await handle\n .select({\n scopeType: npBans.scopeType,\n scopeId: npBans.scopeId,\n })\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 )) as Array<{\n scopeType: \"site\" | \"category\" | \"collection\";\n scopeId: string | null;\n }>;\n\n return bans.some((ban) => {\n if (ban.scopeType === \"site\") return true;\n return scopes.some((s) => s.type === ban.scopeType && s.id === ban.scopeId);\n });\n}\n\n/**\n * Throws `NpForbiddenError` if the member is currently banned for any\n * scope in the chain. Used at the top of community write services\n * before any DB mutation. Pre-existing `memberCan` enforces the same\n * rule for permission-based actions; this helper is the catch-all\n * for write paths that don't go through capability checks.\n */\nexport async function assertNotBanned(\n memberId: string,\n scopes: ReadonlyArray<{ type: CommunityScope; id: string }> = [],\n): Promise<void> {\n if (await isMemberBanned(memberId, scopes)) {\n throw new NpForbiddenError(\"community\", \"banned\");\n }\n}\n\n/**\n * Structural enforcement of the ban-check gate (#311). Every\n * community write service should run inside this wrapper — the ban\n * check fires before `fn` and a service author can't accidentally\n * ship a new write path that skips it.\n *\n * Pre-validation that doesn't write (input shape, target lookup\n * existence) can run *before* this call; the gate is specifically\n * for the moment between \"we know enough to attempt the write\" and\n * the first DB mutation.\n *\n * `scopes` is the same chain `assertNotBanned` accepts — pass\n * `[{ type: \"collection\", id: targetType }]` for collection-scoped\n * actions, leave empty for site-wide-only enforcement (e.g. follows,\n * polymorphic-target reactions where no obvious scope chain exists).\n */\nexport async function withMemberWrite<T>(\n memberId: string,\n scopes: ReadonlyArray<{ type: CommunityScope; id: string }>,\n fn: () => Promise<T>,\n): Promise<T> {\n await assertNotBanned(memberId, scopes);\n return fn();\n}\n\n/**\n * Action a member is attempting. Most actions are real\n * `CommunityCapability` literals — those map 1:1 to a role's\n * capability list. The two exceptions are `\"edit-own\"` and\n * `\"delete-own\"`, which short-circuit on ownership without consulting\n * grants at all.\n */\nexport type MemberAction = CommunityCapability | \"edit-own\" | \"delete-own\";\n\n/**\n * Caller-provided context for a permission check. The caller — the\n * comment service, a future thread service, etc. — provides the\n * target's ownership + scope chain rather than `memberCan` looking\n * it up via a polymorphic join. This keeps the resolver decoupled\n * from the per-target table layout, and lets the surface evolve\n * without touching this resolver.\n */\nexport interface MemberCanTarget {\n /** Free-form target type — `\"comment\" | \"thread\" | \"reply\" | \"category\" | \"report\" | \"member\"`. */\n type: string;\n /** Stable id for logs / future denial reasons. */\n id: string;\n /** Member id of the target's author. Required for own-action checks. */\n ownerId?: string;\n /**\n * Scope chain from most specific to least specific. A reply might\n * provide `[{ type: \"thread\", id: \"<threadId>\" }, { type: \"category\",\n * id: \"<categoryId>\" }]`; the resolver also checks site-wide grants\n * regardless of what's in the chain.\n */\n scopes?: ReadonlyArray<{ type: CommunityScope; id: string }>;\n}\n\ninterface MemberCanOptions {\n /** Override the DB handle (tests). Defaults to `getDb()`. */\n db?: NodePgDatabase<Record<string, unknown>>;\n /** Reference time for ban/grant expiry checks. Defaults to `new Date()`. */\n now?: Date;\n}\n\n/**\n * Returns true when `memberId` is allowed to perform `action` on\n * `target`. Walk order:\n *\n * 1. Active scoped ban → deny everything.\n * 2. `edit-own` / `delete-own` → allow only when `target.ownerId === memberId`.\n * 3. Site-wide grants whose role's capability list includes `action`.\n * 4. Scoped grants matching any element of `target.scopes`, whose role\n * includes `action`.\n * 5. Otherwise deny.\n *\n * The resolver ignores staff (`np_users`) entirely. Staff bypass is the\n * caller's responsibility — typically `principalCan(principal, …)` at\n * the API layer, which routes to `memberCan` only when the principal\n * is a member.\n */\nexport async function memberCan(\n memberId: string,\n action: MemberAction,\n target: MemberCanTarget,\n options: MemberCanOptions = {},\n): Promise<boolean> {\n const db = options.db ?? (getDb() as unknown as NodePgDatabase<Record<string, unknown>>);\n const now = options.now ?? new Date();\n const scopes = target.scopes ?? [];\n\n // Step 1: ban check. Site-wide bans always apply; scoped bans match\n // when the target's scope chain contains the ban's scope.\n const isBanned = await isMemberBanned(memberId, scopes, db, now);\n if (isBanned) return false;\n\n // Step 2: ownership shortcut for own-content actions.\n if (action === \"edit-own\" || action === \"delete-own\") {\n return Boolean(target.ownerId) && target.ownerId === memberId;\n }\n\n // Step 3+4: walk grants. Pull the member's unexpired grants\n // on the current tenant only — a community-mod on tenant A\n // shouldn't authorize actions on tenant B. Site-wide grants\n // (scope_type='site') still match every action on the\n // resolved tenant.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const grants = (await db\n .select({\n role: npMemberRoles.role,\n scopeType: npMemberRoles.scopeType,\n scopeId: npMemberRoles.scopeId,\n })\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 )) as Array<{\n role: string;\n scopeType: CommunityScope;\n scopeId: string | null;\n }>;\n\n for (const grant of grants) {\n const def = getCommunityRole(grant.role, grant.scopeType);\n if (!def) continue;\n if (!def.capabilities.includes(action)) continue;\n\n if (grant.scopeType === \"site\") {\n return true;\n }\n const matchesTargetScope = scopes.some(\n (s) => s.type === grant.scopeType && s.id === grant.scopeId,\n );\n if (matchesTargetScope) return true;\n }\n\n return false;\n}\n","/**\n * Community role registry. Maps a role name + scope type to the\n * capabilities a grant of that role unlocks. Plugins extend the registry\n * via `registerCommunityRole(...)` (gated by the `members:write` or\n * `community:moderate` capability — enforced at registration time, not\n * here).\n *\n * The capability vocabulary is the single source of truth for \"what\n * actions exist in the community.\" `memberCan()` (../community/can.ts)\n * looks up grants and matches their roles' capability lists against the\n * requested action.\n */\n\nexport type CommunityScope = \"site\" | \"category\" | \"collection\" | \"thread\";\n\n/**\n * Action vocabulary. Adding new actions later is fine, but rename with\n * care — built-in role definitions reference these literals and a\n * silent typo widens permissions instead of narrowing them.\n */\nexport type CommunityCapability =\n | \"hide-comment\"\n | \"restore-comment\"\n | \"edit-any-comment\"\n | \"delete-any-comment\"\n | \"hide-thread\"\n | \"restore-thread\"\n | \"lock-thread\"\n | \"unlock-thread\"\n | \"pin-thread\"\n | \"unpin-thread\"\n | \"edit-any-thread\"\n | \"delete-any-thread\"\n | \"edit-own-thread\"\n | \"lock-own-thread\"\n | \"ban-member\"\n | \"unban-member\"\n | \"resolve-report\"\n | \"manage-category\"\n | \"view-staff-tools\";\n\nexport interface CommunityRoleDefinition {\n /** e.g. `\"category-mod\"`. Plugins can ship custom roles like `\"tag-mod\"`. */\n role: string;\n /** What kind of scope a grant of this role applies to. */\n scopeType: CommunityScope;\n /** Capabilities a grant of this role unlocks within its scope. */\n capabilities: readonly CommunityCapability[];\n /**\n * Human-readable label for admin UIs that surface a role picker. Falls\n * back to `role` when omitted.\n */\n label?: string;\n /** Optional plugin id that registered this role; null for built-ins. */\n source?: string;\n}\n\nconst ALL_MOD_CAPS: readonly CommunityCapability[] = [\n \"hide-comment\",\n \"restore-comment\",\n \"edit-any-comment\",\n \"delete-any-comment\",\n \"hide-thread\",\n \"restore-thread\",\n \"lock-thread\",\n \"unlock-thread\",\n \"pin-thread\",\n \"unpin-thread\",\n \"edit-any-thread\",\n \"delete-any-thread\",\n \"ban-member\",\n \"unban-member\",\n \"resolve-report\",\n \"view-staff-tools\",\n];\n\nconst builtInRoles: CommunityRoleDefinition[] = [\n {\n role: \"community-mod\",\n scopeType: \"site\",\n label: \"Community moderator\",\n capabilities: [...ALL_MOD_CAPS, \"manage-category\"],\n },\n {\n role: \"category-mod\",\n scopeType: \"category\",\n label: \"Category moderator\",\n capabilities: ALL_MOD_CAPS,\n },\n {\n role: \"collection-mod\",\n scopeType: \"collection\",\n label: \"Collection moderator\",\n // Collection-mods only have authority over the comments under a\n // collection's documents. Thread-only capabilities don't apply, so\n // they're omitted on purpose.\n capabilities: [\n \"hide-comment\",\n \"restore-comment\",\n \"edit-any-comment\",\n \"delete-any-comment\",\n \"ban-member\",\n \"unban-member\",\n \"resolve-report\",\n \"view-staff-tools\",\n ],\n },\n {\n role: \"thread-author\",\n scopeType: \"thread\",\n label: \"Thread author\",\n // Auto-granted on thread create. Lets the OP edit / lock their own\n // thread without giving them broader powers.\n capabilities: [\"edit-own-thread\", \"lock-own-thread\"],\n },\n];\n\nconst customRoles: CommunityRoleDefinition[] = [];\n\nfunction key(role: string, scopeType: CommunityScope): string {\n return `${scopeType}:${role}`;\n}\n\n/**\n * Plugins call this from setup() to add their own role kinds. Throws\n * when the (role, scopeType) pair is already registered to keep the\n * registry deterministic — a plugin overriding a built-in role would\n * silently widen permissions and is almost always a mistake.\n */\nexport function registerCommunityRole(definition: CommunityRoleDefinition): void {\n const composite = key(definition.role, definition.scopeType);\n if (\n builtInRoles.some((b) => key(b.role, b.scopeType) === composite) ||\n customRoles.some((c) => key(c.role, c.scopeType) === composite)\n ) {\n throw new Error(\n `[community] role \"${definition.role}\" already registered for scope \"${definition.scopeType}\".`,\n );\n }\n customRoles.push({ ...definition });\n}\n\n/** Look up a role by `(role, scopeType)`. Returns undefined when unknown. */\nexport function getCommunityRole(\n role: string,\n scopeType: CommunityScope,\n): CommunityRoleDefinition | undefined {\n const composite = key(role, scopeType);\n return (\n builtInRoles.find((b) => key(b.role, b.scopeType) === composite) ??\n customRoles.find((c) => key(c.role, c.scopeType) === composite)\n );\n}\n\n/**\n * Returns every role currently registered, built-ins first then\n * plugin-defined. Used by the admin role picker to render selectable\n * options for a given scope.\n */\nexport function listCommunityRoles(scopeType?: CommunityScope): CommunityRoleDefinition[] {\n const all = [...builtInRoles, ...customRoles];\n return scopeType ? all.filter((r) => r.scopeType === scopeType) : all;\n}\n\n/** Tests reset state between cases; production callers should never need this. */\nexport function resetCommunityRoles(): void {\n customRoles.length = 0;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/community/settings.ts"],"sourcesContent":["import { and, eq } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\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() as unknown as NodePgDatabase<Record<string, unknown>>;\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() as unknown as NodePgDatabase<Record<string, unknown>>;\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":[]}
|