@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.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/audit-54XLVCWD.js +14 -0
- package/dist/audit-54XLVCWD.js.map +1 -0
- package/dist/auth.d.ts +640 -0
- package/dist/auth.js +94 -0
- package/dist/auth.js.map +1 -0
- package/dist/can-YLUHRJAB.js +19 -0
- package/dist/can-YLUHRJAB.js.map +1 -0
- package/dist/chunk-2G264RCD.js +68 -0
- package/dist/chunk-2G264RCD.js.map +1 -0
- package/dist/chunk-2YDGE7YX.js +92 -0
- package/dist/chunk-2YDGE7YX.js.map +1 -0
- package/dist/chunk-473S4TER.js +538 -0
- package/dist/chunk-473S4TER.js.map +1 -0
- package/dist/chunk-4ZLMEKFX.js +18 -0
- package/dist/chunk-4ZLMEKFX.js.map +1 -0
- package/dist/chunk-55FU6WED.js +179 -0
- package/dist/chunk-55FU6WED.js.map +1 -0
- package/dist/chunk-6YI5K2TI.js +1959 -0
- package/dist/chunk-6YI5K2TI.js.map +1 -0
- package/dist/chunk-BHK3AD3Q.js +41 -0
- package/dist/chunk-BHK3AD3Q.js.map +1 -0
- package/dist/chunk-CRUQBZUF.js +39 -0
- package/dist/chunk-CRUQBZUF.js.map +1 -0
- package/dist/chunk-CTSQ7BRI.js +175 -0
- package/dist/chunk-CTSQ7BRI.js.map +1 -0
- package/dist/chunk-DK2JBJH7.js +81 -0
- package/dist/chunk-DK2JBJH7.js.map +1 -0
- package/dist/chunk-DP2PREDU.js +597 -0
- package/dist/chunk-DP2PREDU.js.map +1 -0
- package/dist/chunk-EQ2Z3KMD.js +24 -0
- package/dist/chunk-EQ2Z3KMD.js.map +1 -0
- package/dist/chunk-FZ7O6DWI.js +305 -0
- package/dist/chunk-FZ7O6DWI.js.map +1 -0
- package/dist/chunk-ISLYFQWL.js +1270 -0
- package/dist/chunk-ISLYFQWL.js.map +1 -0
- package/dist/chunk-JJL74ZPK.js +68 -0
- package/dist/chunk-JJL74ZPK.js.map +1 -0
- package/dist/chunk-JKXAPSU4.js +24 -0
- package/dist/chunk-JKXAPSU4.js.map +1 -0
- package/dist/chunk-KU5M27ZC.js +24 -0
- package/dist/chunk-KU5M27ZC.js.map +1 -0
- package/dist/chunk-LSHHRDVR.js +34 -0
- package/dist/chunk-LSHHRDVR.js.map +1 -0
- package/dist/chunk-M43PGOQY.js +715 -0
- package/dist/chunk-M43PGOQY.js.map +1 -0
- package/dist/chunk-MEJAHXIO.js +150 -0
- package/dist/chunk-MEJAHXIO.js.map +1 -0
- package/dist/chunk-NUCGHWCF.js +101 -0
- package/dist/chunk-NUCGHWCF.js.map +1 -0
- package/dist/chunk-OK5HOCQI.js +845 -0
- package/dist/chunk-OK5HOCQI.js.map +1 -0
- package/dist/chunk-OROPGO65.js +13 -0
- package/dist/chunk-OROPGO65.js.map +1 -0
- package/dist/chunk-PPAS4SZR.js +176 -0
- package/dist/chunk-PPAS4SZR.js.map +1 -0
- package/dist/chunk-PPBWRKO2.js +171 -0
- package/dist/chunk-PPBWRKO2.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/chunk-QO7LAQZH.js +321 -0
- package/dist/chunk-QO7LAQZH.js.map +1 -0
- package/dist/chunk-QVJ2HCAX.js +225 -0
- package/dist/chunk-QVJ2HCAX.js.map +1 -0
- package/dist/chunk-RIPHIRPP.js +68 -0
- package/dist/chunk-RIPHIRPP.js.map +1 -0
- package/dist/chunk-S27S42QY.js +134 -0
- package/dist/chunk-S27S42QY.js.map +1 -0
- package/dist/chunk-SBCVAC2Z.js +40 -0
- package/dist/chunk-SBCVAC2Z.js.map +1 -0
- package/dist/chunk-TFJ4MKPH.js +694 -0
- package/dist/chunk-TFJ4MKPH.js.map +1 -0
- package/dist/chunk-THX3SHYA.js +75 -0
- package/dist/chunk-THX3SHYA.js.map +1 -0
- package/dist/chunk-UGQSQO5B.js +222 -0
- package/dist/chunk-UGQSQO5B.js.map +1 -0
- package/dist/chunk-V2UNHGAP.js +26 -0
- package/dist/chunk-V2UNHGAP.js.map +1 -0
- package/dist/chunk-VGTPQXNQ.js +2790 -0
- package/dist/chunk-VGTPQXNQ.js.map +1 -0
- package/dist/chunk-VNIHXQ7W.js +194 -0
- package/dist/chunk-VNIHXQ7W.js.map +1 -0
- package/dist/chunk-WV272MPW.js +31 -0
- package/dist/chunk-WV272MPW.js.map +1 -0
- package/dist/chunk-X5KKBOUS.js +26 -0
- package/dist/chunk-X5KKBOUS.js.map +1 -0
- package/dist/chunk-XANPEOJC.js +17 -0
- package/dist/chunk-XANPEOJC.js.map +1 -0
- package/dist/chunk-XPVQIHAQ.js +83 -0
- package/dist/chunk-XPVQIHAQ.js.map +1 -0
- package/dist/chunk-ZCINJSS4.js +75 -0
- package/dist/chunk-ZCINJSS4.js.map +1 -0
- package/dist/community.d.ts +1425 -0
- package/dist/community.js +206 -0
- package/dist/community.js.map +1 -0
- package/dist/config-2GDU7PCK.js +32 -0
- package/dist/config-2GDU7PCK.js.map +1 -0
- package/dist/context-MNZ4QXPC.js +16 -0
- package/dist/context-MNZ4QXPC.js.map +1 -0
- package/dist/db-schema.d.ts +4 -0
- package/dist/db-schema.js +102 -0
- package/dist/db-schema.js.map +1 -0
- package/dist/db.d.ts +7 -0
- package/dist/db.js +117 -0
- package/dist/db.js.map +1 -0
- package/dist/digest-SY42GQSU.js +17 -0
- package/dist/digest-SY42GQSU.js.map +1 -0
- package/dist/errors-5OS3S2J3.js +22 -0
- package/dist/errors-5OS3S2J3.js.map +1 -0
- package/dist/host-OBOI4MJK.js +51 -0
- package/dist/host-OBOI4MJK.js.map +1 -0
- package/dist/i18n.d.ts +301 -0
- package/dist/i18n.js +68 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index-B6-_vr_m.d.ts +590 -0
- package/dist/index-CY55LC0u.d.ts +4722 -0
- package/dist/index-CeiTvwbp.d.ts +168 -0
- package/dist/index-XwP1ET8b.d.ts +61 -0
- package/dist/index.d.ts +2037 -0
- package/dist/index.js +2205 -0
- package/dist/index.js.map +1 -0
- package/dist/job-log-VZXWQUDK.js +24 -0
- package/dist/job-log-VZXWQUDK.js.map +1 -0
- package/dist/jobs.d.ts +4 -0
- package/dist/jobs.js +76 -0
- package/dist/jobs.js.map +1 -0
- package/dist/logger-DqGaOU_j.d.ts +29 -0
- package/dist/logger-S7REWDNE.js +16 -0
- package/dist/logger-S7REWDNE.js.map +1 -0
- package/dist/media.d.ts +5 -0
- package/dist/media.js +41 -0
- package/dist/media.js.map +1 -0
- package/dist/mentions-2IHFVSHW.js +23 -0
- package/dist/mentions-2IHFVSHW.js.map +1 -0
- package/dist/mutes-EWAE5FZR.js +21 -0
- package/dist/mutes-EWAE5FZR.js.map +1 -0
- package/dist/notification-prefs-VPJDU7I6.js +21 -0
- package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
- package/dist/observability.d.ts +156 -0
- package/dist/observability.js +32 -0
- package/dist/observability.js.map +1 -0
- package/dist/profanity-adapter-NU2JQSLX.js +12 -0
- package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
- package/dist/queue-XE5BC75T.js +14 -0
- package/dist/queue-XE5BC75T.js.map +1 -0
- package/dist/rate-limit.d.ts +99 -0
- package/dist/rate-limit.js +14 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/registry-XIXDEPVI.js +31 -0
- package/dist/registry-XIXDEPVI.js.map +1 -0
- package/dist/reputation-JRL2YQHM.js +11 -0
- package/dist/reputation-JRL2YQHM.js.map +1 -0
- package/dist/routes.d.ts +43 -0
- package/dist/routes.js +12 -0
- package/dist/routes.js.map +1 -0
- package/dist/scheduled-CIQM57HT.js +20 -0
- package/dist/scheduled-CIQM57HT.js.map +1 -0
- package/dist/seo.d.ts +410 -0
- package/dist/seo.js +44 -0
- package/dist/seo.js.map +1 -0
- package/dist/settings-FOBIESPB.js +17 -0
- package/dist/settings-FOBIESPB.js.map +1 -0
- package/dist/spam-adapter-XX3G737Z.js +12 -0
- package/dist/spam-adapter-XX3G737Z.js.map +1 -0
- package/dist/strings-VAE47B2C.js +29 -0
- package/dist/strings-VAE47B2C.js.map +1 -0
- package/dist/templates-IFVJMCJ6.js +12 -0
- package/dist/templates-IFVJMCJ6.js.map +1 -0
- package/dist/types-TlsbXS0T.d.ts +871 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|