@nexpress/core 0.1.0

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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/dist/audit-54XLVCWD.js +14 -0
  4. package/dist/audit-54XLVCWD.js.map +1 -0
  5. package/dist/auth.d.ts +640 -0
  6. package/dist/auth.js +94 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/can-YLUHRJAB.js +19 -0
  9. package/dist/can-YLUHRJAB.js.map +1 -0
  10. package/dist/chunk-2G264RCD.js +68 -0
  11. package/dist/chunk-2G264RCD.js.map +1 -0
  12. package/dist/chunk-2YDGE7YX.js +92 -0
  13. package/dist/chunk-2YDGE7YX.js.map +1 -0
  14. package/dist/chunk-473S4TER.js +538 -0
  15. package/dist/chunk-473S4TER.js.map +1 -0
  16. package/dist/chunk-4ZLMEKFX.js +18 -0
  17. package/dist/chunk-4ZLMEKFX.js.map +1 -0
  18. package/dist/chunk-55FU6WED.js +179 -0
  19. package/dist/chunk-55FU6WED.js.map +1 -0
  20. package/dist/chunk-6YI5K2TI.js +1959 -0
  21. package/dist/chunk-6YI5K2TI.js.map +1 -0
  22. package/dist/chunk-BHK3AD3Q.js +41 -0
  23. package/dist/chunk-BHK3AD3Q.js.map +1 -0
  24. package/dist/chunk-CRUQBZUF.js +39 -0
  25. package/dist/chunk-CRUQBZUF.js.map +1 -0
  26. package/dist/chunk-CTSQ7BRI.js +175 -0
  27. package/dist/chunk-CTSQ7BRI.js.map +1 -0
  28. package/dist/chunk-DK2JBJH7.js +81 -0
  29. package/dist/chunk-DK2JBJH7.js.map +1 -0
  30. package/dist/chunk-DP2PREDU.js +597 -0
  31. package/dist/chunk-DP2PREDU.js.map +1 -0
  32. package/dist/chunk-EQ2Z3KMD.js +24 -0
  33. package/dist/chunk-EQ2Z3KMD.js.map +1 -0
  34. package/dist/chunk-FZ7O6DWI.js +305 -0
  35. package/dist/chunk-FZ7O6DWI.js.map +1 -0
  36. package/dist/chunk-ISLYFQWL.js +1270 -0
  37. package/dist/chunk-ISLYFQWL.js.map +1 -0
  38. package/dist/chunk-JJL74ZPK.js +68 -0
  39. package/dist/chunk-JJL74ZPK.js.map +1 -0
  40. package/dist/chunk-JKXAPSU4.js +24 -0
  41. package/dist/chunk-JKXAPSU4.js.map +1 -0
  42. package/dist/chunk-KU5M27ZC.js +24 -0
  43. package/dist/chunk-KU5M27ZC.js.map +1 -0
  44. package/dist/chunk-LSHHRDVR.js +34 -0
  45. package/dist/chunk-LSHHRDVR.js.map +1 -0
  46. package/dist/chunk-M43PGOQY.js +715 -0
  47. package/dist/chunk-M43PGOQY.js.map +1 -0
  48. package/dist/chunk-MEJAHXIO.js +150 -0
  49. package/dist/chunk-MEJAHXIO.js.map +1 -0
  50. package/dist/chunk-NUCGHWCF.js +101 -0
  51. package/dist/chunk-NUCGHWCF.js.map +1 -0
  52. package/dist/chunk-OK5HOCQI.js +845 -0
  53. package/dist/chunk-OK5HOCQI.js.map +1 -0
  54. package/dist/chunk-OROPGO65.js +13 -0
  55. package/dist/chunk-OROPGO65.js.map +1 -0
  56. package/dist/chunk-PPAS4SZR.js +176 -0
  57. package/dist/chunk-PPAS4SZR.js.map +1 -0
  58. package/dist/chunk-PPBWRKO2.js +171 -0
  59. package/dist/chunk-PPBWRKO2.js.map +1 -0
  60. package/dist/chunk-PZ5AY32C.js +10 -0
  61. package/dist/chunk-PZ5AY32C.js.map +1 -0
  62. package/dist/chunk-QO7LAQZH.js +321 -0
  63. package/dist/chunk-QO7LAQZH.js.map +1 -0
  64. package/dist/chunk-QVJ2HCAX.js +225 -0
  65. package/dist/chunk-QVJ2HCAX.js.map +1 -0
  66. package/dist/chunk-RIPHIRPP.js +68 -0
  67. package/dist/chunk-RIPHIRPP.js.map +1 -0
  68. package/dist/chunk-S27S42QY.js +134 -0
  69. package/dist/chunk-S27S42QY.js.map +1 -0
  70. package/dist/chunk-SBCVAC2Z.js +40 -0
  71. package/dist/chunk-SBCVAC2Z.js.map +1 -0
  72. package/dist/chunk-TFJ4MKPH.js +694 -0
  73. package/dist/chunk-TFJ4MKPH.js.map +1 -0
  74. package/dist/chunk-THX3SHYA.js +75 -0
  75. package/dist/chunk-THX3SHYA.js.map +1 -0
  76. package/dist/chunk-UGQSQO5B.js +222 -0
  77. package/dist/chunk-UGQSQO5B.js.map +1 -0
  78. package/dist/chunk-V2UNHGAP.js +26 -0
  79. package/dist/chunk-V2UNHGAP.js.map +1 -0
  80. package/dist/chunk-VGTPQXNQ.js +2790 -0
  81. package/dist/chunk-VGTPQXNQ.js.map +1 -0
  82. package/dist/chunk-VNIHXQ7W.js +194 -0
  83. package/dist/chunk-VNIHXQ7W.js.map +1 -0
  84. package/dist/chunk-WV272MPW.js +31 -0
  85. package/dist/chunk-WV272MPW.js.map +1 -0
  86. package/dist/chunk-X5KKBOUS.js +26 -0
  87. package/dist/chunk-X5KKBOUS.js.map +1 -0
  88. package/dist/chunk-XANPEOJC.js +17 -0
  89. package/dist/chunk-XANPEOJC.js.map +1 -0
  90. package/dist/chunk-XPVQIHAQ.js +83 -0
  91. package/dist/chunk-XPVQIHAQ.js.map +1 -0
  92. package/dist/chunk-ZCINJSS4.js +75 -0
  93. package/dist/chunk-ZCINJSS4.js.map +1 -0
  94. package/dist/community.d.ts +1425 -0
  95. package/dist/community.js +206 -0
  96. package/dist/community.js.map +1 -0
  97. package/dist/config-2GDU7PCK.js +32 -0
  98. package/dist/config-2GDU7PCK.js.map +1 -0
  99. package/dist/context-MNZ4QXPC.js +16 -0
  100. package/dist/context-MNZ4QXPC.js.map +1 -0
  101. package/dist/db-schema.d.ts +4 -0
  102. package/dist/db-schema.js +102 -0
  103. package/dist/db-schema.js.map +1 -0
  104. package/dist/db.d.ts +7 -0
  105. package/dist/db.js +117 -0
  106. package/dist/db.js.map +1 -0
  107. package/dist/digest-SY42GQSU.js +17 -0
  108. package/dist/digest-SY42GQSU.js.map +1 -0
  109. package/dist/errors-5OS3S2J3.js +22 -0
  110. package/dist/errors-5OS3S2J3.js.map +1 -0
  111. package/dist/host-OBOI4MJK.js +51 -0
  112. package/dist/host-OBOI4MJK.js.map +1 -0
  113. package/dist/i18n.d.ts +301 -0
  114. package/dist/i18n.js +68 -0
  115. package/dist/i18n.js.map +1 -0
  116. package/dist/index-B6-_vr_m.d.ts +590 -0
  117. package/dist/index-CY55LC0u.d.ts +4722 -0
  118. package/dist/index-CeiTvwbp.d.ts +168 -0
  119. package/dist/index-XwP1ET8b.d.ts +61 -0
  120. package/dist/index.d.ts +2037 -0
  121. package/dist/index.js +2205 -0
  122. package/dist/index.js.map +1 -0
  123. package/dist/job-log-VZXWQUDK.js +24 -0
  124. package/dist/job-log-VZXWQUDK.js.map +1 -0
  125. package/dist/jobs.d.ts +4 -0
  126. package/dist/jobs.js +76 -0
  127. package/dist/jobs.js.map +1 -0
  128. package/dist/logger-DqGaOU_j.d.ts +29 -0
  129. package/dist/logger-S7REWDNE.js +16 -0
  130. package/dist/logger-S7REWDNE.js.map +1 -0
  131. package/dist/media.d.ts +5 -0
  132. package/dist/media.js +41 -0
  133. package/dist/media.js.map +1 -0
  134. package/dist/mentions-2IHFVSHW.js +23 -0
  135. package/dist/mentions-2IHFVSHW.js.map +1 -0
  136. package/dist/mutes-EWAE5FZR.js +21 -0
  137. package/dist/mutes-EWAE5FZR.js.map +1 -0
  138. package/dist/notification-prefs-VPJDU7I6.js +21 -0
  139. package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
  140. package/dist/observability.d.ts +156 -0
  141. package/dist/observability.js +32 -0
  142. package/dist/observability.js.map +1 -0
  143. package/dist/profanity-adapter-NU2JQSLX.js +12 -0
  144. package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
  145. package/dist/queue-XE5BC75T.js +14 -0
  146. package/dist/queue-XE5BC75T.js.map +1 -0
  147. package/dist/rate-limit.d.ts +99 -0
  148. package/dist/rate-limit.js +14 -0
  149. package/dist/rate-limit.js.map +1 -0
  150. package/dist/registry-XIXDEPVI.js +31 -0
  151. package/dist/registry-XIXDEPVI.js.map +1 -0
  152. package/dist/reputation-JRL2YQHM.js +11 -0
  153. package/dist/reputation-JRL2YQHM.js.map +1 -0
  154. package/dist/routes.d.ts +43 -0
  155. package/dist/routes.js +12 -0
  156. package/dist/routes.js.map +1 -0
  157. package/dist/scheduled-CIQM57HT.js +20 -0
  158. package/dist/scheduled-CIQM57HT.js.map +1 -0
  159. package/dist/seo.d.ts +410 -0
  160. package/dist/seo.js +44 -0
  161. package/dist/seo.js.map +1 -0
  162. package/dist/settings-FOBIESPB.js +17 -0
  163. package/dist/settings-FOBIESPB.js.map +1 -0
  164. package/dist/spam-adapter-XX3G737Z.js +12 -0
  165. package/dist/spam-adapter-XX3G737Z.js.map +1 -0
  166. package/dist/strings-VAE47B2C.js +29 -0
  167. package/dist/strings-VAE47B2C.js.map +1 -0
  168. package/dist/templates-IFVJMCJ6.js +12 -0
  169. package/dist/templates-IFVJMCJ6.js.map +1 -0
  170. package/dist/types-TlsbXS0T.d.ts +871 -0
  171. package/package.json +129 -0
@@ -0,0 +1,1959 @@
1
+ import {
2
+ getMutedTargetIds
3
+ } from "./chunk-NUCGHWCF.js";
4
+ import {
5
+ createNotification,
6
+ extractMentionHandles,
7
+ fanOutMentionNotifications
8
+ } from "./chunk-UGQSQO5B.js";
9
+ import {
10
+ applyReputation
11
+ } from "./chunk-THX3SHYA.js";
12
+ import {
13
+ getSpamAdapter
14
+ } from "./chunk-JKXAPSU4.js";
15
+ import {
16
+ getProfanityAdapter
17
+ } from "./chunk-KU5M27ZC.js";
18
+ import {
19
+ getCommunityRole,
20
+ memberCan,
21
+ withMemberWrite
22
+ } from "./chunk-55FU6WED.js";
23
+ import {
24
+ buildWeightedSearchVectorSql,
25
+ deleteDocument,
26
+ findDocuments,
27
+ getDocumentById,
28
+ saveDocument
29
+ } from "./chunk-VGTPQXNQ.js";
30
+ import {
31
+ can
32
+ } from "./chunk-EQ2Z3KMD.js";
33
+ import {
34
+ recordAuditEvent
35
+ } from "./chunk-RIPHIRPP.js";
36
+ import {
37
+ getCommunitySettings
38
+ } from "./chunk-PPBWRKO2.js";
39
+ import {
40
+ getI18nConfig
41
+ } from "./chunk-4ZLMEKFX.js";
42
+ import {
43
+ NP_DEFAULT_SITE_ID,
44
+ getAllCollectionSlugs,
45
+ getCollectionConfig,
46
+ getCollectionRegistration,
47
+ getCollectionTable
48
+ } from "./chunk-FZ7O6DWI.js";
49
+ import {
50
+ getCurrentSiteId,
51
+ requireSiteId
52
+ } from "./chunk-SBCVAC2Z.js";
53
+ import {
54
+ NpConflictError,
55
+ NpForbiddenError,
56
+ NpNotFoundError,
57
+ NpValidationError
58
+ } from "./chunk-ZCINJSS4.js";
59
+ import {
60
+ getMediaUrl
61
+ } from "./chunk-BHK3AD3Q.js";
62
+ import {
63
+ deleteMedia
64
+ } from "./chunk-473S4TER.js";
65
+ import {
66
+ getLogger
67
+ } from "./chunk-JJL74ZPK.js";
68
+ import {
69
+ getDb
70
+ } from "./chunk-XANPEOJC.js";
71
+ import {
72
+ npBans,
73
+ npComments,
74
+ npFollows,
75
+ npMedia,
76
+ npMemberRoles,
77
+ npMembers,
78
+ npReactions,
79
+ npReports,
80
+ npRevisions
81
+ } from "./chunk-M43PGOQY.js";
82
+
83
+ // src/community/profiles.ts
84
+ import { and, eq, inArray, ne, or } from "drizzle-orm";
85
+ async function getMemberProfile(idOrHandle, options = {}) {
86
+ if (typeof idOrHandle !== "string" || idOrHandle.length === 0) return null;
87
+ const needle = idOrHandle.toLowerCase();
88
+ const db = getDb();
89
+ const rows = await db.select({
90
+ id: npMembers.id,
91
+ handle: npMembers.handle,
92
+ displayName: npMembers.displayName,
93
+ avatarId: npMembers.avatar,
94
+ bio: npMembers.bio,
95
+ reputation: npMembers.reputation,
96
+ status: npMembers.status,
97
+ createdAt: npMembers.createdAt
98
+ }).from(npMembers).where(
99
+ and(
100
+ or(eq(npMembers.id, needle), eq(npMembers.handle, needle)),
101
+ ne(npMembers.status, "suspended"),
102
+ ne(npMembers.status, "deleted")
103
+ )
104
+ ).limit(1);
105
+ const row = rows[0];
106
+ if (!row) return null;
107
+ const avatarUrl = row.avatarId ? await getMemberAvatarUrl(row.avatarId, options.avatarVariant ?? "thumbnail") : null;
108
+ return {
109
+ id: row.id,
110
+ handle: row.handle,
111
+ displayName: row.displayName,
112
+ avatarUrl,
113
+ bio: row.bio ?? null,
114
+ reputation: row.reputation,
115
+ joinedAt: row.createdAt
116
+ };
117
+ }
118
+ async function getMemberAvatarUrl(mediaId, variant) {
119
+ try {
120
+ return await getMediaUrl(mediaId, { variant });
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+ async function getMemberProfiles(ids, options = {}) {
126
+ const result = /* @__PURE__ */ new Map();
127
+ if (ids.length === 0) return result;
128
+ const unique = Array.from(new Set(ids.filter((id) => typeof id === "string" && id.length > 0)));
129
+ if (unique.length === 0) return result;
130
+ const db = getDb();
131
+ const rows = await db.select({
132
+ id: npMembers.id,
133
+ handle: npMembers.handle,
134
+ displayName: npMembers.displayName,
135
+ avatarId: npMembers.avatar,
136
+ bio: npMembers.bio,
137
+ reputation: npMembers.reputation,
138
+ status: npMembers.status,
139
+ createdAt: npMembers.createdAt
140
+ }).from(npMembers).where(
141
+ and(
142
+ inArray(npMembers.id, unique),
143
+ ne(npMembers.status, "suspended"),
144
+ ne(npMembers.status, "deleted")
145
+ )
146
+ );
147
+ const variant = options.avatarVariant ?? "thumbnail";
148
+ await Promise.all(
149
+ rows.map(async (row) => {
150
+ const avatarUrl = row.avatarId ? await getMemberAvatarUrl(row.avatarId, variant) : null;
151
+ result.set(row.id, {
152
+ id: row.id,
153
+ handle: row.handle,
154
+ displayName: row.displayName,
155
+ avatarUrl,
156
+ bio: row.bio ?? null,
157
+ reputation: row.reputation,
158
+ joinedAt: row.createdAt
159
+ });
160
+ })
161
+ );
162
+ return result;
163
+ }
164
+
165
+ // src/community/markdown.ts
166
+ var URL_RE = /^(?:https?:\/\/|mailto:)[^\s)]+$/;
167
+ function escapeHtml(value) {
168
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
169
+ }
170
+ function renderInline(text) {
171
+ let html = escapeHtml(text);
172
+ html = html.replace(/`([^`\n]+?)`/g, (_match, code) => `<code>${code}</code>`);
173
+ html = html.replace(/\*\*([^*\n][^*\n]*?)\*\*/g, "<strong>$1</strong>");
174
+ html = html.replace(/\*(\S(?:[^*\n]*\S)?)\*/g, "<em>$1</em>");
175
+ html = html.replace(/\[([^\]\n]+?)\]\(([^)\n]+?)\)/g, (_match, label, rawUrl) => {
176
+ if (!URL_RE.test(rawUrl)) return `[${label}](${rawUrl})`;
177
+ return `<a href="${rawUrl}" rel="nofollow ugc" target="_blank">${label}</a>`;
178
+ });
179
+ html = html.replace(/\n/g, "<br/>");
180
+ return html;
181
+ }
182
+ function renderCommentMarkdown(source) {
183
+ if (!source) return "";
184
+ const blocks = [];
185
+ let cursor = 0;
186
+ const fenceRe = /```([\s\S]*?)```/g;
187
+ let match;
188
+ while ((match = fenceRe.exec(source)) !== null) {
189
+ const before = source.slice(cursor, match.index);
190
+ if (before) blocks.push(renderTextBlocks(before));
191
+ blocks.push(`<pre><code>${escapeHtml(match[1] ?? "")}</code></pre>`);
192
+ cursor = match.index + match[0].length;
193
+ }
194
+ const tail = source.slice(cursor);
195
+ if (tail) blocks.push(renderTextBlocks(tail));
196
+ return blocks.join("\n").trim();
197
+ }
198
+ function renderTextBlocks(chunk) {
199
+ return chunk.split(/\n{2,}/).map((para) => para.trim()).filter(Boolean).map((para) => `<p>${renderInline(para)}</p>`).join("\n");
200
+ }
201
+
202
+ // src/community/comments.ts
203
+ import { and as and2, asc, count, desc, eq as eq2, notInArray, sql } from "drizzle-orm";
204
+ var MAX_BODY_LENGTH = 5e3;
205
+ function assertCollectionAcceptsComments(slug) {
206
+ const config = getCollectionConfig(slug);
207
+ if (!config.community?.comments) {
208
+ throw new NpValidationError("Comments disabled", [
209
+ {
210
+ field: "collection",
211
+ message: `Collection "${slug}" does not accept comments. Set community.comments=true on the collection config.`
212
+ }
213
+ ]);
214
+ }
215
+ }
216
+ function validateBody(bodyMd) {
217
+ const trimmed = bodyMd.trim();
218
+ if (trimmed.length === 0) {
219
+ throw new NpValidationError("Invalid input", [
220
+ { field: "bodyMd", message: "Comment body required" }
221
+ ]);
222
+ }
223
+ if (trimmed.length > MAX_BODY_LENGTH) {
224
+ throw new NpValidationError("Invalid input", [
225
+ { field: "bodyMd", message: `Comment body must be \u2264 ${MAX_BODY_LENGTH} characters` }
226
+ ]);
227
+ }
228
+ }
229
+ function commentScopes(row) {
230
+ return [{ type: "collection", id: row.targetType }];
231
+ }
232
+ async function createComment(input) {
233
+ validateBody(input.bodyMd);
234
+ assertCollectionAcceptsComments(input.targetType);
235
+ return withMemberWrite(
236
+ input.memberId,
237
+ [{ type: "collection", id: input.targetType }],
238
+ async () => doCreateComment(input)
239
+ );
240
+ }
241
+ async function doCreateComment(input) {
242
+ const targetDoc = await getDocumentById(input.targetType, input.targetId);
243
+ if (!targetDoc) {
244
+ throw new NpNotFoundError(input.targetType, input.targetId);
245
+ }
246
+ const requestSiteId = await getCurrentSiteId();
247
+ if (requestSiteId && typeof targetDoc.siteId === "string" && targetDoc.siteId !== requestSiteId) {
248
+ throw new NpForbiddenError("comment", "cross-site");
249
+ }
250
+ if (targetDoc.locked === true) {
251
+ throw new NpValidationError("Invalid input", [
252
+ { field: "targetId", message: "This thread is locked and does not accept new comments." }
253
+ ]);
254
+ }
255
+ const db = getDb();
256
+ let parentAuthorId = null;
257
+ if (input.parentId) {
258
+ const [parent] = await db.select({
259
+ id: npComments.id,
260
+ targetType: npComments.targetType,
261
+ targetId: npComments.targetId,
262
+ memberId: npComments.memberId,
263
+ status: npComments.status
264
+ }).from(npComments).where(eq2(npComments.id, input.parentId)).limit(1);
265
+ if (!parent) {
266
+ throw new NpNotFoundError("comment", input.parentId);
267
+ }
268
+ if (parent.targetType !== input.targetType || parent.targetId !== input.targetId) {
269
+ throw new NpValidationError("Invalid input", [
270
+ { field: "parentId", message: "Parent comment belongs to a different document" }
271
+ ]);
272
+ }
273
+ if (parent.status !== "visible") {
274
+ throw new NpValidationError("Invalid input", [
275
+ {
276
+ field: "parentId",
277
+ message: `Cannot reply to a comment with status '${parent.status}'`
278
+ }
279
+ ]);
280
+ }
281
+ parentAuthorId = parent.memberId;
282
+ }
283
+ const ctx = {
284
+ memberId: input.memberId,
285
+ targetType: input.targetType,
286
+ targetId: input.targetId,
287
+ parentId: input.parentId ?? null
288
+ };
289
+ let profanityVerdict;
290
+ try {
291
+ profanityVerdict = await getProfanityAdapter().check(input.bodyMd, ctx);
292
+ } catch (err) {
293
+ getLogger().warn("profanity adapter threw \u2014 treating as pass", {
294
+ error: err instanceof Error ? err.message : String(err),
295
+ targetType: input.targetType,
296
+ targetId: input.targetId
297
+ });
298
+ profanityVerdict = { kind: "pass" };
299
+ }
300
+ if (profanityVerdict.kind === "reject") {
301
+ throw new NpValidationError("Invalid input", [
302
+ {
303
+ field: "bodyMd",
304
+ message: profanityVerdict.reason ?? "Comment contains prohibited language"
305
+ }
306
+ ]);
307
+ }
308
+ let spamVerdict;
309
+ try {
310
+ spamVerdict = await getSpamAdapter().check(input.bodyMd, ctx);
311
+ } catch (err) {
312
+ getLogger().warn("spam adapter threw \u2014 treating as pass", {
313
+ error: err instanceof Error ? err.message : String(err),
314
+ targetType: input.targetType,
315
+ targetId: input.targetId
316
+ });
317
+ spamVerdict = { kind: "pass" };
318
+ }
319
+ if (spamVerdict.kind === "reject") {
320
+ throw new NpValidationError("Invalid input", [
321
+ {
322
+ field: "bodyMd",
323
+ message: spamVerdict.reason ?? "Comment was rejected by the site's spam filter"
324
+ }
325
+ ]);
326
+ }
327
+ const flaggedBy = [];
328
+ if (profanityVerdict.kind === "flag") flaggedBy.push("profanity");
329
+ if (spamVerdict.kind === "flag") flaggedBy.push("spam");
330
+ const initialStatus = flaggedBy.length > 0 ? "pending" : "visible";
331
+ const html = renderCommentMarkdown(input.bodyMd);
332
+ const targetSiteId = typeof targetDoc.siteId === "string" && targetDoc.siteId.length > 0 ? targetDoc.siteId : await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
333
+ const [row] = await db.insert(npComments).values({
334
+ targetType: input.targetType,
335
+ targetId: input.targetId,
336
+ parentId: input.parentId ?? null,
337
+ memberId: input.memberId,
338
+ bodyMd: input.bodyMd,
339
+ bodyHtml: html,
340
+ status: initialStatus,
341
+ siteId: targetSiteId
342
+ }).returning();
343
+ if (!row) throw new Error("Comment insert returned no row");
344
+ if (flaggedBy.length > 0) {
345
+ await recordAuditEvent({
346
+ actor: { kind: "member", memberId: input.memberId },
347
+ action: "comment.flag",
348
+ targetType: "comment",
349
+ targetId: row.id,
350
+ payload: {
351
+ sources: flaggedBy,
352
+ profanity: profanityVerdict.kind === "flag" ? {
353
+ reason: profanityVerdict.reason ?? null,
354
+ metadata: profanityVerdict.metadata ?? null
355
+ } : null,
356
+ spam: spamVerdict.kind === "flag" ? {
357
+ reason: spamVerdict.reason ?? null,
358
+ metadata: spamVerdict.metadata ?? null
359
+ } : null
360
+ }
361
+ });
362
+ }
363
+ if (initialStatus === "visible") {
364
+ await applyReputation(input.memberId, {
365
+ kind: "comment.created",
366
+ commentId: row.id,
367
+ memberId: input.memberId,
368
+ targetType: input.targetType,
369
+ targetId: input.targetId
370
+ });
371
+ }
372
+ if (initialStatus === "visible" && parentAuthorId && parentAuthorId !== input.memberId) {
373
+ await createNotification({
374
+ memberId: parentAuthorId,
375
+ kind: "comment.reply",
376
+ actorMemberId: input.memberId,
377
+ payload: {
378
+ commentId: row.id,
379
+ replyAuthorId: input.memberId,
380
+ targetType: input.targetType,
381
+ targetId: input.targetId
382
+ }
383
+ });
384
+ }
385
+ if (initialStatus === "visible") {
386
+ const exclude = /* @__PURE__ */ new Set();
387
+ if (parentAuthorId) exclude.add(parentAuthorId);
388
+ await fanOutMentionNotifications({
389
+ actorMemberId: input.memberId,
390
+ kind: "comment.mention",
391
+ source: input.bodyMd,
392
+ exclude,
393
+ payload: {
394
+ commentId: row.id,
395
+ targetType: input.targetType,
396
+ targetId: input.targetId
397
+ }
398
+ });
399
+ }
400
+ return row;
401
+ }
402
+ async function listComments(targetType, targetId, options = {}) {
403
+ const db = getDb();
404
+ const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
405
+ const offset = Math.max(options.offset ?? 0, 0);
406
+ const order = options.order ?? "newest";
407
+ const mutedAuthorIds = options.viewerMemberId ? Array.from(await getMutedTargetIds(options.viewerMemberId)) : [];
408
+ const muteFilter = mutedAuthorIds.length > 0 ? notInArray(npComments.memberId, mutedAuthorIds) : void 0;
409
+ const baseWhere = options.includeHidden ? and2(eq2(npComments.targetType, targetType), eq2(npComments.targetId, targetId)) : sql`${eq2(npComments.targetType, targetType)} and ${eq2(npComments.targetId, targetId)} and ${eq2(npComments.status, "visible")}`;
410
+ const where = muteFilter ? and2(baseWhere, muteFilter) : baseWhere;
411
+ const orderBy = order === "top" ? sql`(SELECT COUNT(*) FROM ${npReactions} WHERE ${npReactions.targetType} = 'comment' AND ${npReactions.targetId} = ${npComments.id}) DESC, ${npComments.createdAt} DESC` : order === "oldest" ? asc(npComments.createdAt) : desc(npComments.createdAt);
412
+ const joinedRows = await db.select({
413
+ comment: npComments,
414
+ authorStatus: npMembers.status
415
+ }).from(npComments).leftJoin(npMembers, eq2(npComments.memberId, npMembers.id)).where(where).orderBy(orderBy).limit(limit).offset(offset);
416
+ const rows = joinedRows.map(({ comment, authorStatus }) => ({
417
+ ...comment,
418
+ authorStatus
419
+ }));
420
+ const [totalRow] = await db.select({ total: count() }).from(npComments).where(where);
421
+ return { comments: rows, totalDocs: Number(totalRow?.total ?? 0) };
422
+ }
423
+ async function updateComment(input) {
424
+ validateBody(input.bodyMd);
425
+ const db = getDb();
426
+ const [existing] = await db.select().from(npComments).where(eq2(npComments.id, input.commentId)).limit(1);
427
+ if (!existing) throw new NpNotFoundError("comment", input.commentId);
428
+ if (existing.status === "deleted") {
429
+ throw new NpValidationError("Invalid state", [
430
+ { field: "comment", message: "Cannot edit a deleted comment" }
431
+ ]);
432
+ }
433
+ const ownerCan = await memberCan(input.memberId, "edit-own", {
434
+ type: "comment",
435
+ id: existing.id,
436
+ ownerId: existing.memberId,
437
+ scopes: commentScopes(existing)
438
+ });
439
+ const modCan = ownerCan ? false : await memberCan(input.memberId, "edit-any-comment", {
440
+ type: "comment",
441
+ id: existing.id,
442
+ ownerId: existing.memberId,
443
+ scopes: commentScopes(existing)
444
+ });
445
+ if (!ownerCan && !modCan) {
446
+ throw new NpForbiddenError("comment", "update");
447
+ }
448
+ const ctx = {
449
+ memberId: input.memberId,
450
+ targetType: existing.targetType,
451
+ targetId: existing.targetId,
452
+ parentId: existing.parentId
453
+ };
454
+ let profanityFlag = null;
455
+ try {
456
+ const verdict = await getProfanityAdapter().check(input.bodyMd, ctx);
457
+ if (verdict.kind === "reject") {
458
+ throw new NpValidationError("Invalid input", [
459
+ {
460
+ field: "bodyMd",
461
+ message: verdict.reason ?? "Comment contains prohibited language"
462
+ }
463
+ ]);
464
+ }
465
+ if (verdict.kind === "flag") {
466
+ profanityFlag = {
467
+ reason: verdict.reason ?? null,
468
+ metadata: verdict.metadata ?? null
469
+ };
470
+ }
471
+ } catch (err) {
472
+ if (err instanceof NpValidationError) throw err;
473
+ getLogger().warn("profanity adapter threw on comment edit \u2014 treating as pass", {
474
+ error: err instanceof Error ? err.message : String(err),
475
+ commentId: input.commentId
476
+ });
477
+ }
478
+ let spamFlag = null;
479
+ try {
480
+ const verdict = await getSpamAdapter().check(input.bodyMd, ctx);
481
+ if (verdict.kind === "reject") {
482
+ throw new NpValidationError("Invalid input", [
483
+ {
484
+ field: "bodyMd",
485
+ message: verdict.reason ?? "Comment was rejected by the site's spam filter"
486
+ }
487
+ ]);
488
+ }
489
+ if (verdict.kind === "flag") {
490
+ spamFlag = {
491
+ reason: verdict.reason ?? null,
492
+ metadata: verdict.metadata ?? null
493
+ };
494
+ }
495
+ } catch (err) {
496
+ if (err instanceof NpValidationError) throw err;
497
+ getLogger().warn("spam adapter threw on comment edit \u2014 treating as pass", {
498
+ error: err instanceof Error ? err.message : String(err),
499
+ commentId: input.commentId
500
+ });
501
+ }
502
+ const editFlaggedBy = [];
503
+ if (profanityFlag) editFlaggedBy.push("profanity");
504
+ if (spamFlag) editFlaggedBy.push("spam");
505
+ const html = renderCommentMarkdown(input.bodyMd);
506
+ const updateValues = {
507
+ bodyMd: input.bodyMd,
508
+ bodyHtml: html,
509
+ editedAt: /* @__PURE__ */ new Date()
510
+ };
511
+ if (editFlaggedBy.length > 0) {
512
+ updateValues.status = "pending";
513
+ }
514
+ const [updated] = await db.update(npComments).set(updateValues).where(eq2(npComments.id, input.commentId)).returning();
515
+ if (!updated) throw new Error("Comment update returned no row");
516
+ if (editFlaggedBy.length > 0) {
517
+ await recordAuditEvent({
518
+ actor: { kind: "member", memberId: input.memberId },
519
+ action: "comment.flag",
520
+ targetType: "comment",
521
+ targetId: updated.id,
522
+ payload: {
523
+ event: "update",
524
+ sources: editFlaggedBy,
525
+ profanity: profanityFlag,
526
+ spam: spamFlag
527
+ }
528
+ });
529
+ }
530
+ if (updated.status === "visible") {
531
+ const previousHandles = new Set(extractMentionHandles(existing.bodyMd));
532
+ await fanOutMentionNotifications({
533
+ actorMemberId: input.memberId,
534
+ kind: "comment.mention",
535
+ source: input.bodyMd,
536
+ previousHandles,
537
+ payload: {
538
+ commentId: updated.id,
539
+ targetType: existing.targetType,
540
+ targetId: existing.targetId
541
+ }
542
+ });
543
+ }
544
+ return updated;
545
+ }
546
+ async function deleteComment(input) {
547
+ const db = getDb();
548
+ const [existing] = await db.select().from(npComments).where(eq2(npComments.id, input.commentId)).limit(1);
549
+ if (!existing) throw new NpNotFoundError("comment", input.commentId);
550
+ const ownerCan = await memberCan(input.memberId, "delete-own", {
551
+ type: "comment",
552
+ id: existing.id,
553
+ ownerId: existing.memberId,
554
+ scopes: commentScopes(existing)
555
+ });
556
+ const modCan = ownerCan ? false : await memberCan(input.memberId, "delete-any-comment", {
557
+ type: "comment",
558
+ id: existing.id,
559
+ ownerId: existing.memberId,
560
+ scopes: commentScopes(existing)
561
+ });
562
+ if (!ownerCan && !modCan) {
563
+ throw new NpForbiddenError("comment", "delete");
564
+ }
565
+ await db.update(npComments).set({ status: "deleted", bodyMd: "", bodyHtml: "", editedAt: /* @__PURE__ */ new Date() }).where(eq2(npComments.id, input.commentId));
566
+ }
567
+ async function hideComment(input) {
568
+ const db = getDb();
569
+ const [existing] = await db.select().from(npComments).where(eq2(npComments.id, input.commentId)).limit(1);
570
+ if (!existing) throw new NpNotFoundError("comment", input.commentId);
571
+ const ok = await memberCan(input.memberId, "hide-comment", {
572
+ type: "comment",
573
+ id: existing.id,
574
+ ownerId: existing.memberId,
575
+ scopes: commentScopes(existing)
576
+ });
577
+ if (!ok) throw new NpForbiddenError("comment", "hide");
578
+ await db.update(npComments).set({
579
+ status: "hidden",
580
+ hiddenByMemberId: input.memberId,
581
+ hiddenReason: input.reason ?? null
582
+ }).where(eq2(npComments.id, input.commentId));
583
+ await recordAuditEvent({
584
+ actor: { kind: "member", memberId: input.memberId },
585
+ action: "comment.hide",
586
+ targetType: "comment",
587
+ targetId: existing.id,
588
+ payload: { reason: input.reason ?? null, collection: existing.targetType }
589
+ });
590
+ }
591
+ async function restoreComment(input) {
592
+ const db = getDb();
593
+ const [existing] = await db.select().from(npComments).where(eq2(npComments.id, input.commentId)).limit(1);
594
+ if (!existing) throw new NpNotFoundError("comment", input.commentId);
595
+ if (existing.status !== "hidden") {
596
+ throw new NpValidationError("Invalid state", [
597
+ { field: "status", message: `Comment is "${existing.status}", not "hidden"` }
598
+ ]);
599
+ }
600
+ const ok = await memberCan(input.memberId, "restore-comment", {
601
+ type: "comment",
602
+ id: existing.id,
603
+ ownerId: existing.memberId,
604
+ scopes: commentScopes(existing)
605
+ });
606
+ if (!ok) throw new NpForbiddenError("comment", "restore");
607
+ await db.update(npComments).set({
608
+ status: "visible",
609
+ hiddenByUserId: null,
610
+ hiddenByMemberId: null,
611
+ hiddenReason: null
612
+ }).where(eq2(npComments.id, input.commentId));
613
+ await recordAuditEvent({
614
+ actor: { kind: "member", memberId: input.memberId },
615
+ action: "comment.restore",
616
+ targetType: "comment",
617
+ targetId: existing.id,
618
+ payload: { collection: existing.targetType }
619
+ });
620
+ }
621
+ async function loadCommentForStaffOp(commentId) {
622
+ const db = getDb();
623
+ const [existing] = await db.select().from(npComments).where(eq2(npComments.id, commentId)).limit(1);
624
+ if (!existing) throw new NpNotFoundError("comment", commentId);
625
+ const requestSiteId = await requireSiteId();
626
+ if (existing.siteId !== requestSiteId) {
627
+ throw new NpForbiddenError("comment", "cross-site");
628
+ }
629
+ return { row: existing, siteId: requestSiteId };
630
+ }
631
+ async function staffHideComment(commentId, staffUserId, reason) {
632
+ const { row: existing, siteId } = await loadCommentForStaffOp(commentId);
633
+ const db = getDb();
634
+ await db.update(npComments).set({
635
+ status: "hidden",
636
+ hiddenByUserId: staffUserId,
637
+ hiddenByMemberId: null,
638
+ hiddenReason: reason ?? null
639
+ }).where(and2(eq2(npComments.id, commentId), eq2(npComments.siteId, siteId)));
640
+ await recordAuditEvent({
641
+ actor: { kind: "staff", userId: staffUserId },
642
+ action: "comment.hide",
643
+ targetType: "comment",
644
+ targetId: commentId,
645
+ payload: { reason: reason ?? null, byStaff: true }
646
+ });
647
+ await applyReputation(existing.memberId, {
648
+ kind: "comment.hidden",
649
+ commentId,
650
+ memberId: existing.memberId,
651
+ byStaff: true,
652
+ reason: reason ?? null
653
+ });
654
+ }
655
+ async function staffRestoreComment(commentId, staffUserId) {
656
+ const { row: existing, siteId } = await loadCommentForStaffOp(commentId);
657
+ if (existing.status !== "hidden") {
658
+ throw new NpValidationError("Invalid state", [
659
+ {
660
+ field: "status",
661
+ message: `Comment is "${existing.status}", not "hidden"`
662
+ }
663
+ ]);
664
+ }
665
+ const db = getDb();
666
+ await db.update(npComments).set({
667
+ status: "visible",
668
+ hiddenByUserId: null,
669
+ hiddenByMemberId: null,
670
+ hiddenReason: null
671
+ }).where(and2(eq2(npComments.id, commentId), eq2(npComments.siteId, siteId)));
672
+ await recordAuditEvent({
673
+ actor: { kind: "staff", userId: staffUserId },
674
+ action: "comment.restore",
675
+ targetType: "comment",
676
+ targetId: commentId,
677
+ payload: { byStaff: true }
678
+ });
679
+ }
680
+ async function staffDeleteComment(commentId, staffUserId) {
681
+ const { row: existing, siteId } = await loadCommentForStaffOp(commentId);
682
+ const db = getDb();
683
+ await db.update(npComments).set({ status: "deleted", bodyMd: "", bodyHtml: "" }).where(and2(eq2(npComments.id, commentId), eq2(npComments.siteId, siteId)));
684
+ await recordAuditEvent({
685
+ actor: { kind: "staff", userId: staffUserId },
686
+ action: "comment.delete",
687
+ targetType: "comment",
688
+ targetId: commentId,
689
+ payload: { byStaff: true }
690
+ });
691
+ await applyReputation(existing.memberId, {
692
+ kind: "comment.deleted",
693
+ commentId,
694
+ memberId: existing.memberId,
695
+ byStaff: true
696
+ });
697
+ }
698
+
699
+ // src/community/reactions.ts
700
+ import { and as and3, count as count2, eq as eq3 } from "drizzle-orm";
701
+ var DEFAULT_REACTION_KINDS = ["like"];
702
+ var KIND_RE = /^[a-z][a-z0-9_-]{0,29}$/;
703
+ function validateKind(kind) {
704
+ if (!KIND_RE.test(kind)) {
705
+ throw new NpValidationError("Invalid input", [
706
+ {
707
+ field: "kind",
708
+ message: "kind must match [a-z][a-z0-9_-]{0,29}"
709
+ }
710
+ ]);
711
+ }
712
+ }
713
+ async function addReaction(input) {
714
+ validateKind(input.kind);
715
+ const settings = await getCommunitySettings();
716
+ if (!settings.reactionKinds.includes(input.kind)) {
717
+ throw new NpValidationError("Invalid input", [
718
+ {
719
+ field: "kind",
720
+ message: `Reaction kind '${input.kind}' is not allowed on this site`
721
+ }
722
+ ]);
723
+ }
724
+ const scopes = await deriveScopesFor(input);
725
+ return withMemberWrite(input.memberId, scopes, async () => {
726
+ return doAddReaction(input);
727
+ });
728
+ }
729
+ async function deriveScopesFor(input) {
730
+ if (input.targetType !== "comment") return [];
731
+ const db = getDb();
732
+ const [comment] = await db.select({ targetType: npComments.targetType }).from(npComments).where(eq3(npComments.id, input.targetId)).limit(1);
733
+ if (!comment) return [];
734
+ return [{ type: "collection", id: comment.targetType }];
735
+ }
736
+ async function doAddReaction(input) {
737
+ const db = getDb();
738
+ const requestSiteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
739
+ let targetSiteId;
740
+ if (input.targetType === "comment") {
741
+ const [t] = await db.select({ siteId: npComments.siteId }).from(npComments).where(eq3(npComments.id, input.targetId)).limit(1);
742
+ targetSiteId = t?.siteId ?? requestSiteId;
743
+ } else {
744
+ targetSiteId = requestSiteId;
745
+ }
746
+ if (targetSiteId !== requestSiteId) {
747
+ throw new NpForbiddenError("reaction", "cross-site");
748
+ }
749
+ const inserted = await db.insert(npReactions).values({
750
+ targetType: input.targetType,
751
+ targetId: input.targetId,
752
+ memberId: input.memberId,
753
+ kind: input.kind,
754
+ siteId: targetSiteId
755
+ }).onConflictDoNothing().returning();
756
+ let row;
757
+ if (inserted.length > 0) {
758
+ row = inserted[0];
759
+ } else {
760
+ const [existing] = await db.select().from(npReactions).where(
761
+ and3(
762
+ eq3(npReactions.targetType, input.targetType),
763
+ eq3(npReactions.targetId, input.targetId),
764
+ eq3(npReactions.memberId, input.memberId),
765
+ eq3(npReactions.kind, input.kind)
766
+ )
767
+ ).limit(1);
768
+ if (!existing) throw new Error("Reaction conflict but row not found");
769
+ return existing;
770
+ }
771
+ if (input.targetType === "comment") {
772
+ const [comment] = await db.select({ memberId: npComments.memberId }).from(npComments).where(eq3(npComments.id, input.targetId)).limit(1);
773
+ if (comment && comment.memberId !== input.memberId) {
774
+ await createNotification({
775
+ memberId: comment.memberId,
776
+ kind: "reaction.received",
777
+ actorMemberId: input.memberId,
778
+ payload: {
779
+ reactorId: input.memberId,
780
+ targetType: input.targetType,
781
+ targetId: input.targetId,
782
+ reactionKind: input.kind
783
+ }
784
+ });
785
+ await applyReputation(comment.memberId, {
786
+ kind: "reaction.received",
787
+ reactionKind: input.kind,
788
+ recipientId: comment.memberId,
789
+ reactorId: input.memberId,
790
+ targetType: input.targetType,
791
+ targetId: input.targetId
792
+ });
793
+ }
794
+ }
795
+ return row;
796
+ }
797
+ async function removeReaction(input) {
798
+ validateKind(input.kind);
799
+ const db = getDb();
800
+ const requestSiteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
801
+ let recipientId = null;
802
+ if (input.targetType === "comment") {
803
+ const [comment] = await db.select({ memberId: npComments.memberId, siteId: npComments.siteId }).from(npComments).where(eq3(npComments.id, input.targetId)).limit(1);
804
+ if (comment && comment.siteId !== requestSiteId) {
805
+ throw new NpForbiddenError("reaction", "cross-site");
806
+ }
807
+ if (comment && comment.memberId !== input.memberId) {
808
+ recipientId = comment.memberId;
809
+ }
810
+ }
811
+ const deleted = await db.delete(npReactions).where(
812
+ and3(
813
+ eq3(npReactions.targetType, input.targetType),
814
+ eq3(npReactions.targetId, input.targetId),
815
+ eq3(npReactions.memberId, input.memberId),
816
+ eq3(npReactions.kind, input.kind),
817
+ eq3(npReactions.siteId, requestSiteId)
818
+ )
819
+ ).returning({ id: npReactions.id });
820
+ if (recipientId && deleted.length > 0) {
821
+ await applyReputation(recipientId, {
822
+ kind: "reaction.removed",
823
+ reactionKind: input.kind,
824
+ recipientId,
825
+ reactorId: input.memberId,
826
+ targetType: input.targetType,
827
+ targetId: input.targetId
828
+ });
829
+ }
830
+ }
831
+ async function countReactions(targetType, targetId) {
832
+ const db = getDb();
833
+ const rows = await db.select({ kind: npReactions.kind, total: count2() }).from(npReactions).where(and3(eq3(npReactions.targetType, targetType), eq3(npReactions.targetId, targetId))).groupBy(npReactions.kind);
834
+ const out = {};
835
+ for (const row of rows) out[row.kind] = Number(row.total);
836
+ return out;
837
+ }
838
+ async function listMemberReactions(targetType, targetId, memberId) {
839
+ const db = getDb();
840
+ const rows = await db.select({ kind: npReactions.kind }).from(npReactions).where(
841
+ and3(
842
+ eq3(npReactions.targetType, targetType),
843
+ eq3(npReactions.targetId, targetId),
844
+ eq3(npReactions.memberId, memberId)
845
+ )
846
+ );
847
+ return rows.map((r) => r.kind);
848
+ }
849
+ async function assertReactableExists(targetType, targetId) {
850
+ if (targetType !== "comment") {
851
+ throw new NpValidationError("Invalid input", [
852
+ {
853
+ field: "targetType",
854
+ message: `Reactions on '${targetType}' aren't supported yet \u2014 only 'comment' is wired today.`
855
+ }
856
+ ]);
857
+ }
858
+ const db = getDb();
859
+ const [comment] = await db.select({ id: npComments.id, status: npComments.status }).from(npComments).where(eq3(npComments.id, targetId)).limit(1);
860
+ if (!comment) throw new NpNotFoundError("comment", targetId);
861
+ if (comment.status === "deleted") {
862
+ throw new NpValidationError("Invalid input", [
863
+ { field: "targetId", message: "Cannot react to a deleted comment" }
864
+ ]);
865
+ }
866
+ }
867
+
868
+ // src/community/follows.ts
869
+ import { and as and4, eq as eq4 } from "drizzle-orm";
870
+ var SUPPORTED_TARGETS = ["member", "thread", "tag"];
871
+ function assertSupportedTarget(targetType) {
872
+ if (!SUPPORTED_TARGETS.includes(targetType)) {
873
+ throw new NpValidationError("Invalid input", [
874
+ {
875
+ field: "targetType",
876
+ message: `targetType must be one of: ${SUPPORTED_TARGETS.join(", ")}`
877
+ }
878
+ ]);
879
+ }
880
+ }
881
+ async function follow(input) {
882
+ assertSupportedTarget(input.targetType);
883
+ if (input.targetType === "member" && input.targetId === input.followerId) {
884
+ throw new NpValidationError("Invalid input", [
885
+ { field: "targetId", message: "Members can't follow themselves." }
886
+ ]);
887
+ }
888
+ return withMemberWrite(input.followerId, [], async () => {
889
+ return doFollow(input);
890
+ });
891
+ }
892
+ async function doFollow(input) {
893
+ const db = getDb();
894
+ if (input.targetType === "member") {
895
+ const [target] = await db.select({ id: npMembers.id, status: npMembers.status }).from(npMembers).where(eq4(npMembers.id, input.targetId)).limit(1);
896
+ if (!target) throw new NpNotFoundError("member", input.targetId);
897
+ if (target.status !== "active") {
898
+ throw new NpValidationError("Invalid input", [
899
+ { field: "targetId", message: "Cannot follow a non-active member." }
900
+ ]);
901
+ }
902
+ } else {
903
+ throw new NpValidationError("Invalid input", [
904
+ {
905
+ field: "targetType",
906
+ message: `Following ${input.targetType} targets is not supported yet`
907
+ }
908
+ ]);
909
+ }
910
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
911
+ const [inserted] = await db.insert(npFollows).values({
912
+ followerId: input.followerId,
913
+ targetType: input.targetType,
914
+ targetId: input.targetId,
915
+ siteId
916
+ }).onConflictDoNothing().returning();
917
+ if (inserted) {
918
+ if (input.targetType === "member") {
919
+ await createNotification({
920
+ memberId: input.targetId,
921
+ kind: "follow.received",
922
+ actorMemberId: input.followerId,
923
+ payload: { followerId: input.followerId }
924
+ });
925
+ }
926
+ return inserted;
927
+ }
928
+ const [existing] = await db.select().from(npFollows).where(
929
+ and4(
930
+ eq4(npFollows.followerId, input.followerId),
931
+ eq4(npFollows.targetType, input.targetType),
932
+ eq4(npFollows.targetId, input.targetId),
933
+ eq4(npFollows.siteId, siteId)
934
+ )
935
+ ).limit(1);
936
+ if (!existing) {
937
+ throw new Error("Follow insert hit conflict but re-select returned no row");
938
+ }
939
+ return existing;
940
+ }
941
+ async function unfollow(input) {
942
+ assertSupportedTarget(input.targetType);
943
+ const db = getDb();
944
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
945
+ await db.delete(npFollows).where(
946
+ and4(
947
+ eq4(npFollows.followerId, input.followerId),
948
+ eq4(npFollows.targetType, input.targetType),
949
+ eq4(npFollows.targetId, input.targetId),
950
+ eq4(npFollows.siteId, siteId)
951
+ )
952
+ );
953
+ }
954
+ async function isFollowing(input) {
955
+ assertSupportedTarget(input.targetType);
956
+ const db = getDb();
957
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
958
+ const [row] = await db.select({ id: npFollows.id }).from(npFollows).where(
959
+ and4(
960
+ eq4(npFollows.followerId, input.followerId),
961
+ eq4(npFollows.targetType, input.targetType),
962
+ eq4(npFollows.targetId, input.targetId),
963
+ eq4(npFollows.siteId, siteId)
964
+ )
965
+ ).limit(1);
966
+ return Boolean(row);
967
+ }
968
+ async function listFollowing(followerId, options = {}) {
969
+ const db = getDb();
970
+ const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
971
+ const offset = Math.max(options.offset ?? 0, 0);
972
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
973
+ const where = options.targetType ? and4(
974
+ eq4(npFollows.followerId, followerId),
975
+ eq4(npFollows.targetType, options.targetType),
976
+ eq4(npFollows.siteId, siteId)
977
+ ) : and4(eq4(npFollows.followerId, followerId), eq4(npFollows.siteId, siteId));
978
+ const rows = await db.select().from(npFollows).where(where).limit(limit).offset(offset);
979
+ return rows;
980
+ }
981
+
982
+ // src/community/principal.ts
983
+ async function principalCan(principal, action, target) {
984
+ const ownerOnly = action === "edit-own" || action === "delete-own";
985
+ switch (principal.kind) {
986
+ case "staff":
987
+ if (ownerOnly) return false;
988
+ return can(principal.user, "community.moderate");
989
+ case "member":
990
+ return memberCan(principal.memberId, action, target);
991
+ default: {
992
+ const _exhaustive = principal;
993
+ void _exhaustive;
994
+ return false;
995
+ }
996
+ }
997
+ }
998
+
999
+ // src/community/reports.ts
1000
+ import { and as and5, count as count3, desc as desc2, eq as eq5, isNotNull, isNull } from "drizzle-orm";
1001
+ var MAX_REASON_LENGTH = 1e3;
1002
+ var SUPPORTED_TARGETS2 = ["comment", "thread", "reply", "member"];
1003
+ function validateTargetType(value) {
1004
+ if (!SUPPORTED_TARGETS2.includes(value)) {
1005
+ throw new NpValidationError("Invalid input", [
1006
+ {
1007
+ field: "targetType",
1008
+ message: `targetType must be one of: ${SUPPORTED_TARGETS2.join(", ")}`
1009
+ }
1010
+ ]);
1011
+ }
1012
+ }
1013
+ async function fileReport(input) {
1014
+ validateTargetType(input.targetType);
1015
+ const targetId = input.targetId.trim();
1016
+ if (targetId.length === 0) {
1017
+ throw new NpValidationError("Invalid input", [
1018
+ { field: "targetId", message: "targetId required" }
1019
+ ]);
1020
+ }
1021
+ const reason = input.reason.trim();
1022
+ if (reason.length === 0) {
1023
+ throw new NpValidationError("Invalid input", [
1024
+ { field: "reason", message: "Report reason required" }
1025
+ ]);
1026
+ }
1027
+ if (reason.length > MAX_REASON_LENGTH) {
1028
+ throw new NpValidationError("Invalid input", [
1029
+ { field: "reason", message: `Reason must be \u2264 ${MAX_REASON_LENGTH} characters` }
1030
+ ]);
1031
+ }
1032
+ return withMemberWrite(input.reporterId, [], async () => {
1033
+ return doFileReport(input, targetId, reason);
1034
+ });
1035
+ }
1036
+ async function doFileReport(input, targetId, reason) {
1037
+ const target = await assertReportTargetExists(input.targetType, targetId);
1038
+ const db = getDb();
1039
+ const siteId = await requireSiteId();
1040
+ if (target.siteId !== null && target.siteId !== siteId) {
1041
+ throw new NpForbiddenError("report", "cross-site");
1042
+ }
1043
+ const [row] = await db.insert(npReports).values({
1044
+ reporterId: input.reporterId,
1045
+ targetType: input.targetType,
1046
+ targetId,
1047
+ reason,
1048
+ siteId
1049
+ }).returning();
1050
+ if (!row) throw new Error("Report insert returned no row");
1051
+ await recordAuditEvent({
1052
+ actor: { kind: "member", memberId: input.reporterId },
1053
+ action: "report.filed",
1054
+ targetType: input.targetType,
1055
+ targetId,
1056
+ payload: { reportId: row.id, reason }
1057
+ });
1058
+ return row;
1059
+ }
1060
+ async function listReports(options = {}) {
1061
+ const db = getDb();
1062
+ const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
1063
+ const offset = Math.max(options.offset ?? 0, 0);
1064
+ const filters = [];
1065
+ if (options.status === "resolved") filters.push(isNotNull(npReports.resolvedAt));
1066
+ else if (options.status === "all") {
1067
+ } else filters.push(isNull(npReports.resolvedAt));
1068
+ if (options.targetType) filters.push(eq5(npReports.targetType, options.targetType));
1069
+ if (options.siteId !== null) {
1070
+ const resolvedSite = options.siteId !== void 0 ? options.siteId : await getCurrentSiteId();
1071
+ if (resolvedSite !== null) {
1072
+ filters.push(eq5(npReports.siteId, resolvedSite));
1073
+ }
1074
+ }
1075
+ const where = filters.length > 0 ? and5(...filters) : void 0;
1076
+ const reports = await db.select().from(npReports).where(where).orderBy(desc2(npReports.createdAt)).limit(limit).offset(offset);
1077
+ const [totalRow] = await db.select({ total: count3() }).from(npReports).where(where);
1078
+ return { reports, totalDocs: Number(totalRow?.total ?? 0) };
1079
+ }
1080
+ async function resolveReport(input) {
1081
+ const resolution = input.resolution.trim();
1082
+ if (resolution.length === 0) {
1083
+ throw new NpValidationError("Invalid input", [
1084
+ { field: "resolution", message: "Resolution label required" }
1085
+ ]);
1086
+ }
1087
+ const db = getDb();
1088
+ const requestSiteId = await requireSiteId();
1089
+ const [existing] = await db.select().from(npReports).where(eq5(npReports.id, input.reportId)).limit(1);
1090
+ if (!existing) throw new NpNotFoundError("report", input.reportId);
1091
+ if (existing.siteId !== requestSiteId) {
1092
+ throw new NpForbiddenError("report", "cross-site");
1093
+ }
1094
+ if (existing.resolvedAt) {
1095
+ throw new NpValidationError("Invalid state", [
1096
+ { field: "report", message: "Report already resolved" }
1097
+ ]);
1098
+ }
1099
+ const resolvedByUserId = input.actor.kind === "staff" ? input.actor.user.id : null;
1100
+ const resolvedByMemberId = input.actor.kind === "member" ? input.actor.memberId : null;
1101
+ const [updated] = await db.update(npReports).set({
1102
+ resolvedAt: /* @__PURE__ */ new Date(),
1103
+ resolvedByUserId,
1104
+ resolvedByMemberId,
1105
+ resolution
1106
+ }).where(and5(eq5(npReports.id, input.reportId), eq5(npReports.siteId, requestSiteId))).returning();
1107
+ if (!updated) throw new Error("Report update returned no row");
1108
+ await recordAuditEvent({
1109
+ actor: input.actor.kind === "staff" ? { kind: "staff", userId: input.actor.user.id } : { kind: "member", memberId: input.actor.memberId },
1110
+ action: "report.resolved",
1111
+ targetType: existing.targetType,
1112
+ targetId: existing.targetId,
1113
+ payload: { reportId: existing.id, resolution }
1114
+ });
1115
+ return updated;
1116
+ }
1117
+ async function assertReportTargetExists(targetType, targetId) {
1118
+ const db = getDb();
1119
+ if (targetType === "comment" || targetType === "reply") {
1120
+ const [row] = await db.select({ id: npComments.id, siteId: npComments.siteId }).from(npComments).where(eq5(npComments.id, targetId)).limit(1);
1121
+ if (!row) throw new NpNotFoundError(targetType, targetId);
1122
+ return { siteId: row.siteId };
1123
+ }
1124
+ if (targetType === "member") {
1125
+ const [row] = await db.select({ id: npMembers.id }).from(npMembers).where(eq5(npMembers.id, targetId)).limit(1);
1126
+ if (!row) throw new NpNotFoundError("member", targetId);
1127
+ return { siteId: null };
1128
+ }
1129
+ if (targetType === "thread") {
1130
+ const slug = "discussions";
1131
+ let registered;
1132
+ try {
1133
+ registered = getCollectionRegistration(slug);
1134
+ } catch {
1135
+ registered = null;
1136
+ }
1137
+ if (!registered) {
1138
+ throw new NpValidationError("Invalid input", [
1139
+ {
1140
+ field: "targetType",
1141
+ message: "Reports against threads require the forum plugin's `discussions` collection to be registered."
1142
+ }
1143
+ ]);
1144
+ }
1145
+ const table = getCollectionTable(slug);
1146
+ const idCol = table.id;
1147
+ const siteCol = table.siteId;
1148
+ const [row] = await db.select({ id: idCol, siteId: siteCol }).from(table).where(eq5(idCol, targetId)).limit(1);
1149
+ if (!row) throw new NpNotFoundError("thread", targetId);
1150
+ return { siteId: row.siteId ?? null };
1151
+ }
1152
+ throw new NpValidationError("Invalid input", [
1153
+ {
1154
+ field: "targetType",
1155
+ message: `Reports against "${targetType}" are not supported`
1156
+ }
1157
+ ]);
1158
+ }
1159
+ async function unresolvedReportCount() {
1160
+ const db = getDb();
1161
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
1162
+ const [row] = await db.select({ total: count3() }).from(npReports).where(and5(eq5(npReports.siteId, siteId), isNull(npReports.resolvedAt)));
1163
+ return Number(row?.total ?? 0);
1164
+ }
1165
+
1166
+ // src/community/bans.ts
1167
+ import { and as and6, desc as desc3, eq as eq6, gt, isNull as isNull2, or as or2 } from "drizzle-orm";
1168
+ async function issueBan(input) {
1169
+ if (input.kind === "temporary" && !(input.expiresAt instanceof Date)) {
1170
+ throw new NpValidationError("Invalid input", [
1171
+ { field: "expiresAt", message: "Temporary bans require an expiresAt timestamp" }
1172
+ ]);
1173
+ }
1174
+ if (input.scopeType !== "site" && !input.scopeId) {
1175
+ throw new NpValidationError("Invalid input", [
1176
+ { field: "scopeId", message: "Scoped bans require a scopeId" }
1177
+ ]);
1178
+ }
1179
+ const db = getDb();
1180
+ const byUserId = input.actor.kind === "staff" ? input.actor.user.id : null;
1181
+ const byMemberId = input.actor.kind === "member" ? input.actor.memberId : null;
1182
+ const siteId = await requireSiteId();
1183
+ const [row] = await db.insert(npBans).values({
1184
+ memberId: input.memberId,
1185
+ scopeType: input.scopeType,
1186
+ scopeId: input.scopeId ?? null,
1187
+ kind: input.kind,
1188
+ expiresAt: input.expiresAt ?? null,
1189
+ reason: input.reason ?? null,
1190
+ byUserId,
1191
+ byMemberId,
1192
+ siteId
1193
+ }).returning();
1194
+ if (!row) throw new Error("Ban insert returned no row");
1195
+ await recordAuditEvent({
1196
+ actor: input.actor.kind === "staff" ? { kind: "staff", userId: input.actor.user.id } : { kind: "member", memberId: input.actor.memberId },
1197
+ action: "member.ban",
1198
+ targetType: "member",
1199
+ targetId: input.memberId,
1200
+ payload: {
1201
+ banId: row.id,
1202
+ scopeType: row.scopeType,
1203
+ scopeId: row.scopeId,
1204
+ kind: row.kind,
1205
+ expiresAt: row.expiresAt?.toISOString() ?? null,
1206
+ reason: row.reason
1207
+ }
1208
+ });
1209
+ return row;
1210
+ }
1211
+ async function listBansForMember(memberId) {
1212
+ const db = getDb();
1213
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
1214
+ const now = /* @__PURE__ */ new Date();
1215
+ return await db.select().from(npBans).where(
1216
+ and6(
1217
+ eq6(npBans.memberId, memberId),
1218
+ eq6(npBans.siteId, siteId),
1219
+ or2(isNull2(npBans.expiresAt), gt(npBans.expiresAt, now))
1220
+ )
1221
+ ).orderBy(desc3(npBans.createdAt));
1222
+ }
1223
+ async function revokeBan(input) {
1224
+ const db = getDb();
1225
+ const requestSiteId = await requireSiteId();
1226
+ const [existing] = await db.select().from(npBans).where(eq6(npBans.id, input.banId)).limit(1);
1227
+ if (!existing) throw new NpNotFoundError("ban", input.banId);
1228
+ if (existing.siteId !== requestSiteId) {
1229
+ throw new NpForbiddenError("ban", "cross-site");
1230
+ }
1231
+ await db.delete(npBans).where(and6(eq6(npBans.id, input.banId), eq6(npBans.siteId, requestSiteId)));
1232
+ await recordAuditEvent({
1233
+ actor: input.actor.kind === "staff" ? { kind: "staff", userId: input.actor.user.id } : { kind: "member", memberId: input.actor.memberId },
1234
+ action: "member.unban",
1235
+ targetType: "member",
1236
+ targetId: existing.memberId,
1237
+ payload: { banId: existing.id, scopeType: existing.scopeType, scopeId: existing.scopeId }
1238
+ });
1239
+ }
1240
+
1241
+ // src/community/grants.ts
1242
+ import { and as and7, desc as desc4, eq as eq7, gt as gt2, isNull as isNull3, or as or3 } from "drizzle-orm";
1243
+ async function grantMemberRole(input) {
1244
+ const definition = getCommunityRole(input.role, input.scopeType);
1245
+ if (!definition) {
1246
+ throw new NpValidationError("Invalid input", [
1247
+ {
1248
+ field: "role",
1249
+ message: `Unknown role '${input.role}' for scope '${input.scopeType}'`
1250
+ }
1251
+ ]);
1252
+ }
1253
+ const scopeId = input.scopeType === "site" ? null : (input.scopeId ?? "").trim();
1254
+ if (input.scopeType !== "site" && !scopeId) {
1255
+ throw new NpValidationError("Invalid input", [
1256
+ { field: "scopeId", message: "scopeId required for non-site grants" }
1257
+ ]);
1258
+ }
1259
+ if (input.expiresAt instanceof Date && input.expiresAt.getTime() <= Date.now()) {
1260
+ throw new NpValidationError("Invalid input", [
1261
+ { field: "expiresAt", message: "expiresAt must be in the future" }
1262
+ ]);
1263
+ }
1264
+ const db = getDb();
1265
+ const normalizedScopeId = scopeId === "" ? null : scopeId;
1266
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
1267
+ const existing = await db.select({ id: npMemberRoles.id }).from(npMemberRoles).where(
1268
+ and7(
1269
+ eq7(npMemberRoles.memberId, input.memberId),
1270
+ eq7(npMemberRoles.role, input.role),
1271
+ eq7(npMemberRoles.scopeType, input.scopeType),
1272
+ eq7(npMemberRoles.siteId, siteId),
1273
+ normalizedScopeId === null ? isNull3(npMemberRoles.scopeId) : eq7(npMemberRoles.scopeId, normalizedScopeId)
1274
+ )
1275
+ ).limit(1);
1276
+ if (existing.length > 0) {
1277
+ throw new NpConflictError(`Member already has this role grant in scope '${input.scopeType}'.`);
1278
+ }
1279
+ let row;
1280
+ try {
1281
+ const [inserted] = await db.insert(npMemberRoles).values({
1282
+ memberId: input.memberId,
1283
+ role: input.role,
1284
+ scopeType: input.scopeType,
1285
+ scopeId: normalizedScopeId,
1286
+ siteId,
1287
+ grantedBy: input.grantedByUserId,
1288
+ expiresAt: input.expiresAt ?? null
1289
+ }).returning();
1290
+ if (!inserted) throw new Error("Grant insert returned no row");
1291
+ row = inserted;
1292
+ } catch (err) {
1293
+ const code = err?.code;
1294
+ const message = err instanceof Error ? err.message : "";
1295
+ if (code === "23505" || /unique|23505|duplicate key/i.test(message)) {
1296
+ throw new NpConflictError(
1297
+ `Member already has this role grant in scope '${input.scopeType}'.`
1298
+ );
1299
+ }
1300
+ throw err;
1301
+ }
1302
+ await recordAuditEvent({
1303
+ actor: { kind: "staff", userId: input.grantedByUserId },
1304
+ action: "member.role.grant",
1305
+ targetType: "member",
1306
+ targetId: input.memberId,
1307
+ payload: {
1308
+ grantId: row.id,
1309
+ role: row.role,
1310
+ scopeType: row.scopeType,
1311
+ scopeId: row.scopeId,
1312
+ expiresAt: row.expiresAt?.toISOString() ?? null
1313
+ }
1314
+ });
1315
+ return row;
1316
+ }
1317
+ async function listMemberRoleGrants(memberId) {
1318
+ const db = getDb();
1319
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
1320
+ const now = /* @__PURE__ */ new Date();
1321
+ return await db.select().from(npMemberRoles).where(
1322
+ and7(
1323
+ eq7(npMemberRoles.memberId, memberId),
1324
+ eq7(npMemberRoles.siteId, siteId),
1325
+ or3(isNull3(npMemberRoles.expiresAt), gt2(npMemberRoles.expiresAt, now))
1326
+ )
1327
+ ).orderBy(desc4(npMemberRoles.grantedAt));
1328
+ }
1329
+ async function revokeMemberRole(input) {
1330
+ const db = getDb();
1331
+ const requestSiteId = await requireSiteId();
1332
+ const deleted = await db.delete(npMemberRoles).where(and7(eq7(npMemberRoles.id, input.grantId), eq7(npMemberRoles.siteId, requestSiteId))).returning();
1333
+ if (deleted.length === 0) {
1334
+ throw new NpNotFoundError("memberRoleGrant", input.grantId);
1335
+ }
1336
+ const [existing] = deleted;
1337
+ await recordAuditEvent({
1338
+ actor: { kind: "staff", userId: input.revokedByUserId },
1339
+ action: "member.role.revoke",
1340
+ targetType: "member",
1341
+ targetId: existing.memberId,
1342
+ payload: {
1343
+ grantId: existing.id,
1344
+ role: existing.role,
1345
+ scopeType: existing.scopeType,
1346
+ scopeId: existing.scopeId
1347
+ }
1348
+ });
1349
+ }
1350
+
1351
+ // src/community/member-admin.ts
1352
+ import { and as and9, eq as eq11, isNull as isNull4, ne as ne2 } from "drizzle-orm";
1353
+
1354
+ // src/collections/revisions.ts
1355
+ import { and as and8, desc as desc5, eq as eq8, count as count4 } from "drizzle-orm";
1356
+ function normalizeLimit(limit) {
1357
+ if (!limit || limit < 1) return 20;
1358
+ return Math.min(Math.floor(limit), 100);
1359
+ }
1360
+ function normalizeOffset(offset) {
1361
+ if (!offset || offset < 0) return 0;
1362
+ return Math.floor(offset);
1363
+ }
1364
+ function assertVersionsEnabled(collection) {
1365
+ const config = getCollectionConfig(collection);
1366
+ if (!config.versions) {
1367
+ throw new NpValidationError("Revisions not enabled", [
1368
+ {
1369
+ field: "collection",
1370
+ message: `Collection "${collection}" has no versions config \u2014 enable versions.drafts to persist revisions.`
1371
+ }
1372
+ ]);
1373
+ }
1374
+ }
1375
+ async function assertReadAccess(collection, user, doc) {
1376
+ const config = getCollectionConfig(collection);
1377
+ if (!user) {
1378
+ throw new NpForbiddenError(collection, "read-revision");
1379
+ }
1380
+ if (config.access?.update) {
1381
+ const allowed = await config.access.update({ user, doc: doc ?? void 0 });
1382
+ if (!allowed) {
1383
+ throw new NpForbiddenError(collection, "read-revision");
1384
+ }
1385
+ return;
1386
+ }
1387
+ if (user.role !== "admin" && user.role !== "editor") {
1388
+ throw new NpForbiddenError(collection, "read-revision");
1389
+ }
1390
+ }
1391
+ function toRevisionSnapshot(value) {
1392
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1393
+ throw new NpValidationError("Invalid revision snapshot", [
1394
+ { field: "snapshot", message: "Snapshot must be a JSON object" }
1395
+ ]);
1396
+ }
1397
+ return value;
1398
+ }
1399
+ async function listRevisions(collection, documentId, options = {}, user = null) {
1400
+ assertVersionsEnabled(collection);
1401
+ const targetDoc = await getDocumentById(collection, documentId, user ?? void 0);
1402
+ await assertReadAccess(collection, user, targetDoc);
1403
+ const db = getDb();
1404
+ const limit = normalizeLimit(options.limit);
1405
+ const offset = normalizeOffset(options.offset);
1406
+ const filter = and8(
1407
+ eq8(npRevisions.collection, collection),
1408
+ eq8(npRevisions.documentId, documentId)
1409
+ );
1410
+ const rows = await db.select({
1411
+ id: npRevisions.id,
1412
+ collection: npRevisions.collection,
1413
+ documentId: npRevisions.documentId,
1414
+ version: npRevisions.version,
1415
+ status: npRevisions.status,
1416
+ changedFields: npRevisions.changedFields,
1417
+ authorId: npRevisions.authorId,
1418
+ createdAt: npRevisions.createdAt
1419
+ }).from(npRevisions).where(filter).orderBy(desc5(npRevisions.version)).limit(limit).offset(offset);
1420
+ const [totalRow] = await db.select({ total: count4() }).from(npRevisions).where(filter);
1421
+ return {
1422
+ revisions: rows.map((row) => ({
1423
+ ...row,
1424
+ changedFields: row.changedFields ?? []
1425
+ })),
1426
+ total: Number(totalRow?.total ?? 0)
1427
+ };
1428
+ }
1429
+ async function getRevision(collection, documentId, revisionId, user = null) {
1430
+ assertVersionsEnabled(collection);
1431
+ const targetDoc = await getDocumentById(collection, documentId, user ?? void 0);
1432
+ await assertReadAccess(collection, user, targetDoc);
1433
+ const db = getDb();
1434
+ const [row] = await db.select().from(npRevisions).where(
1435
+ and8(
1436
+ eq8(npRevisions.id, revisionId),
1437
+ eq8(npRevisions.collection, collection),
1438
+ eq8(npRevisions.documentId, documentId)
1439
+ )
1440
+ ).limit(1);
1441
+ if (!row) {
1442
+ throw new NpNotFoundError("revision", revisionId);
1443
+ }
1444
+ return {
1445
+ id: row.id,
1446
+ collection: row.collection,
1447
+ documentId: row.documentId,
1448
+ version: row.version,
1449
+ status: row.status,
1450
+ changedFields: row.changedFields ?? [],
1451
+ snapshot: toRevisionSnapshot(row.snapshot),
1452
+ authorId: row.authorId,
1453
+ createdAt: row.createdAt
1454
+ };
1455
+ }
1456
+ async function restoreRevision(collection, documentId, revisionId, user) {
1457
+ const revision = await getRevision(collection, documentId, revisionId, user);
1458
+ return saveDocument(collection, documentId, revision.snapshot, user, {
1459
+ status: revision.status === "published" ? "published" : "draft"
1460
+ });
1461
+ }
1462
+
1463
+ // src/collections/pending-queue.ts
1464
+ import { sql as sql2 } from "drizzle-orm";
1465
+ function getTableColumn(table, name) {
1466
+ return table[name];
1467
+ }
1468
+ function buildPendingBranch(slug) {
1469
+ let config;
1470
+ try {
1471
+ config = getCollectionConfig(slug);
1472
+ } catch {
1473
+ return null;
1474
+ }
1475
+ if (!config.community?.memberWrite?.create) return null;
1476
+ const table = getCollectionTable(slug);
1477
+ const titleCol = getTableColumn(table, "title");
1478
+ if (!titleCol) return null;
1479
+ const slugCol = getTableColumn(table, "slug");
1480
+ return sql2`
1481
+ SELECT
1482
+ ${slug}::text AS collection_slug,
1483
+ id,
1484
+ title,
1485
+ ${slugCol ? sql2`slug` : sql2`NULL::text`} AS doc_slug,
1486
+ created_at,
1487
+ member_author_id
1488
+ FROM ${table}
1489
+ WHERE status = 'pending' AND member_author_id IS NOT NULL
1490
+ `;
1491
+ }
1492
+ async function listPendingMemberDocs(options = {}) {
1493
+ const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
1494
+ const offset = Math.max(options.offset ?? 0, 0);
1495
+ const slugs = options.collectionSlug ? [options.collectionSlug] : getAllCollectionSlugs();
1496
+ const db = getDb();
1497
+ const branches = [];
1498
+ for (const slug of slugs) {
1499
+ const branch = buildPendingBranch(slug);
1500
+ if (branch) branches.push(branch);
1501
+ }
1502
+ if (branches.length === 0) {
1503
+ return { docs: [], totalDocs: 0 };
1504
+ }
1505
+ const union = sql2.join(branches, sql2` UNION ALL `);
1506
+ const [countRow] = (await db.execute(
1507
+ sql2`SELECT count(*)::int AS total FROM (${union}) p`
1508
+ )).rows;
1509
+ const totalDocs = Number(countRow?.total ?? 0);
1510
+ const result = await db.execute(sql2`
1511
+ SELECT
1512
+ p.collection_slug AS collection_slug,
1513
+ p.id AS id,
1514
+ p.title AS title,
1515
+ p.doc_slug AS doc_slug,
1516
+ p.created_at AS created_at,
1517
+ m.id AS member_id,
1518
+ m.handle AS member_handle,
1519
+ m.display_name AS member_display_name
1520
+ FROM (${union}) p
1521
+ LEFT JOIN ${npMembers} m ON m.id = p.member_author_id
1522
+ ORDER BY p.created_at DESC
1523
+ LIMIT ${limit} OFFSET ${offset}
1524
+ `);
1525
+ const docs = result.rows.map((row) => ({
1526
+ id: row.id,
1527
+ collectionSlug: row.collection_slug,
1528
+ title: typeof row.title === "string" && row.title.length > 0 ? row.title : "(untitled)",
1529
+ slug: row.doc_slug,
1530
+ status: "pending",
1531
+ createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at),
1532
+ memberAuthor: row.member_id && row.member_handle && row.member_display_name ? {
1533
+ id: row.member_id,
1534
+ handle: row.member_handle,
1535
+ displayName: row.member_display_name
1536
+ } : null
1537
+ }));
1538
+ return { docs, totalDocs };
1539
+ }
1540
+
1541
+ // src/collections/search-api.ts
1542
+ import { eq as eq9 } from "drizzle-orm";
1543
+
1544
+ // src/collections/search-adapter.ts
1545
+ var currentAdapter = null;
1546
+ function setSearchAdapter(adapter) {
1547
+ if (typeof adapter?.search !== "function") {
1548
+ throw new Error("setSearchAdapter: adapter must implement search()");
1549
+ }
1550
+ currentAdapter = adapter;
1551
+ }
1552
+ function getSearchAdapter() {
1553
+ return currentAdapter;
1554
+ }
1555
+ function resetSearchAdapter() {
1556
+ currentAdapter = null;
1557
+ }
1558
+
1559
+ // src/collections/search-api.ts
1560
+ var DEFAULT_LIMIT = 10;
1561
+ var MAX_LIMIT = 50;
1562
+ function normalizeLimit2(limit) {
1563
+ if (!limit || limit < 1) return DEFAULT_LIMIT;
1564
+ return Math.min(Math.floor(limit), MAX_LIMIT);
1565
+ }
1566
+ function hasSearchVectorColumn(table) {
1567
+ return table.searchVector !== void 0;
1568
+ }
1569
+ async function searchCollections(opts) {
1570
+ const query = opts.q.trim();
1571
+ if (query.length === 0) {
1572
+ return { results: [], total: 0, perCollection: {} };
1573
+ }
1574
+ const slugs = opts.collections ?? getAllCollectionSlugs();
1575
+ const limit = normalizeLimit2(opts.limit);
1576
+ const offset = opts.offset ?? 0;
1577
+ const baseWhere = opts.where ?? { status: "published" };
1578
+ const adapter = getSearchAdapter();
1579
+ if (adapter) {
1580
+ try {
1581
+ const adapterResult = await adapter.search({
1582
+ q: query,
1583
+ collections: opts.collections,
1584
+ limit,
1585
+ offset,
1586
+ locale: opts.locale
1587
+ });
1588
+ if (adapterResult) return adapterResult;
1589
+ } catch (err) {
1590
+ const { getLogger: getLogger2 } = await import("./logger-S7REWDNE.js");
1591
+ getLogger2().warn("search adapter threw \u2014 falling back to pg tsvector", {
1592
+ error: err instanceof Error ? err.message : String(err)
1593
+ });
1594
+ }
1595
+ }
1596
+ const results = [];
1597
+ const perCollection = {};
1598
+ let total = 0;
1599
+ for (const slug of slugs) {
1600
+ let table;
1601
+ try {
1602
+ table = getCollectionTable(slug);
1603
+ } catch {
1604
+ continue;
1605
+ }
1606
+ if (!hasSearchVectorColumn(table)) continue;
1607
+ const config = getCollectionConfig(slug);
1608
+ const collectionLocale = config.i18n && opts.locale ? opts.locale : void 0;
1609
+ const page = await findDocuments(slug, {
1610
+ search: query,
1611
+ where: baseWhere,
1612
+ limit,
1613
+ page: 1,
1614
+ ...collectionLocale ? { locale: collectionLocale } : {}
1615
+ });
1616
+ perCollection[slug] = page.totalDocs;
1617
+ total += page.totalDocs;
1618
+ for (const doc of page.docs) {
1619
+ results.push({ collection: slug, doc });
1620
+ }
1621
+ }
1622
+ return {
1623
+ results: results.slice(offset, offset + limit),
1624
+ total,
1625
+ perCollection
1626
+ };
1627
+ }
1628
+ function getTableColumn2(table, key) {
1629
+ const column = table[key];
1630
+ if (!column) {
1631
+ throw new Error(`Column '${key}' not found on collection table.`);
1632
+ }
1633
+ return column;
1634
+ }
1635
+ async function reindexCollection(slug) {
1636
+ const config = getCollectionConfig(slug);
1637
+ const table = getCollectionTable(slug);
1638
+ if (!hasSearchVectorColumn(table)) {
1639
+ return { collection: slug, processed: 0 };
1640
+ }
1641
+ const db = getDb();
1642
+ const idCol = getTableColumn2(table, "id");
1643
+ const rows = await db.select().from(table);
1644
+ let processed = 0;
1645
+ for (const row of rows) {
1646
+ const weighted = buildWeightedSearchVectorSql(config, row);
1647
+ await db.update(table).set({ searchVector: weighted }).where(eq9(idCol, row.id));
1648
+ processed += 1;
1649
+ }
1650
+ return { collection: slug, processed };
1651
+ }
1652
+
1653
+ // src/collections/translations.ts
1654
+ import { eq as eq10, sql as sql3 } from "drizzle-orm";
1655
+ function getTableColumn3(table, name) {
1656
+ const column = table[name];
1657
+ if (!column) {
1658
+ throw new Error(`Column "${name}" not found on table`);
1659
+ }
1660
+ return column;
1661
+ }
1662
+ async function findTranslations(collection, docId) {
1663
+ const config = getCollectionConfig(collection);
1664
+ if (!config.i18n) {
1665
+ throw new NpValidationError("Invalid input", [
1666
+ {
1667
+ field: "collection",
1668
+ message: `Collection "${collection}" is not i18n-enabled`
1669
+ }
1670
+ ]);
1671
+ }
1672
+ const table = getCollectionTable(collection);
1673
+ const db = getDb();
1674
+ const source = await getDocumentById(collection, docId);
1675
+ if (!source) throw new NpNotFoundError(collection, docId);
1676
+ const groupId = source.translationGroupId;
1677
+ if (!groupId) {
1678
+ throw new Error(
1679
+ `Doc ${docId} in collection "${collection}" has no translationGroupId`
1680
+ );
1681
+ }
1682
+ const rows = await db.select().from(table).where(
1683
+ eq10(getTableColumn3(table, "translationGroupId"), groupId)
1684
+ );
1685
+ const ordering = getI18nConfig()?.locales ?? [];
1686
+ const rank = (locale) => {
1687
+ const i = ordering.indexOf(locale);
1688
+ return i === -1 ? Number.MAX_SAFE_INTEGER : i;
1689
+ };
1690
+ return rows.map(
1691
+ (r) => ({
1692
+ id: String(r.id),
1693
+ locale: String(r.locale),
1694
+ slug: String(r.slug),
1695
+ status: String(r.status),
1696
+ title: r.title,
1697
+ updatedAt: r.updatedAt,
1698
+ translationGroupId: String(r.translationGroupId)
1699
+ })
1700
+ ).sort((a, b) => rank(a.locale) - rank(b.locale));
1701
+ }
1702
+ async function createTranslation(collection, sourceDocId, targetLocale, user) {
1703
+ const config = getCollectionConfig(collection);
1704
+ if (!config.i18n) {
1705
+ throw new NpValidationError("Invalid input", [
1706
+ {
1707
+ field: "collection",
1708
+ message: `Collection "${collection}" is not i18n-enabled`
1709
+ }
1710
+ ]);
1711
+ }
1712
+ const i18n = getI18nConfig();
1713
+ if (!i18n) {
1714
+ throw new Error("i18n config is not initialised");
1715
+ }
1716
+ if (!i18n.locales.includes(targetLocale)) {
1717
+ throw new NpValidationError("Invalid input", [
1718
+ {
1719
+ field: "targetLocale",
1720
+ message: `Locale "${targetLocale}" is not configured`
1721
+ }
1722
+ ]);
1723
+ }
1724
+ const source = await getDocumentById(collection, sourceDocId);
1725
+ if (!source) throw new NpNotFoundError(collection, sourceDocId);
1726
+ const sourceLocale = source.locale;
1727
+ if (sourceLocale === targetLocale) {
1728
+ throw new NpValidationError("Invalid input", [
1729
+ {
1730
+ field: "targetLocale",
1731
+ message: `Source row is already in locale "${targetLocale}"`
1732
+ }
1733
+ ]);
1734
+ }
1735
+ const existing = await findTranslations(collection, sourceDocId);
1736
+ if (existing.some((r) => r.locale === targetLocale)) {
1737
+ throw new NpValidationError("Invalid input", [
1738
+ {
1739
+ field: "targetLocale",
1740
+ message: `A "${targetLocale}" translation already exists for this document`
1741
+ }
1742
+ ]);
1743
+ }
1744
+ const groupId = source.translationGroupId;
1745
+ if (!groupId) {
1746
+ throw new Error(
1747
+ `Doc ${sourceDocId} in collection "${collection}" has no translationGroupId`
1748
+ );
1749
+ }
1750
+ const {
1751
+ id,
1752
+ slug,
1753
+ locale,
1754
+ status,
1755
+ _status,
1756
+ createdAt,
1757
+ updatedAt,
1758
+ createdBy,
1759
+ updatedBy,
1760
+ searchVector,
1761
+ translationGroupId,
1762
+ ...content
1763
+ } = source;
1764
+ void id;
1765
+ void slug;
1766
+ void locale;
1767
+ void status;
1768
+ void _status;
1769
+ void createdAt;
1770
+ void updatedAt;
1771
+ void createdBy;
1772
+ void updatedBy;
1773
+ void searchVector;
1774
+ void translationGroupId;
1775
+ const result = await saveDocument(
1776
+ collection,
1777
+ null,
1778
+ {
1779
+ ...content,
1780
+ locale: targetLocale,
1781
+ translationGroupId: groupId
1782
+ },
1783
+ user,
1784
+ { status: "draft" }
1785
+ );
1786
+ return { id: result.doc.id };
1787
+ }
1788
+ async function getTranslationProgress() {
1789
+ const i18n = getI18nConfig();
1790
+ if (!i18n) return null;
1791
+ const db = getDb();
1792
+ const out = [];
1793
+ for (const slug of getAllCollectionSlugs()) {
1794
+ const config = getCollectionConfig(slug);
1795
+ if (!config.i18n) continue;
1796
+ const table = getCollectionTable(slug);
1797
+ const localeCol = getTableColumn3(table, "locale");
1798
+ const groupCol = getTableColumn3(table, "translationGroupId");
1799
+ const localeRows = await db.select({
1800
+ locale: localeCol,
1801
+ count: sql3`count(*)::int`
1802
+ }).from(table).groupBy(localeCol);
1803
+ const totalRows = await db.select({
1804
+ groups: sql3`count(distinct ${groupCol})::int`
1805
+ }).from(table);
1806
+ const totalGroups = totalRows[0]?.groups ?? 0;
1807
+ const counts = Object.fromEntries(
1808
+ i18n.locales.map((loc) => [loc, 0])
1809
+ );
1810
+ for (const row of localeRows) {
1811
+ if (row.locale in counts) {
1812
+ counts[row.locale] = row.count;
1813
+ }
1814
+ }
1815
+ const perLocale = {};
1816
+ for (const loc of i18n.locales) {
1817
+ const count5 = counts[loc] ?? 0;
1818
+ perLocale[loc] = {
1819
+ count: count5,
1820
+ missing: Math.max(0, totalGroups - count5)
1821
+ };
1822
+ }
1823
+ out.push({
1824
+ collection: slug,
1825
+ totalGroups,
1826
+ perLocale
1827
+ });
1828
+ }
1829
+ return {
1830
+ defaultLocale: i18n.defaultLocale,
1831
+ locales: i18n.locales,
1832
+ collections: out
1833
+ };
1834
+ }
1835
+
1836
+ // src/community/member-admin.ts
1837
+ async function purgeMemberContent(memberId, staffUser) {
1838
+ const db = getDb();
1839
+ const [memberRow] = await db.select({ id: npMembers.id }).from(npMembers).where(eq11(npMembers.id, memberId)).limit(1);
1840
+ if (!memberRow) {
1841
+ throw new NpNotFoundError("member", memberId);
1842
+ }
1843
+ const liveComments = await db.select({ id: npComments.id }).from(npComments).where(
1844
+ and9(eq11(npComments.memberId, memberId), ne2(npComments.status, "deleted"))
1845
+ );
1846
+ let commentsDeleted = 0;
1847
+ for (const row of liveComments) {
1848
+ try {
1849
+ await staffDeleteComment(row.id, staffUser.id);
1850
+ commentsDeleted += 1;
1851
+ } catch (err) {
1852
+ if (err instanceof NpNotFoundError) continue;
1853
+ throw err;
1854
+ }
1855
+ }
1856
+ const documents = {};
1857
+ for (const slug of getAllCollectionSlugs()) {
1858
+ let config;
1859
+ try {
1860
+ config = getCollectionConfig(slug);
1861
+ } catch {
1862
+ continue;
1863
+ }
1864
+ if (!config.community?.memberWrite?.create) continue;
1865
+ const table = getCollectionTable(slug);
1866
+ const memberAuthorCol = table.memberAuthorId;
1867
+ const idCol = table.id;
1868
+ if (!memberAuthorCol || !idCol) continue;
1869
+ const rows = await db.select({ id: idCol }).from(table).where(eq11(memberAuthorCol, memberId));
1870
+ let perCollection = 0;
1871
+ for (const row of rows) {
1872
+ try {
1873
+ await deleteDocument(slug, row.id, staffUser);
1874
+ perCollection += 1;
1875
+ } catch (err) {
1876
+ if (err instanceof NpNotFoundError) continue;
1877
+ throw err;
1878
+ }
1879
+ }
1880
+ if (perCollection > 0) documents[slug] = perCollection;
1881
+ }
1882
+ const mediaDb = getDb();
1883
+ const liveMedia = await mediaDb.select({ id: npMedia.id }).from(npMedia).where(
1884
+ and9(eq11(npMedia.uploadedByMemberId, memberId), isNull4(npMedia.deletedAt))
1885
+ );
1886
+ let mediaDeleted = 0;
1887
+ let mediaSkipped = 0;
1888
+ for (const row of liveMedia) {
1889
+ const result = await deleteMedia(row.id);
1890
+ if (result.deleted) mediaDeleted += 1;
1891
+ else mediaSkipped += 1;
1892
+ }
1893
+ await recordAuditEvent({
1894
+ actor: { kind: "staff", userId: staffUser.id },
1895
+ action: "member.content.purge",
1896
+ targetType: "member",
1897
+ targetId: memberId,
1898
+ payload: {
1899
+ comments: commentsDeleted,
1900
+ documents,
1901
+ media: { deleted: mediaDeleted, skipped: mediaSkipped }
1902
+ }
1903
+ });
1904
+ return {
1905
+ comments: commentsDeleted,
1906
+ documents,
1907
+ media: { deleted: mediaDeleted, skipped: mediaSkipped }
1908
+ };
1909
+ }
1910
+
1911
+ export {
1912
+ listRevisions,
1913
+ getRevision,
1914
+ restoreRevision,
1915
+ listPendingMemberDocs,
1916
+ setSearchAdapter,
1917
+ getSearchAdapter,
1918
+ resetSearchAdapter,
1919
+ searchCollections,
1920
+ reindexCollection,
1921
+ findTranslations,
1922
+ createTranslation,
1923
+ getTranslationProgress,
1924
+ getMemberProfile,
1925
+ getMemberProfiles,
1926
+ renderCommentMarkdown,
1927
+ createComment,
1928
+ listComments,
1929
+ updateComment,
1930
+ deleteComment,
1931
+ hideComment,
1932
+ restoreComment,
1933
+ staffHideComment,
1934
+ staffRestoreComment,
1935
+ staffDeleteComment,
1936
+ DEFAULT_REACTION_KINDS,
1937
+ addReaction,
1938
+ removeReaction,
1939
+ countReactions,
1940
+ listMemberReactions,
1941
+ assertReactableExists,
1942
+ follow,
1943
+ unfollow,
1944
+ isFollowing,
1945
+ listFollowing,
1946
+ principalCan,
1947
+ fileReport,
1948
+ listReports,
1949
+ resolveReport,
1950
+ unresolvedReportCount,
1951
+ issueBan,
1952
+ listBansForMember,
1953
+ revokeBan,
1954
+ grantMemberRole,
1955
+ listMemberRoleGrants,
1956
+ revokeMemberRole,
1957
+ purgeMemberContent
1958
+ };
1959
+ //# sourceMappingURL=chunk-6YI5K2TI.js.map