@igstack/app-catalog-backend-core 6.10.2 → 6.11.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 (104) hide show
  1. package/dist/db/syncAppCatalog.mjs +1 -1
  2. package/dist/generated/prisma/client.d.mts +1 -1
  3. package/dist/generated/prisma/client.mjs.map +1 -1
  4. package/dist/generated/prisma/commonInputTypes.d.mts +33 -3
  5. package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
  6. package/dist/generated/prisma/enums.d.mts +6 -1
  7. package/dist/generated/prisma/enums.d.mts.map +1 -1
  8. package/dist/generated/prisma/internal/class.d.mts +84 -0
  9. package/dist/generated/prisma/internal/class.d.mts.map +1 -1
  10. package/dist/generated/prisma/internal/class.mjs +4 -4
  11. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  12. package/dist/generated/prisma/internal/prismaNamespace.d.mts +617 -4
  13. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  14. package/dist/generated/prisma/models/DbCommunityTool.d.mts +2852 -0
  15. package/dist/generated/prisma/models/DbCommunityTool.d.mts.map +1 -0
  16. package/dist/generated/prisma/models/DbCommunityToolLike.d.mts +1102 -0
  17. package/dist/generated/prisma/models/DbCommunityToolLike.d.mts.map +1 -0
  18. package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts +1034 -0
  19. package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts.map +1 -0
  20. package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts +1034 -0
  21. package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts.map +1 -0
  22. package/dist/generated/prisma/models/DbCommunityToolPin.d.mts +1110 -0
  23. package/dist/generated/prisma/models/DbCommunityToolPin.d.mts.map +1 -0
  24. package/dist/generated/prisma/models/DbCommunityToolTag.d.mts +1073 -0
  25. package/dist/generated/prisma/models/DbCommunityToolTag.d.mts.map +1 -0
  26. package/dist/generated/prisma/models/DbCommunityToolView.d.mts +1149 -0
  27. package/dist/generated/prisma/models/DbCommunityToolView.d.mts.map +1 -0
  28. package/dist/generated/prisma/models/DbFeedback.d.mts +361 -2
  29. package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -1
  30. package/dist/generated/prisma/models.d.mts +9 -2
  31. package/dist/index.d.mts +5 -2
  32. package/dist/index.mjs +3 -1
  33. package/dist/middleware/createAcMiddleware.d.mts.map +1 -1
  34. package/dist/middleware/createAcMiddleware.mjs +15 -1
  35. package/dist/middleware/createAcMiddleware.mjs.map +1 -1
  36. package/dist/middleware/database.d.mts +1 -1
  37. package/dist/middleware/types.d.mts +20 -1
  38. package/dist/middleware/types.d.mts.map +1 -1
  39. package/dist/modules/communityTools/capture.d.mts +27 -0
  40. package/dist/modules/communityTools/capture.d.mts.map +1 -0
  41. package/dist/modules/communityTools/capture.mjs +192 -0
  42. package/dist/modules/communityTools/capture.mjs.map +1 -0
  43. package/dist/modules/communityTools/healthJob.mjs +132 -0
  44. package/dist/modules/communityTools/healthJob.mjs.map +1 -0
  45. package/dist/modules/communityTools/router.d.mts +150 -0
  46. package/dist/modules/communityTools/router.d.mts.map +1 -0
  47. package/dist/modules/communityTools/router.mjs +91 -0
  48. package/dist/modules/communityTools/router.mjs.map +1 -0
  49. package/dist/modules/communityTools/service.mjs +252 -0
  50. package/dist/modules/communityTools/service.mjs.map +1 -0
  51. package/dist/modules/communityTools/toView.mjs +81 -0
  52. package/dist/modules/communityTools/toView.mjs.map +1 -0
  53. package/dist/modules/communityTools/writeService.d.mts +55 -0
  54. package/dist/modules/communityTools/writeService.d.mts.map +1 -0
  55. package/dist/modules/communityTools/writeService.mjs +280 -0
  56. package/dist/modules/communityTools/writeService.mjs.map +1 -0
  57. package/dist/modules/feedback/feedbackRouter.d.mts +3 -0
  58. package/dist/modules/feedback/feedbackRouter.mjs +6 -2
  59. package/dist/modules/feedback/feedbackRouter.mjs.map +1 -1
  60. package/dist/modules/feedback/service.d.mts +3 -0
  61. package/dist/modules/feedback/service.d.mts.map +1 -1
  62. package/dist/modules/feedback/service.mjs +50 -17
  63. package/dist/modules/feedback/service.mjs.map +1 -1
  64. package/dist/server/controller.d.mts +143 -0
  65. package/dist/server/controller.d.mts.map +1 -1
  66. package/dist/server/controller.mjs +2 -0
  67. package/dist/server/controller.mjs.map +1 -1
  68. package/dist/server/trpcSetup.mjs +1 -1
  69. package/package.json +12 -5
  70. package/prisma/schema.prisma +161 -0
  71. package/src/__tests__/communityTools.test.ts +781 -0
  72. package/src/__tests__/communityToolsCapture.test.ts +319 -0
  73. package/src/__tests__/communityToolsHealth.test.ts +154 -0
  74. package/src/__tests__/communityToolsRouter.test.ts +58 -0
  75. package/src/__tests__/communityToolsWrite.test.ts +561 -0
  76. package/src/__tests__/feedbackService.test.ts +160 -0
  77. package/src/generated/prisma/browser.ts +35 -0
  78. package/src/generated/prisma/client.ts +35 -0
  79. package/src/generated/prisma/commonInputTypes.ts +34 -0
  80. package/src/generated/prisma/enums.ts +8 -0
  81. package/src/generated/prisma/internal/class.ts +74 -4
  82. package/src/generated/prisma/internal/prismaNamespace.ts +635 -2
  83. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +95 -1
  84. package/src/generated/prisma/models/DbCommunityTool.ts +3135 -0
  85. package/src/generated/prisma/models/DbCommunityToolLike.ts +1313 -0
  86. package/src/generated/prisma/models/DbCommunityToolNamedOwner.ts +1243 -0
  87. package/src/generated/prisma/models/DbCommunityToolOwner.ts +1243 -0
  88. package/src/generated/prisma/models/DbCommunityToolPin.ts +1325 -0
  89. package/src/generated/prisma/models/DbCommunityToolTag.ts +1283 -0
  90. package/src/generated/prisma/models/DbCommunityToolView.ts +1361 -0
  91. package/src/generated/prisma/models/DbFeedback.ts +379 -1
  92. package/src/generated/prisma/models.ts +7 -0
  93. package/src/index.ts +16 -0
  94. package/src/middleware/createAcMiddleware.ts +20 -0
  95. package/src/middleware/types.ts +21 -0
  96. package/src/modules/communityTools/capture.ts +332 -0
  97. package/src/modules/communityTools/healthJob.ts +191 -0
  98. package/src/modules/communityTools/router.ts +172 -0
  99. package/src/modules/communityTools/service.ts +380 -0
  100. package/src/modules/communityTools/toView.ts +112 -0
  101. package/src/modules/communityTools/writeService.ts +408 -0
  102. package/src/modules/feedback/feedbackRouter.ts +20 -6
  103. package/src/modules/feedback/service.ts +68 -12
  104. package/src/server/controller.ts +8 -1
@@ -0,0 +1,280 @@
1
+ import { getDbClient } from "../../db/client.mjs";
2
+ import { MARKER_KIND } from "../feedback/service.mjs";
3
+ import { computeCanEdit, getTool } from "./service.mjs";
4
+ import { resizeImage } from "../assets/assetUtils.mjs";
5
+ import { checkTool } from "./healthJob.mjs";
6
+ import { TRPCError } from "@trpc/server";
7
+ import { z } from "zod";
8
+ import { TAG_VALUE_MAX, hostOf, isCaptureAllowed, normalizeTags } from "@igstack/app-catalog-shared-core";
9
+
10
+ //#region src/modules/communityTools/writeService.ts
11
+ const toolInputSchema = z.object({
12
+ url: z.string().max(2e3),
13
+ name: z.string().trim().min(1).max(80),
14
+ summary: z.string().trim().max(160),
15
+ descriptionMd: z.string().max(5e3),
16
+ ownerSlugs: z.array(z.string()).max(5),
17
+ ownerNames: z.array(z.string().trim().min(1).max(80)).max(5).transform((names) => Array.from(new Set(names))).optional(),
18
+ tags: z.array(z.object({
19
+ kind: z.enum(["app", "topic"]),
20
+ value: z.string()
21
+ })).transform(normalizeTags).pipe(z.array(z.object({
22
+ kind: z.enum(["app", "topic"]),
23
+ value: z.string().min(1).max(TAG_VALUE_MAX)
24
+ })).max(8))
25
+ });
26
+ const MAX_OWNERS = 5;
27
+ const PREVIEW_WIDTH = 640;
28
+ /** Previews are someone waiting at the form; past this many queued, ask them to retry. */
29
+ const MAX_WAITING_CAPTURES = 3;
30
+ function requireHost(url) {
31
+ const host = hostOf(url);
32
+ if (!host) throw new TRPCError({
33
+ code: "BAD_REQUEST",
34
+ message: "The address must start with http:// or https://"
35
+ });
36
+ return host;
37
+ }
38
+ async function requireTool(slug) {
39
+ const tool = await getDbClient().dbCommunityTool.findUnique({
40
+ where: { slug },
41
+ include: {
42
+ owners: true,
43
+ namedOwners: true,
44
+ tags: true
45
+ }
46
+ });
47
+ if (!tool) throw new TRPCError({
48
+ code: "NOT_FOUND",
49
+ message: "Tool not found"
50
+ });
51
+ return tool;
52
+ }
53
+ async function requireEditor(tool, writer) {
54
+ if (!await computeCanEdit(tool, writer.user, writer.isAdmin)) throw new TRPCError({
55
+ code: "FORBIDDEN",
56
+ message: "Only an owner or an admin can change this tool"
57
+ });
58
+ }
59
+ /**
60
+ * The owners as stored: directory people, who must exist, and names of people
61
+ * outside it. One owner at least, of either kind, and five in all.
62
+ */
63
+ async function requireOwners(input, keptNames = []) {
64
+ const unique = Array.from(new Set(input.ownerSlugs));
65
+ const ownerNames = input.ownerNames ?? keptNames;
66
+ const count = unique.length + ownerNames.length;
67
+ if (count < 1 || count > MAX_OWNERS) throw new TRPCError({
68
+ code: "BAD_REQUEST",
69
+ message: `A tool has 1 to ${MAX_OWNERS} owners`
70
+ });
71
+ if ((await getDbClient().dbPerson.findMany({
72
+ where: { slug: { in: unique } },
73
+ select: { slug: true }
74
+ })).length !== unique.length) throw new TRPCError({
75
+ code: "BAD_REQUEST",
76
+ message: "Every owner must be in the person directory"
77
+ });
78
+ return {
79
+ ownerSlugs: unique,
80
+ ownerNames
81
+ };
82
+ }
83
+ function slugify(name) {
84
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tool";
85
+ }
86
+ async function freeSlug(name) {
87
+ const base = slugify(name);
88
+ const db = getDbClient();
89
+ for (let n = 1;; n++) {
90
+ const slug = n === 1 ? base : `${base}-${n}`;
91
+ if (!await db.dbCommunityTool.findUnique({ where: { slug } })) return slug;
92
+ }
93
+ }
94
+ /** A review row: what puts the change in the maintainer's existing comment queue. */
95
+ async function writeMarker(toolId, body, writer, status) {
96
+ await getDbClient().dbFeedback.create({ data: {
97
+ communityToolId: toolId,
98
+ kind: MARKER_KIND,
99
+ body,
100
+ status,
101
+ authorHash: writer.actor.hash,
102
+ authorAlias: writer.actor.alias,
103
+ authorUserId: writer.user.id,
104
+ authorName: writer.user.name
105
+ } });
106
+ }
107
+ /** Capture in the background: publishing must not wait on a 20-second page load. */
108
+ function captureLater(tool, deps, now) {
109
+ checkTool(tool, deps, now).catch((error) => {
110
+ console.error(`[communityTools] capture of ${tool.slug}:`, error);
111
+ });
112
+ }
113
+ /** The top-level catalog app whose address is on this host, if any. */
114
+ async function findOfficialApp(host) {
115
+ const app = (await getDbClient().dbResource.findMany({
116
+ where: {
117
+ parentSlug: null,
118
+ appUrl: { not: null }
119
+ },
120
+ select: {
121
+ slug: true,
122
+ displayName: true,
123
+ appUrl: true
124
+ }
125
+ })).find((a) => a.appUrl && hostOf(a.appUrl) === host);
126
+ return app ? {
127
+ slug: app.slug,
128
+ name: app.displayName
129
+ } : null;
130
+ }
131
+ async function previewTool(url, deps) {
132
+ const host = requireHost(url);
133
+ const captureAllowed = isCaptureAllowed(url, deps.captureHosts);
134
+ if (captureAllowed && (deps.capturer?.waiting?.() ?? 0) > MAX_WAITING_CAPTURES) throw new TRPCError({
135
+ code: "TOO_MANY_REQUESTS",
136
+ message: "Too many previews in progress; try again in a minute"
137
+ });
138
+ const duplicate = await getDbClient().dbCommunityTool.findFirst({
139
+ where: { host },
140
+ select: {
141
+ slug: true,
142
+ name: true
143
+ }
144
+ });
145
+ const officialApp = await findOfficialApp(host);
146
+ let title = null;
147
+ let screenshotDataUrl = null;
148
+ if (captureAllowed && deps.capturer) try {
149
+ const result = await deps.capturer.capture(url, deps.captureHosts);
150
+ if (result.ok) {
151
+ title = result.title;
152
+ screenshotDataUrl = `data:image/jpeg;base64,${(await resizeImage(result.png, PREVIEW_WIDTH, void 0, "jpeg")).toString("base64")}`;
153
+ }
154
+ } catch (error) {
155
+ console.error("[communityTools] preview capture failed:", error);
156
+ }
157
+ return {
158
+ host,
159
+ title,
160
+ screenshotDataUrl,
161
+ duplicate,
162
+ officialApp,
163
+ captureAllowed
164
+ };
165
+ }
166
+ async function addTool(input, writer, deps, now) {
167
+ const host = requireHost(input.url);
168
+ const { ownerSlugs, ownerNames } = await requireOwners(input);
169
+ const slug = await freeSlug(input.name);
170
+ const created = await getDbClient().dbCommunityTool.create({ data: {
171
+ slug,
172
+ name: input.name,
173
+ url: input.url,
174
+ host,
175
+ summary: input.summary,
176
+ descriptionMd: input.descriptionMd,
177
+ addedByUserId: writer.user.id,
178
+ addedByName: writer.user.name,
179
+ owners: { create: ownerSlugs.map((personSlug) => ({ personSlug })) },
180
+ namedOwners: { create: ownerNames.map((name) => ({ name })) },
181
+ tags: { create: input.tags }
182
+ } });
183
+ await writeMarker(created.id, `New tool added: ${input.name}`, writer, null);
184
+ captureLater(created, deps, now);
185
+ return await getTool(created.slug, writer.user, writer.isAdmin, now, deps);
186
+ }
187
+ async function editTool(input, writer, deps, now) {
188
+ const tool = await requireTool(input.slug);
189
+ await requireEditor(tool, writer);
190
+ const host = requireHost(input.url);
191
+ const { ownerSlugs, ownerNames } = await requireOwners(input, tool.namedOwners.map((o) => o.name));
192
+ const sameSet = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
193
+ const tagKey = (t) => `${t.kind}:${t.value}`;
194
+ const changed = [
195
+ tool.name !== input.name && "name",
196
+ tool.url !== input.url && "url",
197
+ tool.summary !== input.summary && "summary",
198
+ tool.descriptionMd !== input.descriptionMd && "description",
199
+ !(sameSet(tool.owners.map((o) => o.personSlug), ownerSlugs) && sameSet(tool.namedOwners.map((o) => o.name), ownerNames)) && "owners",
200
+ !sameSet(tool.tags.map(tagKey), input.tags.map(tagKey)) && "tags"
201
+ ].filter((f) => !!f);
202
+ if (changed.length > 0) {
203
+ const newSite = host !== tool.host && {
204
+ failingSince: null,
205
+ lastCheckAt: null,
206
+ ...tool.archivedBy === "auto" && {
207
+ archivedAt: null,
208
+ archivedBy: null,
209
+ archivedReason: null
210
+ }
211
+ };
212
+ const updated = await getDbClient().dbCommunityTool.update({
213
+ where: { id: tool.id },
214
+ data: {
215
+ reviewedAt: null,
216
+ reviewedByName: null,
217
+ ...newSite,
218
+ name: input.name,
219
+ url: input.url,
220
+ host,
221
+ summary: input.summary,
222
+ descriptionMd: input.descriptionMd,
223
+ owners: {
224
+ deleteMany: {},
225
+ create: ownerSlugs.map((personSlug) => ({ personSlug }))
226
+ },
227
+ namedOwners: {
228
+ deleteMany: {},
229
+ create: ownerNames.map((name) => ({ name }))
230
+ },
231
+ tags: {
232
+ deleteMany: {},
233
+ create: input.tags
234
+ }
235
+ }
236
+ });
237
+ await writeMarker(tool.id, `Tool edited: ${changed.join(", ")}`, writer, null);
238
+ if (changed.includes("url")) captureLater(updated, deps, now);
239
+ }
240
+ return await getTool(tool.slug, writer.user, writer.isAdmin, now, deps);
241
+ }
242
+ async function archiveTool(slug, reason, writer, now) {
243
+ const tool = await requireTool(slug);
244
+ await requireEditor(tool, writer);
245
+ await getDbClient().dbCommunityTool.update({
246
+ where: { id: tool.id },
247
+ data: {
248
+ archivedAt: now,
249
+ archivedBy: writer.user.name,
250
+ archivedReason: reason
251
+ }
252
+ });
253
+ await writeMarker(tool.id, `Tool archived: ${reason}`, writer, writer.isAdmin ? "acknowledged" : null);
254
+ }
255
+ async function approveTool(slug, writer, now) {
256
+ const tool = await requireTool(slug);
257
+ const db = getDbClient();
258
+ await db.dbCommunityTool.update({
259
+ where: { id: tool.id },
260
+ data: {
261
+ reviewedAt: now,
262
+ reviewedByName: writer.user.name
263
+ }
264
+ });
265
+ await db.dbFeedback.updateMany({
266
+ where: {
267
+ communityToolId: tool.id,
268
+ status: null
269
+ },
270
+ data: {
271
+ status: "applied",
272
+ reviewedAt: now
273
+ }
274
+ });
275
+ await writeMarker(tool.id, "Tool approved", writer, "applied");
276
+ }
277
+
278
+ //#endregion
279
+ export { addTool, approveTool, archiveTool, editTool, previewTool, toolInputSchema };
280
+ //# sourceMappingURL=writeService.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"writeService.mjs","names":[],"sources":["../../../src/modules/communityTools/writeService.ts"],"sourcesContent":["import { TRPCError } from '@trpc/server'\nimport type { User } from 'better-auth/types'\nimport { z } from 'zod'\nimport type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport {\n TAG_VALUE_MAX,\n hostOf,\n isCaptureAllowed,\n normalizeTags,\n} from '@igstack/app-catalog-shared-core'\nimport { getDbClient } from '../../db/client'\nimport { resizeImage } from '../assets/assetUtils'\nimport { MARKER_KIND } from '../feedback/service'\nimport type { Actor } from '../visitor/visitorIdentity'\nimport { checkTool } from './healthJob'\nimport type { CaptureDeps } from './healthJob'\nimport { computeCanEdit, getTool } from './service'\n\nexport const toolInputSchema = z.object({\n url: z.string().max(2000),\n name: z.string().trim().min(1).max(80),\n summary: z.string().trim().max(160),\n descriptionMd: z.string().max(5000),\n ownerSlugs: z.array(z.string()).max(5),\n /**\n * Owners outside the person directory, by name. Left out of an edit, the\n * tool keeps the ones it has; only a list replaces them.\n */\n ownerNames: z\n .array(z.string().trim().min(1).max(80))\n .max(5)\n .transform((names) => Array.from(new Set(names)))\n .optional(),\n // Topics are stored the way the next person will type them, each tag once;\n // the caps hold for what is stored.\n tags: z\n .array(z.object({ kind: z.enum(['app', 'topic']), value: z.string() }))\n .transform(normalizeTags)\n .pipe(\n z\n .array(\n z.object({\n kind: z.enum(['app', 'topic']),\n value: z.string().min(1).max(TAG_VALUE_MAX),\n }),\n )\n .max(8),\n ),\n})\nexport type ToolInput = z.output<typeof toolInputSchema>\n\nconst MAX_OWNERS = 5\n\n/** The signed-in person making the change. */\nexport interface Writer {\n user: User\n isAdmin: boolean\n actor: Actor\n}\n\nexport interface ToolPreview {\n host: string\n title: string | null\n screenshotDataUrl: string | null\n duplicate: { slug: string; name: string } | null\n /** An official app on the same host: the tool would duplicate a catalog entry. */\n officialApp: { slug: string; name: string } | null\n captureAllowed: boolean\n}\n\nconst PREVIEW_WIDTH = 640\n/** Previews are someone waiting at the form; past this many queued, ask them to retry. */\nconst MAX_WAITING_CAPTURES = 3\n\nfunction requireHost(url: string): string {\n const host = hostOf(url)\n if (!host) {\n throw new TRPCError({\n code: 'BAD_REQUEST',\n message: 'The address must start with http:// or https://',\n })\n }\n return host\n}\n\nasync function requireTool(slug: string) {\n const tool = await getDbClient().dbCommunityTool.findUnique({\n where: { slug },\n include: { owners: true, namedOwners: true, tags: true },\n })\n if (!tool) {\n throw new TRPCError({ code: 'NOT_FOUND', message: 'Tool not found' })\n }\n return tool\n}\n\nasync function requireEditor(\n tool: { id: string; addedByUserId: string },\n writer: Writer,\n) {\n if (!(await computeCanEdit(tool, writer.user, writer.isAdmin))) {\n throw new TRPCError({\n code: 'FORBIDDEN',\n message: 'Only an owner or an admin can change this tool',\n })\n }\n}\n\n/**\n * The owners as stored: directory people, who must exist, and names of people\n * outside it. One owner at least, of either kind, and five in all.\n */\nasync function requireOwners(input: ToolInput, keptNames: string[] = []) {\n const unique = Array.from(new Set(input.ownerSlugs))\n const ownerNames = input.ownerNames ?? keptNames\n const count = unique.length + ownerNames.length\n if (count < 1 || count > MAX_OWNERS) {\n throw new TRPCError({\n code: 'BAD_REQUEST',\n message: `A tool has 1 to ${MAX_OWNERS} owners`,\n })\n }\n const found = await getDbClient().dbPerson.findMany({\n where: { slug: { in: unique } },\n select: { slug: true },\n })\n if (found.length !== unique.length) {\n throw new TRPCError({\n code: 'BAD_REQUEST',\n message: 'Every owner must be in the person directory',\n })\n }\n return { ownerSlugs: unique, ownerNames }\n}\n\nfunction slugify(name: string): string {\n return (\n name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'tool'\n )\n}\n\nasync function freeSlug(name: string): Promise<string> {\n const base = slugify(name)\n const db = getDbClient()\n for (let n = 1; ; n++) {\n const slug = n === 1 ? base : `${base}-${n}`\n if (!(await db.dbCommunityTool.findUnique({ where: { slug } }))) return slug\n }\n}\n\n/** A review row: what puts the change in the maintainer's existing comment queue. */\nasync function writeMarker(\n toolId: string,\n body: string,\n writer: Writer,\n status: 'applied' | 'acknowledged' | null,\n) {\n await getDbClient().dbFeedback.create({\n data: {\n communityToolId: toolId,\n kind: MARKER_KIND,\n body,\n status,\n authorHash: writer.actor.hash,\n authorAlias: writer.actor.alias,\n authorUserId: writer.user.id,\n authorName: writer.user.name,\n },\n })\n}\n\n/** Capture in the background: publishing must not wait on a 20-second page load. */\nfunction captureLater(\n tool: Parameters<typeof checkTool>[0],\n deps: CaptureDeps,\n now: Date,\n) {\n checkTool(tool, deps, now).catch((error: unknown) => {\n console.error(`[communityTools] capture of ${tool.slug}:`, error)\n })\n}\n\n/** The top-level catalog app whose address is on this host, if any. */\nasync function findOfficialApp(\n host: string,\n): Promise<{ slug: string; name: string } | null> {\n // ponytail: scans every top-level app; a stored host column if the catalog grows past thousands.\n const apps = await getDbClient().dbResource.findMany({\n where: { parentSlug: null, appUrl: { not: null } },\n select: { slug: true, displayName: true, appUrl: true },\n })\n const app = apps.find((a) => a.appUrl && hostOf(a.appUrl) === host)\n return app ? { slug: app.slug, name: app.displayName } : null\n}\n\nexport async function previewTool(\n url: string,\n deps: CaptureDeps,\n): Promise<ToolPreview> {\n const host = requireHost(url)\n const captureAllowed = isCaptureAllowed(url, deps.captureHosts)\n if (\n captureAllowed &&\n (deps.capturer?.waiting?.() ?? 0) > MAX_WAITING_CAPTURES\n ) {\n throw new TRPCError({\n code: 'TOO_MANY_REQUESTS',\n message: 'Too many previews in progress; try again in a minute',\n })\n }\n\n const duplicate = await getDbClient().dbCommunityTool.findFirst({\n where: { host },\n select: { slug: true, name: true },\n })\n const officialApp = await findOfficialApp(host)\n\n let title: string | null = null\n let screenshotDataUrl: string | null = null\n if (captureAllowed && deps.capturer) {\n try {\n const result = await deps.capturer.capture(url, deps.captureHosts)\n if (result.ok) {\n title = result.title\n const jpeg = await resizeImage(\n result.png,\n PREVIEW_WIDTH,\n undefined,\n 'jpeg',\n )\n screenshotDataUrl = `data:image/jpeg;base64,${jpeg.toString('base64')}`\n }\n } catch (error) {\n console.error('[communityTools] preview capture failed:', error)\n }\n }\n\n return {\n host,\n title,\n screenshotDataUrl,\n duplicate,\n officialApp,\n captureAllowed,\n }\n}\n\nexport async function addTool(\n input: ToolInput,\n writer: Writer,\n deps: CaptureDeps,\n now: Date,\n): Promise<CommunityTool> {\n const host = requireHost(input.url)\n const { ownerSlugs, ownerNames } = await requireOwners(input)\n const slug = await freeSlug(input.name)\n\n const created = await getDbClient().dbCommunityTool.create({\n data: {\n slug,\n name: input.name,\n url: input.url,\n host,\n summary: input.summary,\n descriptionMd: input.descriptionMd,\n addedByUserId: writer.user.id,\n addedByName: writer.user.name,\n owners: { create: ownerSlugs.map((personSlug) => ({ personSlug })) },\n namedOwners: { create: ownerNames.map((name) => ({ name })) },\n tags: { create: input.tags },\n },\n })\n await writeMarker(created.id, `New tool added: ${input.name}`, writer, null)\n captureLater(created, deps, now)\n\n return (await getTool(created.slug, writer.user, writer.isAdmin, now, deps))!\n}\n\nexport async function editTool(\n input: ToolInput & { slug: string },\n writer: Writer,\n deps: CaptureDeps,\n now: Date,\n): Promise<CommunityTool> {\n const tool = await requireTool(input.slug)\n await requireEditor(tool, writer)\n const host = requireHost(input.url)\n const { ownerSlugs, ownerNames } = await requireOwners(\n input,\n tool.namedOwners.map((o) => o.name),\n )\n\n const sameSet = (a: string[], b: string[]) =>\n a.length === b.length && a.every((x) => b.includes(x))\n const tagKey = (t: { kind: string; value: string }) => `${t.kind}:${t.value}`\n const changed = [\n tool.name !== input.name && 'name',\n tool.url !== input.url && 'url',\n tool.summary !== input.summary && 'summary',\n tool.descriptionMd !== input.descriptionMd && 'description',\n !(\n sameSet(\n tool.owners.map((o) => o.personSlug),\n ownerSlugs,\n ) &&\n sameSet(\n tool.namedOwners.map((o) => o.name),\n ownerNames,\n )\n ) && 'owners',\n !sameSet(tool.tags.map(tagKey), input.tags.map(tagKey)) && 'tags',\n ].filter((f): f is string => !!f)\n\n if (changed.length > 0) {\n // A different site has no health history yet, and a dead old site's\n // automatic archive says nothing about the new one.\n const newSite = host !== tool.host && {\n failingSince: null,\n lastCheckAt: null,\n ...(tool.archivedBy === 'auto' && {\n archivedAt: null,\n archivedBy: null,\n archivedReason: null,\n }),\n }\n const updated = await getDbClient().dbCommunityTool.update({\n where: { id: tool.id },\n data: {\n // An edit is news to the maintainer, so the tool waits for approval again.\n reviewedAt: null,\n reviewedByName: null,\n ...newSite,\n name: input.name,\n url: input.url,\n host,\n summary: input.summary,\n descriptionMd: input.descriptionMd,\n owners: {\n deleteMany: {},\n create: ownerSlugs.map((personSlug) => ({ personSlug })),\n },\n namedOwners: {\n deleteMany: {},\n create: ownerNames.map((name) => ({ name })),\n },\n tags: { deleteMany: {}, create: input.tags },\n },\n })\n await writeMarker(\n tool.id,\n `Tool edited: ${changed.join(', ')}`,\n writer,\n null,\n )\n if (changed.includes('url')) captureLater(updated, deps, now)\n }\n\n // The slug stays: pins, links and bookmarks point at it.\n return (await getTool(tool.slug, writer.user, writer.isAdmin, now, deps))!\n}\n\nexport async function archiveTool(\n slug: string,\n reason: string,\n writer: Writer,\n now: Date,\n): Promise<void> {\n const tool = await requireTool(slug)\n await requireEditor(tool, writer)\n\n await getDbClient().dbCommunityTool.update({\n where: { id: tool.id },\n data: {\n archivedAt: now,\n archivedBy: writer.user.name,\n archivedReason: reason,\n },\n })\n // An owner's archive is news to the maintainer; an admin's is their own decision.\n await writeMarker(\n tool.id,\n `Tool archived: ${reason}`,\n writer,\n writer.isAdmin ? 'acknowledged' : null,\n )\n}\n\nexport async function approveTool(\n slug: string,\n writer: Writer,\n now: Date,\n): Promise<void> {\n const tool = await requireTool(slug)\n const db = getDbClient()\n\n await db.dbCommunityTool.update({\n where: { id: tool.id },\n data: { reviewedAt: now, reviewedByName: writer.user.name },\n })\n await db.dbFeedback.updateMany({\n where: { communityToolId: tool.id, status: null },\n data: { status: 'applied', reviewedAt: now },\n })\n await writeMarker(tool.id, 'Tool approved', writer, 'applied')\n}\n"],"mappings":"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"}
@@ -12,6 +12,8 @@ declare function createFeedbackRouter(): _trpc_server0.TRPCBuiltRouter<{
12
12
  /** One entry's thread, plus its unreviewed count so the badge needs no second call. */list: _trpc_server0.TRPCQueryProcedure<{
13
13
  input: {
14
14
  resourceSlug: string;
15
+ } | {
16
+ communityToolSlug: string;
15
17
  };
16
18
  output: FeedbackList;
17
19
  meta: object;
@@ -24,6 +26,7 @@ declare function createFeedbackRouter(): _trpc_server0.TRPCBuiltRouter<{
24
26
  add: _trpc_server0.TRPCMutationProcedure<{
25
27
  input: {
26
28
  resourceSlug?: string | undefined;
29
+ communityToolSlug?: string | undefined;
27
30
  subject?: string | undefined;
28
31
  body?: string | undefined;
29
32
  attachmentIds?: string[] | undefined;
@@ -38,14 +38,18 @@ async function mapErrors(work) {
38
38
  }
39
39
  function createFeedbackRouter() {
40
40
  return router({
41
- list: publicProcedure.input(z.object({ resourceSlug: z.string() })).query(({ input, ctx }) => listFeedback(input.resourceSlug, ctx.actor)),
41
+ list: publicProcedure.input(z.union([z.object({ resourceSlug: z.string() }), z.object({ communityToolSlug: z.string() })])).query(({ input, ctx }) => listFeedback(input, ctx.actor)),
42
42
  mine: publicProcedure.query(({ ctx }) => listMyFeedback(ctx.actor)),
43
43
  add: publicProcedure.input(z.object({
44
44
  resourceSlug: z.string().optional(),
45
+ communityToolSlug: z.string().optional(),
45
46
  subject: z.string().max(120).optional(),
46
47
  body: z.string().max(MAX_BODY_LENGTH).optional(),
47
48
  attachmentIds: z.array(z.string()).max(3).optional()
48
- }).refine((v) => !!v.resourceSlug || !!v.subject?.trim(), { message: "Needs either an entry or a subject" })).mutation(({ input, ctx }) => mapErrors(() => addFeedback(input, requireActor(ctx.actor)))),
49
+ }).refine((v) => !!v.resourceSlug || !!v.communityToolSlug || !!v.subject?.trim(), { message: "Needs either an entry or a subject" })).mutation(({ input, ctx }) => mapErrors(() => addFeedback(input, requireActor(ctx.actor), ctx.user ? {
50
+ id: ctx.user.id,
51
+ name: ctx.user.name
52
+ } : null))),
49
53
  edit: publicProcedure.input(z.object({
50
54
  id: z.string(),
51
55
  body: z.string().max(MAX_BODY_LENGTH)
@@ -1 +1 @@
1
- {"version":3,"file":"feedbackRouter.mjs","names":[],"sources":["../../../src/modules/feedback/feedbackRouter.ts"],"sourcesContent":["import { TRPCError } from '@trpc/server'\nimport { z } from 'zod'\nimport { publicProcedure, router } from '../../server/trpcSetup'\nimport type { Actor } from '../visitor/visitorIdentity'\nimport {\n FeedbackError,\n MAX_ATTACHMENTS,\n MAX_BODY_LENGTH,\n MAX_SUBJECT_LENGTH,\n addFeedback,\n dismissFeedback,\n editFeedback,\n listFeedback,\n listMyFeedback,\n} from './service'\n\n/**\n * Writing needs a visitor token, and the token is issued on the request that reads the\n * list — so by the time anyone can click Send, the cookie exists. A missing one here\n * means cookies are blocked, which no error code describes well; PRECONDITION_FAILED at\n * least separates it from a permission problem.\n */\nfunction requireActor(actor: Actor | null): Actor {\n if (!actor) {\n throw new TRPCError({\n code: 'PRECONDITION_FAILED',\n message: 'Sending this needs cookies enabled',\n })\n }\n return actor\n}\n\nconst REASON_CODES = {\n 'not-found': 'NOT_FOUND',\n 'not-author': 'FORBIDDEN',\n 'window-closed': 'FORBIDDEN',\n 'already-reviewed': 'FORBIDDEN',\n empty: 'BAD_REQUEST',\n 'too-many-attachments': 'BAD_REQUEST',\n} as const\n\nasync function mapErrors<T>(work: () => Promise<T>): Promise<T> {\n try {\n return await work()\n } catch (error) {\n if (error instanceof FeedbackError) {\n throw new TRPCError({\n code: REASON_CODES[error.reason],\n message: error.message,\n })\n }\n throw error\n }\n}\n\nexport function createFeedbackRouter() {\n return router({\n /** One entry's thread, plus its unreviewed count so the badge needs no second call. */\n list: publicProcedure\n .input(z.object({ resourceSlug: z.string() }))\n .query(({ input, ctx }) => listFeedback(input.resourceSlug, ctx.actor)),\n\n /** Everything this browser filed, for the tracker. Empty means render no button. */\n mine: publicProcedure.query(({ ctx }) => listMyFeedback(ctx.actor)),\n\n add: publicProcedure\n .input(\n z\n .object({\n resourceSlug: z.string().optional(),\n subject: z.string().max(MAX_SUBJECT_LENGTH).optional(),\n // Both optional: a note can be words, screenshots or both, and a\n // catalog-level ask may carry only its subject. The entry's input asks\n // for words or an image, but the API still stores an empty note as a\n // bare flag, which is what older rows are.\n body: z.string().max(MAX_BODY_LENGTH).optional(),\n attachmentIds: z.array(z.string()).max(MAX_ATTACHMENTS).optional(),\n })\n .refine((v) => !!v.resourceSlug || !!v.subject?.trim(), {\n message: 'Needs either an entry or a subject',\n }),\n )\n .mutation(({ input, ctx }) =>\n mapErrors(() => addFeedback(input, requireActor(ctx.actor))),\n ),\n\n edit: publicProcedure\n .input(\n z.object({ id: z.string(), body: z.string().max(MAX_BODY_LENGTH) }),\n )\n .mutation(({ input, ctx }) =>\n mapErrors(() =>\n editFeedback(input.id, input.body, requireActor(ctx.actor)),\n ),\n ),\n\n /** Withdraw your own, for as long as nobody has reviewed it. */\n dismiss: publicProcedure\n .input(z.object({ id: z.string() }))\n .mutation(({ input, ctx }) =>\n mapErrors(() => dismissFeedback(input.id, requireActor(ctx.actor))),\n ),\n })\n}\n"],"mappings":";;;;;;;;;;;;AAsBA,SAAS,aAAa,OAA4B;AAChD,KAAI,CAAC,MACH,OAAM,IAAI,UAAU;EAClB,MAAM;EACN,SAAS;EACV,CAAC;AAEJ,QAAO;;AAGT,MAAM,eAAe;CACnB,aAAa;CACb,cAAc;CACd,iBAAiB;CACjB,oBAAoB;CACpB,OAAO;CACP,wBAAwB;CACzB;AAED,eAAe,UAAa,MAAoC;AAC9D,KAAI;AACF,SAAO,MAAM,MAAM;UACZ,OAAO;AACd,MAAI,iBAAiB,cACnB,OAAM,IAAI,UAAU;GAClB,MAAM,aAAa,MAAM;GACzB,SAAS,MAAM;GAChB,CAAC;AAEJ,QAAM;;;AAIV,SAAgB,uBAAuB;AACrC,QAAO,OAAO;EAEZ,MAAM,gBACH,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,CAAC,CAAC,CAC7C,OAAO,EAAE,OAAO,UAAU,aAAa,MAAM,cAAc,IAAI,MAAM,CAAC;EAGzE,MAAM,gBAAgB,OAAO,EAAE,UAAU,eAAe,IAAI,MAAM,CAAC;EAEnE,KAAK,gBACF,MACC,EACG,OAAO;GACN,cAAc,EAAE,QAAQ,CAAC,UAAU;GACnC,SAAS,EAAE,QAAQ,CAAC,QAAuB,CAAC,UAAU;GAKtD,MAAM,EAAE,QAAQ,CAAC,IAAI,gBAAgB,CAAC,UAAU;GAChD,eAAe,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,MAAoB,CAAC,UAAU;GACnE,CAAC,CACD,QAAQ,MAAM,CAAC,CAAC,EAAE,gBAAgB,CAAC,CAAC,EAAE,SAAS,MAAM,EAAE,EACtD,SAAS,sCACV,CAAC,CACL,CACA,UAAU,EAAE,OAAO,UAClB,gBAAgB,YAAY,OAAO,aAAa,IAAI,MAAM,CAAC,CAAC,CAC7D;EAEH,MAAM,gBACH,MACC,EAAE,OAAO;GAAE,IAAI,EAAE,QAAQ;GAAE,MAAM,EAAE,QAAQ,CAAC,IAAI,gBAAgB;GAAE,CAAC,CACpE,CACA,UAAU,EAAE,OAAO,UAClB,gBACE,aAAa,MAAM,IAAI,MAAM,MAAM,aAAa,IAAI,MAAM,CAAC,CAC5D,CACF;EAGH,SAAS,gBACN,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,CACnC,UAAU,EAAE,OAAO,UAClB,gBAAgB,gBAAgB,MAAM,IAAI,aAAa,IAAI,MAAM,CAAC,CAAC,CACpE;EACJ,CAAC"}
1
+ {"version":3,"file":"feedbackRouter.mjs","names":[],"sources":["../../../src/modules/feedback/feedbackRouter.ts"],"sourcesContent":["import { TRPCError } from '@trpc/server'\nimport { z } from 'zod'\nimport { publicProcedure, router } from '../../server/trpcSetup'\nimport type { Actor } from '../visitor/visitorIdentity'\nimport {\n FeedbackError,\n MAX_ATTACHMENTS,\n MAX_BODY_LENGTH,\n MAX_SUBJECT_LENGTH,\n addFeedback,\n dismissFeedback,\n editFeedback,\n listFeedback,\n listMyFeedback,\n} from './service'\n\n/**\n * Writing needs a visitor token, and the token is issued on the request that reads the\n * list — so by the time anyone can click Send, the cookie exists. A missing one here\n * means cookies are blocked, which no error code describes well; PRECONDITION_FAILED at\n * least separates it from a permission problem.\n */\nfunction requireActor(actor: Actor | null): Actor {\n if (!actor) {\n throw new TRPCError({\n code: 'PRECONDITION_FAILED',\n message: 'Sending this needs cookies enabled',\n })\n }\n return actor\n}\n\nconst REASON_CODES = {\n 'not-found': 'NOT_FOUND',\n 'not-author': 'FORBIDDEN',\n 'window-closed': 'FORBIDDEN',\n 'already-reviewed': 'FORBIDDEN',\n empty: 'BAD_REQUEST',\n 'too-many-attachments': 'BAD_REQUEST',\n} as const\n\nasync function mapErrors<T>(work: () => Promise<T>): Promise<T> {\n try {\n return await work()\n } catch (error) {\n if (error instanceof FeedbackError) {\n throw new TRPCError({\n code: REASON_CODES[error.reason],\n message: error.message,\n })\n }\n throw error\n }\n}\n\nexport function createFeedbackRouter() {\n return router({\n /** One entry's thread, plus its unreviewed count so the badge needs no second call. */\n list: publicProcedure\n .input(\n z.union([\n z.object({ resourceSlug: z.string() }),\n z.object({ communityToolSlug: z.string() }),\n ]),\n )\n .query(({ input, ctx }) => listFeedback(input, ctx.actor)),\n\n /** Everything this browser filed, for the tracker. Empty means render no button. */\n mine: publicProcedure.query(({ ctx }) => listMyFeedback(ctx.actor)),\n\n add: publicProcedure\n .input(\n z\n .object({\n resourceSlug: z.string().optional(),\n communityToolSlug: z.string().optional(),\n subject: z.string().max(MAX_SUBJECT_LENGTH).optional(),\n // Both optional: a note can be words, screenshots or both, and a\n // catalog-level ask may carry only its subject. The entry's input asks\n // for words or an image, but the API still stores an empty note as a\n // bare flag, which is what older rows are.\n body: z.string().max(MAX_BODY_LENGTH).optional(),\n attachmentIds: z.array(z.string()).max(MAX_ATTACHMENTS).optional(),\n })\n .refine(\n (v) =>\n !!v.resourceSlug || !!v.communityToolSlug || !!v.subject?.trim(),\n { message: 'Needs either an entry or a subject' },\n ),\n )\n .mutation(({ input, ctx }) =>\n mapErrors(() =>\n addFeedback(\n input,\n requireActor(ctx.actor),\n ctx.user ? { id: ctx.user.id, name: ctx.user.name } : null,\n ),\n ),\n ),\n\n edit: publicProcedure\n .input(\n z.object({ id: z.string(), body: z.string().max(MAX_BODY_LENGTH) }),\n )\n .mutation(({ input, ctx }) =>\n mapErrors(() =>\n editFeedback(input.id, input.body, requireActor(ctx.actor)),\n ),\n ),\n\n /** Withdraw your own, for as long as nobody has reviewed it. */\n dismiss: publicProcedure\n .input(z.object({ id: z.string() }))\n .mutation(({ input, ctx }) =>\n mapErrors(() => dismissFeedback(input.id, requireActor(ctx.actor))),\n ),\n })\n}\n"],"mappings":";;;;;;;;;;;;AAsBA,SAAS,aAAa,OAA4B;AAChD,KAAI,CAAC,MACH,OAAM,IAAI,UAAU;EAClB,MAAM;EACN,SAAS;EACV,CAAC;AAEJ,QAAO;;AAGT,MAAM,eAAe;CACnB,aAAa;CACb,cAAc;CACd,iBAAiB;CACjB,oBAAoB;CACpB,OAAO;CACP,wBAAwB;CACzB;AAED,eAAe,UAAa,MAAoC;AAC9D,KAAI;AACF,SAAO,MAAM,MAAM;UACZ,OAAO;AACd,MAAI,iBAAiB,cACnB,OAAM,IAAI,UAAU;GAClB,MAAM,aAAa,MAAM;GACzB,SAAS,MAAM;GAChB,CAAC;AAEJ,QAAM;;;AAIV,SAAgB,uBAAuB;AACrC,QAAO,OAAO;EAEZ,MAAM,gBACH,MACC,EAAE,MAAM,CACN,EAAE,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,CAAC,EACtC,EAAE,OAAO,EAAE,mBAAmB,EAAE,QAAQ,EAAE,CAAC,CAC5C,CAAC,CACH,CACA,OAAO,EAAE,OAAO,UAAU,aAAa,OAAO,IAAI,MAAM,CAAC;EAG5D,MAAM,gBAAgB,OAAO,EAAE,UAAU,eAAe,IAAI,MAAM,CAAC;EAEnE,KAAK,gBACF,MACC,EACG,OAAO;GACN,cAAc,EAAE,QAAQ,CAAC,UAAU;GACnC,mBAAmB,EAAE,QAAQ,CAAC,UAAU;GACxC,SAAS,EAAE,QAAQ,CAAC,QAAuB,CAAC,UAAU;GAKtD,MAAM,EAAE,QAAQ,CAAC,IAAI,gBAAgB,CAAC,UAAU;GAChD,eAAe,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,MAAoB,CAAC,UAAU;GACnE,CAAC,CACD,QACE,MACC,CAAC,CAAC,EAAE,gBAAgB,CAAC,CAAC,EAAE,qBAAqB,CAAC,CAAC,EAAE,SAAS,MAAM,EAClE,EAAE,SAAS,sCAAsC,CAClD,CACJ,CACA,UAAU,EAAE,OAAO,UAClB,gBACE,YACE,OACA,aAAa,IAAI,MAAM,EACvB,IAAI,OAAO;GAAE,IAAI,IAAI,KAAK;GAAI,MAAM,IAAI,KAAK;GAAM,GAAG,KACvD,CACF,CACF;EAEH,MAAM,gBACH,MACC,EAAE,OAAO;GAAE,IAAI,EAAE,QAAQ;GAAE,MAAM,EAAE,QAAQ,CAAC,IAAI,gBAAgB;GAAE,CAAC,CACpE,CACA,UAAU,EAAE,OAAO,UAClB,gBACE,aAAa,MAAM,IAAI,MAAM,MAAM,aAAa,IAAI,MAAM,CAAC,CAC5D,CACF;EAGH,SAAS,gBACN,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,CACnC,UAAU,EAAE,OAAO,UAClB,gBAAgB,gBAAgB,MAAM,IAAI,aAAa,IAAI,MAAM,CAAC,CAAC,CACpE;EACJ,CAAC"}
@@ -7,6 +7,7 @@ declare const MAX_ATTACHMENTS = 3;
7
7
  /** One piece of feedback as a client sees it. Never carries an author hash. */
8
8
  interface FeedbackView {
9
9
  id: string;
10
+ /** The signed-in author's name, or the pseudonym of an anonymous one. */
10
11
  authorName: string;
11
12
  /** Null when the visitor sent a bare flag with no words. */
12
13
  body: string | null;
@@ -25,6 +26,8 @@ interface FeedbackView {
25
26
  editedAt: string | null;
26
27
  /** Whether this browser wrote it — what makes Edit and Dismiss appear. */
27
28
  isMine: boolean;
29
+ /** A line the system wrote when a community tool changed: nobody can edit or withdraw it. */
30
+ isSystem: boolean;
28
31
  /**
29
32
  * The absolute instant this browser's edit window closes, or null when it is not
30
33
  * theirs. Absolute rather than a remaining duration so a client with a skewed clock
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.mts","names":[],"sources":["../../../src/modules/feedback/service.ts"],"mappings":";AAIA;AAAA,cAAa,cAAA;AAAA,cAEA,eAAA;AAAA,cACA,kBAAA;AAAA,cACA,eAAA;AAFb;AAAA,UAKiB,YAAA;EACf,EAAA;EACA,UAAA;EAP0B;EAS1B,IAAA;EAR6B;EAU7B,OAAA;EAV6B;EAY7B,YAAA;EAXW;EAaX,YAAA;EACA,aAAA;EACA,MAAA;EACA,aAAA;EAbe;EAef,UAAA;EACA,SAAA;EACA,QAAA;EAhBA;EAkBA,MAAA;EAfA;;;;;EAqBA,YAAA;AAAA;AAAA,UAqDe,YAAA;EACf,KAAA,EAAO,YAAA;EA9DP;EAgEA,SAAA;AAAA;;AAkDF;;;;;iBAAgB,eAAA,CAAgB,IAAA"}
1
+ {"version":3,"file":"service.d.mts","names":[],"sources":["../../../src/modules/feedback/service.ts"],"mappings":";AAIA;AAAA,cAAa,cAAA;AAAA,cAEA,eAAA;AAAA,cACA,kBAAA;AAAA,cACA,eAAA;;UAMI,YAAA;EACf,EAAA;EARW;EAUX,UAAA;;EAEA,IAAA;EAZ6B;EAc7B,OAAA;EAb0B;EAe1B,YAAA;EAf0B;EAiB1B,YAAA;EACA,aAAA;EACA,MAAA;EACA,aAAA;EAd2B;EAgB3B,UAAA;EACA,SAAA;EACA,QAAA;EAXA;EAaA,MAAA;EATA;EAWA,QAAA;EATA;;;;;EAeA,YAAA;AAAA;AAAA,UAsEe,YAAA;EACf,KAAA,EAAO,YAAA;EAAA;EAEP,SAAA;AAAA;;;;;;;iBA4Dc,eAAA,CAAgB,IAAA"}
@@ -6,11 +6,14 @@ const EDIT_WINDOW_MS = 3600 * 1e3;
6
6
  const MAX_BODY_LENGTH = 1e3;
7
7
  const MAX_SUBJECT_LENGTH = 120;
8
8
  const MAX_ATTACHMENTS = 3;
9
+ /** `kind` of a review row a community tool write leaves; see `isSystem`. */
10
+ const MARKER_KIND = "marker";
9
11
  function toView(row, actor) {
10
- const isMine = !!actor && row.authorHash === actor.hash;
12
+ const isSystem = row.kind === MARKER_KIND;
13
+ const isMine = !isSystem && !!actor && row.authorHash === actor.hash;
11
14
  return {
12
15
  id: row.id,
13
- authorName: row.authorAlias,
16
+ authorName: row.authorName ?? row.authorAlias,
14
17
  body: row.body,
15
18
  subject: row.subject,
16
19
  resourceSlug: row.resource?.slug ?? null,
@@ -22,6 +25,7 @@ function toView(row, actor) {
22
25
  createdAt: row.createdAt.toISOString(),
23
26
  editedAt: row.editedAt?.toISOString() ?? null,
24
27
  isMine,
28
+ isSystem,
25
29
  canEditUntil: isMine ? new Date(row.createdAt.getTime() + EDIT_WINDOW_MS).toISOString() : null
26
30
  };
27
31
  }
@@ -38,15 +42,32 @@ async function resourceIdForSlug(slug) {
38
42
  select: { id: true }
39
43
  }))?.id ?? null;
40
44
  }
45
+ async function communityToolIdForSlug(slug) {
46
+ return (await getDbClient().dbCommunityTool.findUnique({
47
+ where: { slug },
48
+ select: { id: true }
49
+ }))?.id ?? null;
50
+ }
41
51
  /** Oldest first: a thread reads top to bottom. */
42
- async function listFeedback(resourceSlug, actor) {
43
- const resourceId = await resourceIdForSlug(resourceSlug);
44
- if (!resourceId) return {
45
- items: [],
46
- openCount: 0
47
- };
52
+ async function listFeedback(target, actor) {
53
+ let where;
54
+ if ("resourceSlug" in target) {
55
+ const resourceId = await resourceIdForSlug(target.resourceSlug);
56
+ if (!resourceId) return {
57
+ items: [],
58
+ openCount: 0
59
+ };
60
+ where = { resourceId };
61
+ } else {
62
+ const communityToolId = await communityToolIdForSlug(target.communityToolSlug);
63
+ if (!communityToolId) return {
64
+ items: [],
65
+ openCount: 0
66
+ };
67
+ where = { communityToolId };
68
+ }
48
69
  const rows = await getDbClient().dbFeedback.findMany({
49
- where: { resourceId },
70
+ where,
50
71
  orderBy: { createdAt: "asc" },
51
72
  include: WITH_RELATIONS
52
73
  });
@@ -65,7 +86,10 @@ async function listFeedback(resourceSlug, actor) {
65
86
  async function listMyFeedback(actor) {
66
87
  if (!actor) return [];
67
88
  return (await getDbClient().dbFeedback.findMany({
68
- where: { authorHash: actor.hash },
89
+ where: {
90
+ authorHash: actor.hash,
91
+ kind: null
92
+ },
69
93
  orderBy: [{ status: {
70
94
  sort: "asc",
71
95
  nulls: "first"
@@ -93,22 +117,31 @@ function normaliseBody(body) {
93
117
  const trimmed = stripHiddenText(body).trim();
94
118
  return trimmed ? trimmed.slice(0, MAX_BODY_LENGTH) : null;
95
119
  }
96
- async function addFeedback(input, actor) {
97
- const { resourceSlug, subject, body, attachmentIds = [] } = input;
98
- if (!resourceSlug && !subject?.trim()) throw new FeedbackError("empty", "Tell us which entry this is about, or what you were looking for");
120
+ async function addFeedback(input, actor, user) {
121
+ const { resourceSlug, communityToolSlug, subject, body, attachmentIds = [] } = input;
122
+ if (!resourceSlug && !communityToolSlug && !subject?.trim()) throw new FeedbackError("empty", "Tell us which entry this is about, or what you were looking for");
99
123
  if (attachmentIds.length > 3) throw new FeedbackError("too-many-attachments", `Up to ${3} images`);
100
124
  let resourceId = null;
101
125
  if (resourceSlug) {
102
126
  resourceId = await resourceIdForSlug(resourceSlug);
103
127
  if (!resourceId) throw new FeedbackError("not-found", `Unknown resource: ${resourceSlug}`);
104
128
  }
129
+ let communityToolId = null;
130
+ if (!resourceId && communityToolSlug) {
131
+ communityToolId = await communityToolIdForSlug(communityToolSlug);
132
+ if (!communityToolId) throw new FeedbackError("not-found", `Unknown community tool: ${communityToolSlug}`);
133
+ }
134
+ const onEntry = !!resourceId || !!communityToolId;
105
135
  return toView(await getDbClient().$transaction(async (tx) => {
106
136
  const created = await tx.dbFeedback.create({
107
137
  data: {
108
138
  resourceId,
109
- subject: resourceId ? null : stripHiddenText(subject).trim().slice(0, 120),
139
+ communityToolId,
140
+ subject: onEntry ? null : stripHiddenText(subject).trim().slice(0, 120),
110
141
  authorHash: actor.hash,
111
142
  authorAlias: actor.alias,
143
+ authorUserId: user?.id ?? null,
144
+ authorName: user?.name ?? null,
112
145
  body: normaliseBody(body)
113
146
  },
114
147
  select: { id: true }
@@ -136,7 +169,7 @@ async function addFeedback(input, actor) {
136
169
  async function loadOwnForEdit(id, actor) {
137
170
  const row = await getDbClient().dbFeedback.findUnique({ where: { id } });
138
171
  if (!row) throw new FeedbackError("not-found", "This no longer exists");
139
- if (row.authorHash !== actor.hash) throw new FeedbackError("not-author", "Only the author can change this");
172
+ if (row.kind === "marker" || row.authorHash !== actor.hash) throw new FeedbackError("not-author", "Only the author can change this");
140
173
  if (Date.now() > row.createdAt.getTime() + 36e5) throw new FeedbackError("window-closed", "Wording can only be changed in the first hour");
141
174
  return row;
142
175
  }
@@ -163,12 +196,12 @@ async function editFeedback(id, body, actor) {
163
196
  async function dismissFeedback(id, actor) {
164
197
  const row = await getDbClient().dbFeedback.findUnique({ where: { id } });
165
198
  if (!row) throw new FeedbackError("not-found", "This no longer exists");
166
- if (row.authorHash !== actor.hash) throw new FeedbackError("not-author", "Only the author can withdraw this");
199
+ if (row.kind === "marker" || row.authorHash !== actor.hash) throw new FeedbackError("not-author", "Only the author can withdraw this");
167
200
  if (row.status !== null) throw new FeedbackError("already-reviewed", "This has been answered, so it stays on the entry");
168
201
  await getDbClient().dbFeedback.delete({ where: { id } });
169
202
  return { id };
170
203
  }
171
204
 
172
205
  //#endregion
173
- export { EDIT_WINDOW_MS, FeedbackError, MAX_ATTACHMENTS, MAX_BODY_LENGTH, MAX_SUBJECT_LENGTH, addFeedback, dismissFeedback, editFeedback, listFeedback, listMyFeedback, stripHiddenText };
206
+ export { EDIT_WINDOW_MS, FeedbackError, MARKER_KIND, MAX_ATTACHMENTS, MAX_BODY_LENGTH, MAX_SUBJECT_LENGTH, addFeedback, dismissFeedback, editFeedback, listFeedback, listMyFeedback, stripHiddenText };
174
207
  //# sourceMappingURL=service.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"service.mjs","names":[],"sources":["../../../src/modules/feedback/service.ts"],"sourcesContent":["import { getDbClient } from '../../db/client'\nimport type { Actor } from '../visitor/visitorIdentity'\n\n/** How long an author may edit their own wording. */\nexport const EDIT_WINDOW_MS = 60 * 60 * 1000\n\nexport const MAX_BODY_LENGTH = 1000\nexport const MAX_SUBJECT_LENGTH = 120\nexport const MAX_ATTACHMENTS = 3\n\n/** One piece of feedback as a client sees it. Never carries an author hash. */\nexport interface FeedbackView {\n id: string\n authorName: string\n /** Null when the visitor sent a bare flag with no words. */\n body: string | null\n /** What they were looking for, on a catalog-level ask. Null on entry feedback. */\n subject: string | null\n /** Slug of the entry this is about, or null for a catalog-level ask. */\n resourceSlug: string | null\n /** Display name of that entry, so the tracker can label a row without a second query. */\n resourceName: string | null\n attachmentIds: string[]\n status: 'applied' | 'acknowledged' | null\n reviewerReply: string | null\n /** When a maintainer last touched it — what \"unread\" is computed against. */\n reviewedAt: string | null\n createdAt: string\n editedAt: string | null\n /** Whether this browser wrote it — what makes Edit and Dismiss appear. */\n isMine: boolean\n /**\n * The absolute instant this browser's edit window closes, or null when it is not\n * theirs. Absolute rather than a remaining duration so a client with a skewed clock\n * hides the control at the same moment the server starts refusing the edit.\n */\n canEditUntil: string | null\n}\n\ntype Row = {\n id: string\n authorHash: string\n authorAlias: string\n body: string | null\n subject: string | null\n status: 'applied' | 'acknowledged' | null\n reviewerReply: string | null\n reviewedAt: Date | null\n editedAt: Date | null\n createdAt: Date\n resource?: { slug: string; displayName: string } | null\n attachments?: { id: string }[]\n}\n\nfunction toView(row: Row, actor: Actor | null): FeedbackView {\n const isMine = !!actor && row.authorHash === actor.hash\n return {\n id: row.id,\n authorName: row.authorAlias,\n body: row.body,\n subject: row.subject,\n resourceSlug: row.resource?.slug ?? null,\n resourceName: row.resource?.displayName ?? null,\n attachmentIds: (row.attachments ?? []).map((a) => a.id),\n status: row.status,\n reviewerReply: row.reviewerReply,\n reviewedAt: row.reviewedAt?.toISOString() ?? null,\n createdAt: row.createdAt.toISOString(),\n editedAt: row.editedAt?.toISOString() ?? null,\n isMine,\n canEditUntil: isMine\n ? new Date(row.createdAt.getTime() + EDIT_WINDOW_MS).toISOString()\n : null,\n }\n}\n\nconst WITH_RELATIONS = {\n resource: { select: { slug: true, displayName: true } },\n attachments: { select: { id: true } },\n} as const\n\nasync function resourceIdForSlug(slug: string): Promise<string | null> {\n const resource = await getDbClient().dbResource.findUnique({\n where: { slug },\n select: { id: true },\n })\n return resource?.id ?? null\n}\n\nexport interface FeedbackList {\n items: FeedbackView[]\n /** Unreviewed count — what the badge shows. `status IS NULL` is the whole definition. */\n openCount: number\n}\n\n/** Oldest first: a thread reads top to bottom. */\nexport async function listFeedback(\n resourceSlug: string,\n actor: Actor | null,\n): Promise<FeedbackList> {\n const resourceId = await resourceIdForSlug(resourceSlug)\n if (!resourceId) return { items: [], openCount: 0 }\n\n const rows = await getDbClient().dbFeedback.findMany({\n where: { resourceId },\n orderBy: { createdAt: 'asc' },\n include: WITH_RELATIONS,\n })\n return {\n items: rows.map((row) => toView(row, actor)),\n openCount: rows.filter((row) => row.status === null).length,\n }\n}\n\n/**\n * Everything this browser filed, across entries, for the tracker.\n *\n * Sorted the way the tracker renders: unreviewed first, then newest. Postgres sorts\n * NULLs last by default, so `status` ascending would bury the pending rows — hence the\n * explicit two-key ordering rather than relying on the default.\n */\nexport async function listMyFeedback(\n actor: Actor | null,\n): Promise<FeedbackView[]> {\n if (!actor) return []\n const rows = await getDbClient().dbFeedback.findMany({\n where: { authorHash: actor.hash },\n orderBy: [\n { status: { sort: 'asc', nulls: 'first' } },\n { createdAt: 'desc' },\n ],\n include: WITH_RELATIONS,\n })\n return rows.map((row) => toView(row, actor))\n}\n\n/**\n * Strips invisible characters from text that AI agents will read.\n *\n * Removes zero-width characters (except ZWJ, needed for emoji), directional marks/overrides,\n * and Unicode tag characters that could hide instructions or disguise links.\n */\nexport function stripHiddenText(text: string): string {\n // Zero-width: U+200B, U+200C, U+2060, U+FEFF\n // Directional: U+200E-U+200F, U+202A-U+202E, U+2066-U+2069\n // Tag chars: U+E0000-U+E007F\n // Preserves U+200D (ZWJ) for emoji\n return text.replace(\n /[\\u{200B}\\u{200C}\\u{200E}-\\u{200F}\\u{202A}-\\u{202E}\\u{2060}\\u{2066}-\\u{2069}\\u{E0000}-\\u{E007F}\\u{FEFF}]/gu,\n '',\n )\n}\n\nexport class FeedbackError extends Error {\n constructor(\n readonly reason:\n | 'not-found'\n | 'not-author'\n | 'window-closed'\n | 'already-reviewed'\n | 'empty'\n | 'too-many-attachments',\n message: string,\n ) {\n super(message)\n }\n}\n\nfunction normaliseBody(body: string | undefined | null): string | null {\n if (body === undefined || body === null) return null\n const stripped = stripHiddenText(body)\n const trimmed = stripped.trim()\n // A bare flag is a valid signal, so empty is stored as absent rather than refused.\n return trimmed ? trimmed.slice(0, MAX_BODY_LENGTH) : null\n}\n\nexport interface AddFeedbackInput {\n /** Omitted for a catalog-level ask. */\n resourceSlug?: string\n /** Required when there is no resourceSlug — it is the substance of the ask. */\n subject?: string\n body?: string\n attachmentIds?: string[]\n}\n\nexport async function addFeedback(\n input: AddFeedbackInput,\n actor: Actor,\n): Promise<FeedbackView> {\n const { resourceSlug, subject, body, attachmentIds = [] } = input\n\n if (!resourceSlug && !subject?.trim()) {\n throw new FeedbackError(\n 'empty',\n 'Tell us which entry this is about, or what you were looking for',\n )\n }\n if (attachmentIds.length > MAX_ATTACHMENTS) {\n throw new FeedbackError(\n 'too-many-attachments',\n `Up to ${MAX_ATTACHMENTS} images`,\n )\n }\n\n let resourceId: string | null = null\n if (resourceSlug) {\n resourceId = await resourceIdForSlug(resourceSlug)\n if (!resourceId) {\n throw new FeedbackError('not-found', `Unknown resource: ${resourceSlug}`)\n }\n }\n\n const row = await getDbClient().$transaction(async (tx) => {\n const created = await tx.dbFeedback.create({\n data: {\n resourceId,\n subject: resourceId\n ? null\n : stripHiddenText(subject!).trim().slice(0, MAX_SUBJECT_LENGTH),\n authorHash: actor.hash,\n authorAlias: actor.alias,\n body: normaliseBody(body),\n },\n select: { id: true },\n })\n\n // Claim only images this browser uploaded and nobody has claimed yet, so an id\n // guessed from someone else's upload cannot be pulled onto your feedback.\n //\n // `updateMany` with the conditions in the WHERE, rather than a nested `connect`:\n // `connect` takes only the id, and because `feedbackId` is a single FK it would\n // MOVE a claimed image off its owner's feedback instead of refusing.\n if (attachmentIds.length > 0) {\n await tx.dbFeedbackAttachment.updateMany({\n where: {\n id: { in: attachmentIds },\n authorHash: actor.hash,\n feedbackId: null,\n },\n data: { feedbackId: created.id },\n })\n }\n\n return tx.dbFeedback.findUniqueOrThrow({\n where: { id: created.id },\n include: WITH_RELATIONS,\n })\n })\n return toView(row, actor)\n}\n\n/**\n * Load a row and check this actor may still change its wording.\n *\n * Enforced here, not in the UI: the control disappearing is a courtesy, and a request\n * that arrives a second late must still be refused.\n */\nasync function loadOwnForEdit(id: string, actor: Actor) {\n const row = await getDbClient().dbFeedback.findUnique({ where: { id } })\n if (!row) throw new FeedbackError('not-found', 'This no longer exists')\n if (row.authorHash !== actor.hash) {\n throw new FeedbackError('not-author', 'Only the author can change this')\n }\n if (Date.now() > row.createdAt.getTime() + EDIT_WINDOW_MS) {\n throw new FeedbackError(\n 'window-closed',\n 'Wording can only be changed in the first hour',\n )\n }\n return row\n}\n\nexport async function editFeedback(\n id: string,\n body: string,\n actor: Actor,\n): Promise<FeedbackView> {\n await loadOwnForEdit(id, actor)\n const row = await getDbClient().dbFeedback.update({\n where: { id },\n data: { body: normaliseBody(body), editedAt: new Date() },\n include: WITH_RELATIONS,\n })\n return toView(row, actor)\n}\n\n/**\n * Withdraw your own feedback.\n *\n * Allowed for as long as nobody has reviewed it, with no time limit — the edit window\n * exists to stop someone rewriting words others have already read, and that reasoning\n * does not apply to withdrawing something no maintainer has acted on. Once reviewed it\n * is public record carrying a maintainer's reply, so withdrawing would delete their\n * answer; from then on it is refused.\n */\nexport async function dismissFeedback(\n id: string,\n actor: Actor,\n): Promise<{ id: string }> {\n const row = await getDbClient().dbFeedback.findUnique({ where: { id } })\n if (!row) throw new FeedbackError('not-found', 'This no longer exists')\n if (row.authorHash !== actor.hash) {\n throw new FeedbackError('not-author', 'Only the author can withdraw this')\n }\n if (row.status !== null) {\n throw new FeedbackError(\n 'already-reviewed',\n 'This has been answered, so it stays on the entry',\n )\n }\n await getDbClient().dbFeedback.delete({ where: { id } })\n return { id }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"service.mjs","names":[],"sources":["../../../src/modules/feedback/service.ts"],"sourcesContent":["import { getDbClient } from '../../db/client'\nimport type { Actor } from '../visitor/visitorIdentity'\n\n/** How long an author may edit their own wording. */\nexport const EDIT_WINDOW_MS = 60 * 60 * 1000\n\nexport const MAX_BODY_LENGTH = 1000\nexport const MAX_SUBJECT_LENGTH = 120\nexport const MAX_ATTACHMENTS = 3\n\n/** `kind` of a review row a community tool write leaves; see `isSystem`. */\nexport const MARKER_KIND = 'marker'\n\n/** One piece of feedback as a client sees it. Never carries an author hash. */\nexport interface FeedbackView {\n id: string\n /** The signed-in author's name, or the pseudonym of an anonymous one. */\n authorName: string\n /** Null when the visitor sent a bare flag with no words. */\n body: string | null\n /** What they were looking for, on a catalog-level ask. Null on entry feedback. */\n subject: string | null\n /** Slug of the entry this is about, or null for a catalog-level ask. */\n resourceSlug: string | null\n /** Display name of that entry, so the tracker can label a row without a second query. */\n resourceName: string | null\n attachmentIds: string[]\n status: 'applied' | 'acknowledged' | null\n reviewerReply: string | null\n /** When a maintainer last touched it — what \"unread\" is computed against. */\n reviewedAt: string | null\n createdAt: string\n editedAt: string | null\n /** Whether this browser wrote it — what makes Edit and Dismiss appear. */\n isMine: boolean\n /** A line the system wrote when a community tool changed: nobody can edit or withdraw it. */\n isSystem: boolean\n /**\n * The absolute instant this browser's edit window closes, or null when it is not\n * theirs. Absolute rather than a remaining duration so a client with a skewed clock\n * hides the control at the same moment the server starts refusing the edit.\n */\n canEditUntil: string | null\n}\n\ntype Row = {\n id: string\n authorHash: string\n authorAlias: string\n authorName: string | null\n body: string | null\n subject: string | null\n kind: string | null\n status: 'applied' | 'acknowledged' | null\n reviewerReply: string | null\n reviewedAt: Date | null\n editedAt: Date | null\n createdAt: Date\n resource?: { slug: string; displayName: string } | null\n attachments?: { id: string }[]\n}\n\nfunction toView(row: Row, actor: Actor | null): FeedbackView {\n const isSystem = row.kind === MARKER_KIND\n const isMine = !isSystem && !!actor && row.authorHash === actor.hash\n return {\n id: row.id,\n authorName: row.authorName ?? row.authorAlias,\n body: row.body,\n subject: row.subject,\n resourceSlug: row.resource?.slug ?? null,\n resourceName: row.resource?.displayName ?? null,\n attachmentIds: (row.attachments ?? []).map((a) => a.id),\n status: row.status,\n reviewerReply: row.reviewerReply,\n reviewedAt: row.reviewedAt?.toISOString() ?? null,\n createdAt: row.createdAt.toISOString(),\n editedAt: row.editedAt?.toISOString() ?? null,\n isMine,\n isSystem,\n canEditUntil: isMine\n ? new Date(row.createdAt.getTime() + EDIT_WINDOW_MS).toISOString()\n : null,\n }\n}\n\nconst WITH_RELATIONS = {\n resource: { select: { slug: true, displayName: true } },\n attachments: { select: { id: true } },\n} as const\n\nasync function resourceIdForSlug(slug: string): Promise<string | null> {\n const resource = await getDbClient().dbResource.findUnique({\n where: { slug },\n select: { id: true },\n })\n return resource?.id ?? null\n}\n\nasync function communityToolIdForSlug(slug: string): Promise<string | null> {\n const tool = await getDbClient().dbCommunityTool.findUnique({\n where: { slug },\n select: { id: true },\n })\n return tool?.id ?? null\n}\n\n/** What a thread hangs on: an official entry or a community tool. */\nexport type FeedbackTarget =\n | { resourceSlug: string }\n | { communityToolSlug: string }\n\nexport interface FeedbackList {\n items: FeedbackView[]\n /** Unreviewed count — what the badge shows. `status IS NULL` is the whole definition. */\n openCount: number\n}\n\n/** Oldest first: a thread reads top to bottom. */\nexport async function listFeedback(\n target: FeedbackTarget,\n actor: Actor | null,\n): Promise<FeedbackList> {\n let where: { resourceId: string } | { communityToolId: string }\n if ('resourceSlug' in target) {\n const resourceId = await resourceIdForSlug(target.resourceSlug)\n if (!resourceId) return { items: [], openCount: 0 }\n where = { resourceId }\n } else {\n const communityToolId = await communityToolIdForSlug(\n target.communityToolSlug,\n )\n if (!communityToolId) return { items: [], openCount: 0 }\n where = { communityToolId }\n }\n\n const rows = await getDbClient().dbFeedback.findMany({\n where,\n orderBy: { createdAt: 'asc' },\n include: WITH_RELATIONS,\n })\n return {\n items: rows.map((row) => toView(row, actor)),\n openCount: rows.filter((row) => row.status === null).length,\n }\n}\n\n/**\n * Everything this browser filed, across entries, for the tracker.\n *\n * Sorted the way the tracker renders: unreviewed first, then newest. Postgres sorts\n * NULLs last by default, so `status` ascending would bury the pending rows — hence the\n * explicit two-key ordering rather than relying on the default.\n */\nexport async function listMyFeedback(\n actor: Actor | null,\n): Promise<FeedbackView[]> {\n if (!actor) return []\n const rows = await getDbClient().dbFeedback.findMany({\n where: { authorHash: actor.hash, kind: null },\n orderBy: [\n { status: { sort: 'asc', nulls: 'first' } },\n { createdAt: 'desc' },\n ],\n include: WITH_RELATIONS,\n })\n return rows.map((row) => toView(row, actor))\n}\n\n/**\n * Strips invisible characters from text that AI agents will read.\n *\n * Removes zero-width characters (except ZWJ, needed for emoji), directional marks/overrides,\n * and Unicode tag characters that could hide instructions or disguise links.\n */\nexport function stripHiddenText(text: string): string {\n // Zero-width: U+200B, U+200C, U+2060, U+FEFF\n // Directional: U+200E-U+200F, U+202A-U+202E, U+2066-U+2069\n // Tag chars: U+E0000-U+E007F\n // Preserves U+200D (ZWJ) for emoji\n return text.replace(\n /[\\u{200B}\\u{200C}\\u{200E}-\\u{200F}\\u{202A}-\\u{202E}\\u{2060}\\u{2066}-\\u{2069}\\u{E0000}-\\u{E007F}\\u{FEFF}]/gu,\n '',\n )\n}\n\nexport class FeedbackError extends Error {\n constructor(\n readonly reason:\n | 'not-found'\n | 'not-author'\n | 'window-closed'\n | 'already-reviewed'\n | 'empty'\n | 'too-many-attachments',\n message: string,\n ) {\n super(message)\n }\n}\n\nfunction normaliseBody(body: string | undefined | null): string | null {\n if (body === undefined || body === null) return null\n const stripped = stripHiddenText(body)\n const trimmed = stripped.trim()\n // A bare flag is a valid signal, so empty is stored as absent rather than refused.\n return trimmed ? trimmed.slice(0, MAX_BODY_LENGTH) : null\n}\n\nexport interface AddFeedbackInput {\n /** Omitted for a catalog-level ask. */\n resourceSlug?: string\n /** The alternative to resourceSlug, for a comment on a community tool. */\n communityToolSlug?: string\n /** Required when there is no resourceSlug — it is the substance of the ask. */\n subject?: string\n body?: string\n attachmentIds?: string[]\n}\n\nexport async function addFeedback(\n input: AddFeedbackInput,\n actor: Actor,\n user: { id: string; name: string } | null,\n): Promise<FeedbackView> {\n const {\n resourceSlug,\n communityToolSlug,\n subject,\n body,\n attachmentIds = [],\n } = input\n\n if (!resourceSlug && !communityToolSlug && !subject?.trim()) {\n throw new FeedbackError(\n 'empty',\n 'Tell us which entry this is about, or what you were looking for',\n )\n }\n if (attachmentIds.length > MAX_ATTACHMENTS) {\n throw new FeedbackError(\n 'too-many-attachments',\n `Up to ${MAX_ATTACHMENTS} images`,\n )\n }\n\n let resourceId: string | null = null\n if (resourceSlug) {\n resourceId = await resourceIdForSlug(resourceSlug)\n if (!resourceId) {\n throw new FeedbackError('not-found', `Unknown resource: ${resourceSlug}`)\n }\n }\n let communityToolId: string | null = null\n if (!resourceId && communityToolSlug) {\n communityToolId = await communityToolIdForSlug(communityToolSlug)\n if (!communityToolId) {\n throw new FeedbackError(\n 'not-found',\n `Unknown community tool: ${communityToolSlug}`,\n )\n }\n }\n const onEntry = !!resourceId || !!communityToolId\n\n const row = await getDbClient().$transaction(async (tx) => {\n const created = await tx.dbFeedback.create({\n data: {\n resourceId,\n communityToolId,\n subject: onEntry\n ? null\n : stripHiddenText(subject!).trim().slice(0, MAX_SUBJECT_LENGTH),\n authorHash: actor.hash,\n authorAlias: actor.alias,\n authorUserId: user?.id ?? null,\n authorName: user?.name ?? null,\n body: normaliseBody(body),\n },\n select: { id: true },\n })\n\n // Claim only images this browser uploaded and nobody has claimed yet, so an id\n // guessed from someone else's upload cannot be pulled onto your feedback.\n //\n // `updateMany` with the conditions in the WHERE, rather than a nested `connect`:\n // `connect` takes only the id, and because `feedbackId` is a single FK it would\n // MOVE a claimed image off its owner's feedback instead of refusing.\n if (attachmentIds.length > 0) {\n await tx.dbFeedbackAttachment.updateMany({\n where: {\n id: { in: attachmentIds },\n authorHash: actor.hash,\n feedbackId: null,\n },\n data: { feedbackId: created.id },\n })\n }\n\n return tx.dbFeedback.findUniqueOrThrow({\n where: { id: created.id },\n include: WITH_RELATIONS,\n })\n })\n return toView(row, actor)\n}\n\n/**\n * Load a row and check this actor may still change its wording.\n *\n * Enforced here, not in the UI: the control disappearing is a courtesy, and a request\n * that arrives a second late must still be refused.\n */\nasync function loadOwnForEdit(id: string, actor: Actor) {\n const row = await getDbClient().dbFeedback.findUnique({ where: { id } })\n if (!row) throw new FeedbackError('not-found', 'This no longer exists')\n if (row.kind === MARKER_KIND || row.authorHash !== actor.hash) {\n throw new FeedbackError('not-author', 'Only the author can change this')\n }\n if (Date.now() > row.createdAt.getTime() + EDIT_WINDOW_MS) {\n throw new FeedbackError(\n 'window-closed',\n 'Wording can only be changed in the first hour',\n )\n }\n return row\n}\n\nexport async function editFeedback(\n id: string,\n body: string,\n actor: Actor,\n): Promise<FeedbackView> {\n await loadOwnForEdit(id, actor)\n const row = await getDbClient().dbFeedback.update({\n where: { id },\n data: { body: normaliseBody(body), editedAt: new Date() },\n include: WITH_RELATIONS,\n })\n return toView(row, actor)\n}\n\n/**\n * Withdraw your own feedback.\n *\n * Allowed for as long as nobody has reviewed it, with no time limit — the edit window\n * exists to stop someone rewriting words others have already read, and that reasoning\n * does not apply to withdrawing something no maintainer has acted on. Once reviewed it\n * is public record carrying a maintainer's reply, so withdrawing would delete their\n * answer; from then on it is refused.\n */\nexport async function dismissFeedback(\n id: string,\n actor: Actor,\n): Promise<{ id: string }> {\n const row = await getDbClient().dbFeedback.findUnique({ where: { id } })\n if (!row) throw new FeedbackError('not-found', 'This no longer exists')\n if (row.kind === MARKER_KIND || row.authorHash !== actor.hash) {\n throw new FeedbackError('not-author', 'Only the author can withdraw this')\n }\n if (row.status !== null) {\n throw new FeedbackError(\n 'already-reviewed',\n 'This has been answered, so it stays on the entry',\n )\n }\n await getDbClient().dbFeedback.delete({ where: { id } })\n return { id }\n}\n"],"mappings":"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"}