@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,408 @@
1
+ import { TRPCError } from '@trpc/server'
2
+ import type { User } from 'better-auth/types'
3
+ import { z } from 'zod'
4
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
5
+ import {
6
+ TAG_VALUE_MAX,
7
+ hostOf,
8
+ isCaptureAllowed,
9
+ normalizeTags,
10
+ } from '@igstack/app-catalog-shared-core'
11
+ import { getDbClient } from '../../db/client'
12
+ import { resizeImage } from '../assets/assetUtils'
13
+ import { MARKER_KIND } from '../feedback/service'
14
+ import type { Actor } from '../visitor/visitorIdentity'
15
+ import { checkTool } from './healthJob'
16
+ import type { CaptureDeps } from './healthJob'
17
+ import { computeCanEdit, getTool } from './service'
18
+
19
+ export const toolInputSchema = z.object({
20
+ url: z.string().max(2000),
21
+ name: z.string().trim().min(1).max(80),
22
+ summary: z.string().trim().max(160),
23
+ descriptionMd: z.string().max(5000),
24
+ ownerSlugs: z.array(z.string()).max(5),
25
+ /**
26
+ * Owners outside the person directory, by name. Left out of an edit, the
27
+ * tool keeps the ones it has; only a list replaces them.
28
+ */
29
+ ownerNames: z
30
+ .array(z.string().trim().min(1).max(80))
31
+ .max(5)
32
+ .transform((names) => Array.from(new Set(names)))
33
+ .optional(),
34
+ // Topics are stored the way the next person will type them, each tag once;
35
+ // the caps hold for what is stored.
36
+ tags: z
37
+ .array(z.object({ kind: z.enum(['app', 'topic']), value: z.string() }))
38
+ .transform(normalizeTags)
39
+ .pipe(
40
+ z
41
+ .array(
42
+ z.object({
43
+ kind: z.enum(['app', 'topic']),
44
+ value: z.string().min(1).max(TAG_VALUE_MAX),
45
+ }),
46
+ )
47
+ .max(8),
48
+ ),
49
+ })
50
+ export type ToolInput = z.output<typeof toolInputSchema>
51
+
52
+ const MAX_OWNERS = 5
53
+
54
+ /** The signed-in person making the change. */
55
+ export interface Writer {
56
+ user: User
57
+ isAdmin: boolean
58
+ actor: Actor
59
+ }
60
+
61
+ export interface ToolPreview {
62
+ host: string
63
+ title: string | null
64
+ screenshotDataUrl: string | null
65
+ duplicate: { slug: string; name: string } | null
66
+ /** An official app on the same host: the tool would duplicate a catalog entry. */
67
+ officialApp: { slug: string; name: string } | null
68
+ captureAllowed: boolean
69
+ }
70
+
71
+ const PREVIEW_WIDTH = 640
72
+ /** Previews are someone waiting at the form; past this many queued, ask them to retry. */
73
+ const MAX_WAITING_CAPTURES = 3
74
+
75
+ function requireHost(url: string): string {
76
+ const host = hostOf(url)
77
+ if (!host) {
78
+ throw new TRPCError({
79
+ code: 'BAD_REQUEST',
80
+ message: 'The address must start with http:// or https://',
81
+ })
82
+ }
83
+ return host
84
+ }
85
+
86
+ async function requireTool(slug: string) {
87
+ const tool = await getDbClient().dbCommunityTool.findUnique({
88
+ where: { slug },
89
+ include: { owners: true, namedOwners: true, tags: true },
90
+ })
91
+ if (!tool) {
92
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Tool not found' })
93
+ }
94
+ return tool
95
+ }
96
+
97
+ async function requireEditor(
98
+ tool: { id: string; addedByUserId: string },
99
+ writer: Writer,
100
+ ) {
101
+ if (!(await computeCanEdit(tool, writer.user, writer.isAdmin))) {
102
+ throw new TRPCError({
103
+ code: 'FORBIDDEN',
104
+ message: 'Only an owner or an admin can change this tool',
105
+ })
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The owners as stored: directory people, who must exist, and names of people
111
+ * outside it. One owner at least, of either kind, and five in all.
112
+ */
113
+ async function requireOwners(input: ToolInput, keptNames: string[] = []) {
114
+ const unique = Array.from(new Set(input.ownerSlugs))
115
+ const ownerNames = input.ownerNames ?? keptNames
116
+ const count = unique.length + ownerNames.length
117
+ if (count < 1 || count > MAX_OWNERS) {
118
+ throw new TRPCError({
119
+ code: 'BAD_REQUEST',
120
+ message: `A tool has 1 to ${MAX_OWNERS} owners`,
121
+ })
122
+ }
123
+ const found = await getDbClient().dbPerson.findMany({
124
+ where: { slug: { in: unique } },
125
+ select: { slug: true },
126
+ })
127
+ if (found.length !== unique.length) {
128
+ throw new TRPCError({
129
+ code: 'BAD_REQUEST',
130
+ message: 'Every owner must be in the person directory',
131
+ })
132
+ }
133
+ return { ownerSlugs: unique, ownerNames }
134
+ }
135
+
136
+ function slugify(name: string): string {
137
+ return (
138
+ name
139
+ .toLowerCase()
140
+ .replace(/[^a-z0-9]+/g, '-')
141
+ .replace(/^-+|-+$/g, '') || 'tool'
142
+ )
143
+ }
144
+
145
+ async function freeSlug(name: string): Promise<string> {
146
+ const base = slugify(name)
147
+ const db = getDbClient()
148
+ for (let n = 1; ; n++) {
149
+ const slug = n === 1 ? base : `${base}-${n}`
150
+ if (!(await db.dbCommunityTool.findUnique({ where: { slug } }))) return slug
151
+ }
152
+ }
153
+
154
+ /** A review row: what puts the change in the maintainer's existing comment queue. */
155
+ async function writeMarker(
156
+ toolId: string,
157
+ body: string,
158
+ writer: Writer,
159
+ status: 'applied' | 'acknowledged' | null,
160
+ ) {
161
+ await getDbClient().dbFeedback.create({
162
+ data: {
163
+ communityToolId: toolId,
164
+ kind: MARKER_KIND,
165
+ body,
166
+ status,
167
+ authorHash: writer.actor.hash,
168
+ authorAlias: writer.actor.alias,
169
+ authorUserId: writer.user.id,
170
+ authorName: writer.user.name,
171
+ },
172
+ })
173
+ }
174
+
175
+ /** Capture in the background: publishing must not wait on a 20-second page load. */
176
+ function captureLater(
177
+ tool: Parameters<typeof checkTool>[0],
178
+ deps: CaptureDeps,
179
+ now: Date,
180
+ ) {
181
+ checkTool(tool, deps, now).catch((error: unknown) => {
182
+ console.error(`[communityTools] capture of ${tool.slug}:`, error)
183
+ })
184
+ }
185
+
186
+ /** The top-level catalog app whose address is on this host, if any. */
187
+ async function findOfficialApp(
188
+ host: string,
189
+ ): Promise<{ slug: string; name: string } | null> {
190
+ // ponytail: scans every top-level app; a stored host column if the catalog grows past thousands.
191
+ const apps = await getDbClient().dbResource.findMany({
192
+ where: { parentSlug: null, appUrl: { not: null } },
193
+ select: { slug: true, displayName: true, appUrl: true },
194
+ })
195
+ const app = apps.find((a) => a.appUrl && hostOf(a.appUrl) === host)
196
+ return app ? { slug: app.slug, name: app.displayName } : null
197
+ }
198
+
199
+ export async function previewTool(
200
+ url: string,
201
+ deps: CaptureDeps,
202
+ ): Promise<ToolPreview> {
203
+ const host = requireHost(url)
204
+ const captureAllowed = isCaptureAllowed(url, deps.captureHosts)
205
+ if (
206
+ captureAllowed &&
207
+ (deps.capturer?.waiting?.() ?? 0) > MAX_WAITING_CAPTURES
208
+ ) {
209
+ throw new TRPCError({
210
+ code: 'TOO_MANY_REQUESTS',
211
+ message: 'Too many previews in progress; try again in a minute',
212
+ })
213
+ }
214
+
215
+ const duplicate = await getDbClient().dbCommunityTool.findFirst({
216
+ where: { host },
217
+ select: { slug: true, name: true },
218
+ })
219
+ const officialApp = await findOfficialApp(host)
220
+
221
+ let title: string | null = null
222
+ let screenshotDataUrl: string | null = null
223
+ if (captureAllowed && deps.capturer) {
224
+ try {
225
+ const result = await deps.capturer.capture(url, deps.captureHosts)
226
+ if (result.ok) {
227
+ title = result.title
228
+ const jpeg = await resizeImage(
229
+ result.png,
230
+ PREVIEW_WIDTH,
231
+ undefined,
232
+ 'jpeg',
233
+ )
234
+ screenshotDataUrl = `data:image/jpeg;base64,${jpeg.toString('base64')}`
235
+ }
236
+ } catch (error) {
237
+ console.error('[communityTools] preview capture failed:', error)
238
+ }
239
+ }
240
+
241
+ return {
242
+ host,
243
+ title,
244
+ screenshotDataUrl,
245
+ duplicate,
246
+ officialApp,
247
+ captureAllowed,
248
+ }
249
+ }
250
+
251
+ export async function addTool(
252
+ input: ToolInput,
253
+ writer: Writer,
254
+ deps: CaptureDeps,
255
+ now: Date,
256
+ ): Promise<CommunityTool> {
257
+ const host = requireHost(input.url)
258
+ const { ownerSlugs, ownerNames } = await requireOwners(input)
259
+ const slug = await freeSlug(input.name)
260
+
261
+ const created = await getDbClient().dbCommunityTool.create({
262
+ data: {
263
+ slug,
264
+ name: input.name,
265
+ url: input.url,
266
+ host,
267
+ summary: input.summary,
268
+ descriptionMd: input.descriptionMd,
269
+ addedByUserId: writer.user.id,
270
+ addedByName: writer.user.name,
271
+ owners: { create: ownerSlugs.map((personSlug) => ({ personSlug })) },
272
+ namedOwners: { create: ownerNames.map((name) => ({ name })) },
273
+ tags: { create: input.tags },
274
+ },
275
+ })
276
+ await writeMarker(created.id, `New tool added: ${input.name}`, writer, null)
277
+ captureLater(created, deps, now)
278
+
279
+ return (await getTool(created.slug, writer.user, writer.isAdmin, now, deps))!
280
+ }
281
+
282
+ export async function editTool(
283
+ input: ToolInput & { slug: string },
284
+ writer: Writer,
285
+ deps: CaptureDeps,
286
+ now: Date,
287
+ ): Promise<CommunityTool> {
288
+ const tool = await requireTool(input.slug)
289
+ await requireEditor(tool, writer)
290
+ const host = requireHost(input.url)
291
+ const { ownerSlugs, ownerNames } = await requireOwners(
292
+ input,
293
+ tool.namedOwners.map((o) => o.name),
294
+ )
295
+
296
+ const sameSet = (a: string[], b: string[]) =>
297
+ a.length === b.length && a.every((x) => b.includes(x))
298
+ const tagKey = (t: { kind: string; value: string }) => `${t.kind}:${t.value}`
299
+ const changed = [
300
+ tool.name !== input.name && 'name',
301
+ tool.url !== input.url && 'url',
302
+ tool.summary !== input.summary && 'summary',
303
+ tool.descriptionMd !== input.descriptionMd && 'description',
304
+ !(
305
+ sameSet(
306
+ tool.owners.map((o) => o.personSlug),
307
+ ownerSlugs,
308
+ ) &&
309
+ sameSet(
310
+ tool.namedOwners.map((o) => o.name),
311
+ ownerNames,
312
+ )
313
+ ) && 'owners',
314
+ !sameSet(tool.tags.map(tagKey), input.tags.map(tagKey)) && 'tags',
315
+ ].filter((f): f is string => !!f)
316
+
317
+ if (changed.length > 0) {
318
+ // A different site has no health history yet, and a dead old site's
319
+ // automatic archive says nothing about the new one.
320
+ const newSite = host !== tool.host && {
321
+ failingSince: null,
322
+ lastCheckAt: null,
323
+ ...(tool.archivedBy === 'auto' && {
324
+ archivedAt: null,
325
+ archivedBy: null,
326
+ archivedReason: null,
327
+ }),
328
+ }
329
+ const updated = await getDbClient().dbCommunityTool.update({
330
+ where: { id: tool.id },
331
+ data: {
332
+ // An edit is news to the maintainer, so the tool waits for approval again.
333
+ reviewedAt: null,
334
+ reviewedByName: null,
335
+ ...newSite,
336
+ name: input.name,
337
+ url: input.url,
338
+ host,
339
+ summary: input.summary,
340
+ descriptionMd: input.descriptionMd,
341
+ owners: {
342
+ deleteMany: {},
343
+ create: ownerSlugs.map((personSlug) => ({ personSlug })),
344
+ },
345
+ namedOwners: {
346
+ deleteMany: {},
347
+ create: ownerNames.map((name) => ({ name })),
348
+ },
349
+ tags: { deleteMany: {}, create: input.tags },
350
+ },
351
+ })
352
+ await writeMarker(
353
+ tool.id,
354
+ `Tool edited: ${changed.join(', ')}`,
355
+ writer,
356
+ null,
357
+ )
358
+ if (changed.includes('url')) captureLater(updated, deps, now)
359
+ }
360
+
361
+ // The slug stays: pins, links and bookmarks point at it.
362
+ return (await getTool(tool.slug, writer.user, writer.isAdmin, now, deps))!
363
+ }
364
+
365
+ export async function archiveTool(
366
+ slug: string,
367
+ reason: string,
368
+ writer: Writer,
369
+ now: Date,
370
+ ): Promise<void> {
371
+ const tool = await requireTool(slug)
372
+ await requireEditor(tool, writer)
373
+
374
+ await getDbClient().dbCommunityTool.update({
375
+ where: { id: tool.id },
376
+ data: {
377
+ archivedAt: now,
378
+ archivedBy: writer.user.name,
379
+ archivedReason: reason,
380
+ },
381
+ })
382
+ // An owner's archive is news to the maintainer; an admin's is their own decision.
383
+ await writeMarker(
384
+ tool.id,
385
+ `Tool archived: ${reason}`,
386
+ writer,
387
+ writer.isAdmin ? 'acknowledged' : null,
388
+ )
389
+ }
390
+
391
+ export async function approveTool(
392
+ slug: string,
393
+ writer: Writer,
394
+ now: Date,
395
+ ): Promise<void> {
396
+ const tool = await requireTool(slug)
397
+ const db = getDbClient()
398
+
399
+ await db.dbCommunityTool.update({
400
+ where: { id: tool.id },
401
+ data: { reviewedAt: now, reviewedByName: writer.user.name },
402
+ })
403
+ await db.dbFeedback.updateMany({
404
+ where: { communityToolId: tool.id, status: null },
405
+ data: { status: 'applied', reviewedAt: now },
406
+ })
407
+ await writeMarker(tool.id, 'Tool approved', writer, 'applied')
408
+ }
@@ -57,8 +57,13 @@ export function createFeedbackRouter() {
57
57
  return router({
58
58
  /** One entry's thread, plus its unreviewed count so the badge needs no second call. */
59
59
  list: publicProcedure
60
- .input(z.object({ resourceSlug: z.string() }))
61
- .query(({ input, ctx }) => listFeedback(input.resourceSlug, ctx.actor)),
60
+ .input(
61
+ z.union([
62
+ z.object({ resourceSlug: z.string() }),
63
+ z.object({ communityToolSlug: z.string() }),
64
+ ]),
65
+ )
66
+ .query(({ input, ctx }) => listFeedback(input, ctx.actor)),
62
67
 
63
68
  /** Everything this browser filed, for the tracker. Empty means render no button. */
64
69
  mine: publicProcedure.query(({ ctx }) => listMyFeedback(ctx.actor)),
@@ -68,6 +73,7 @@ export function createFeedbackRouter() {
68
73
  z
69
74
  .object({
70
75
  resourceSlug: z.string().optional(),
76
+ communityToolSlug: z.string().optional(),
71
77
  subject: z.string().max(MAX_SUBJECT_LENGTH).optional(),
72
78
  // Both optional: a note can be words, screenshots or both, and a
73
79
  // catalog-level ask may carry only its subject. The entry's input asks
@@ -76,12 +82,20 @@ export function createFeedbackRouter() {
76
82
  body: z.string().max(MAX_BODY_LENGTH).optional(),
77
83
  attachmentIds: z.array(z.string()).max(MAX_ATTACHMENTS).optional(),
78
84
  })
79
- .refine((v) => !!v.resourceSlug || !!v.subject?.trim(), {
80
- message: 'Needs either an entry or a subject',
81
- }),
85
+ .refine(
86
+ (v) =>
87
+ !!v.resourceSlug || !!v.communityToolSlug || !!v.subject?.trim(),
88
+ { message: 'Needs either an entry or a subject' },
89
+ ),
82
90
  )
83
91
  .mutation(({ input, ctx }) =>
84
- mapErrors(() => addFeedback(input, requireActor(ctx.actor))),
92
+ mapErrors(() =>
93
+ addFeedback(
94
+ input,
95
+ requireActor(ctx.actor),
96
+ ctx.user ? { id: ctx.user.id, name: ctx.user.name } : null,
97
+ ),
98
+ ),
85
99
  ),
86
100
 
87
101
  edit: publicProcedure
@@ -8,9 +8,13 @@ export const MAX_BODY_LENGTH = 1000
8
8
  export const MAX_SUBJECT_LENGTH = 120
9
9
  export const MAX_ATTACHMENTS = 3
10
10
 
11
+ /** `kind` of a review row a community tool write leaves; see `isSystem`. */
12
+ export const MARKER_KIND = 'marker'
13
+
11
14
  /** One piece of feedback as a client sees it. Never carries an author hash. */
12
15
  export interface FeedbackView {
13
16
  id: string
17
+ /** The signed-in author's name, or the pseudonym of an anonymous one. */
14
18
  authorName: string
15
19
  /** Null when the visitor sent a bare flag with no words. */
16
20
  body: string | null
@@ -29,6 +33,8 @@ export interface FeedbackView {
29
33
  editedAt: string | null
30
34
  /** Whether this browser wrote it — what makes Edit and Dismiss appear. */
31
35
  isMine: boolean
36
+ /** A line the system wrote when a community tool changed: nobody can edit or withdraw it. */
37
+ isSystem: boolean
32
38
  /**
33
39
  * The absolute instant this browser's edit window closes, or null when it is not
34
40
  * theirs. Absolute rather than a remaining duration so a client with a skewed clock
@@ -41,8 +47,10 @@ type Row = {
41
47
  id: string
42
48
  authorHash: string
43
49
  authorAlias: string
50
+ authorName: string | null
44
51
  body: string | null
45
52
  subject: string | null
53
+ kind: string | null
46
54
  status: 'applied' | 'acknowledged' | null
47
55
  reviewerReply: string | null
48
56
  reviewedAt: Date | null
@@ -53,10 +61,11 @@ type Row = {
53
61
  }
54
62
 
55
63
  function toView(row: Row, actor: Actor | null): FeedbackView {
56
- const isMine = !!actor && row.authorHash === actor.hash
64
+ const isSystem = row.kind === MARKER_KIND
65
+ const isMine = !isSystem && !!actor && row.authorHash === actor.hash
57
66
  return {
58
67
  id: row.id,
59
- authorName: row.authorAlias,
68
+ authorName: row.authorName ?? row.authorAlias,
60
69
  body: row.body,
61
70
  subject: row.subject,
62
71
  resourceSlug: row.resource?.slug ?? null,
@@ -68,6 +77,7 @@ function toView(row: Row, actor: Actor | null): FeedbackView {
68
77
  createdAt: row.createdAt.toISOString(),
69
78
  editedAt: row.editedAt?.toISOString() ?? null,
70
79
  isMine,
80
+ isSystem,
71
81
  canEditUntil: isMine
72
82
  ? new Date(row.createdAt.getTime() + EDIT_WINDOW_MS).toISOString()
73
83
  : null,
@@ -87,6 +97,19 @@ async function resourceIdForSlug(slug: string): Promise<string | null> {
87
97
  return resource?.id ?? null
88
98
  }
89
99
 
100
+ async function communityToolIdForSlug(slug: string): Promise<string | null> {
101
+ const tool = await getDbClient().dbCommunityTool.findUnique({
102
+ where: { slug },
103
+ select: { id: true },
104
+ })
105
+ return tool?.id ?? null
106
+ }
107
+
108
+ /** What a thread hangs on: an official entry or a community tool. */
109
+ export type FeedbackTarget =
110
+ | { resourceSlug: string }
111
+ | { communityToolSlug: string }
112
+
90
113
  export interface FeedbackList {
91
114
  items: FeedbackView[]
92
115
  /** Unreviewed count — what the badge shows. `status IS NULL` is the whole definition. */
@@ -95,14 +118,24 @@ export interface FeedbackList {
95
118
 
96
119
  /** Oldest first: a thread reads top to bottom. */
97
120
  export async function listFeedback(
98
- resourceSlug: string,
121
+ target: FeedbackTarget,
99
122
  actor: Actor | null,
100
123
  ): Promise<FeedbackList> {
101
- const resourceId = await resourceIdForSlug(resourceSlug)
102
- if (!resourceId) return { items: [], openCount: 0 }
124
+ let where: { resourceId: string } | { communityToolId: string }
125
+ if ('resourceSlug' in target) {
126
+ const resourceId = await resourceIdForSlug(target.resourceSlug)
127
+ if (!resourceId) return { items: [], openCount: 0 }
128
+ where = { resourceId }
129
+ } else {
130
+ const communityToolId = await communityToolIdForSlug(
131
+ target.communityToolSlug,
132
+ )
133
+ if (!communityToolId) return { items: [], openCount: 0 }
134
+ where = { communityToolId }
135
+ }
103
136
 
104
137
  const rows = await getDbClient().dbFeedback.findMany({
105
- where: { resourceId },
138
+ where,
106
139
  orderBy: { createdAt: 'asc' },
107
140
  include: WITH_RELATIONS,
108
141
  })
@@ -124,7 +157,7 @@ export async function listMyFeedback(
124
157
  ): Promise<FeedbackView[]> {
125
158
  if (!actor) return []
126
159
  const rows = await getDbClient().dbFeedback.findMany({
127
- where: { authorHash: actor.hash },
160
+ where: { authorHash: actor.hash, kind: null },
128
161
  orderBy: [
129
162
  { status: { sort: 'asc', nulls: 'first' } },
130
163
  { createdAt: 'desc' },
@@ -177,6 +210,8 @@ function normaliseBody(body: string | undefined | null): string | null {
177
210
  export interface AddFeedbackInput {
178
211
  /** Omitted for a catalog-level ask. */
179
212
  resourceSlug?: string
213
+ /** The alternative to resourceSlug, for a comment on a community tool. */
214
+ communityToolSlug?: string
180
215
  /** Required when there is no resourceSlug — it is the substance of the ask. */
181
216
  subject?: string
182
217
  body?: string
@@ -186,10 +221,17 @@ export interface AddFeedbackInput {
186
221
  export async function addFeedback(
187
222
  input: AddFeedbackInput,
188
223
  actor: Actor,
224
+ user: { id: string; name: string } | null,
189
225
  ): Promise<FeedbackView> {
190
- const { resourceSlug, subject, body, attachmentIds = [] } = input
226
+ const {
227
+ resourceSlug,
228
+ communityToolSlug,
229
+ subject,
230
+ body,
231
+ attachmentIds = [],
232
+ } = input
191
233
 
192
- if (!resourceSlug && !subject?.trim()) {
234
+ if (!resourceSlug && !communityToolSlug && !subject?.trim()) {
193
235
  throw new FeedbackError(
194
236
  'empty',
195
237
  'Tell us which entry this is about, or what you were looking for',
@@ -209,16 +251,30 @@ export async function addFeedback(
209
251
  throw new FeedbackError('not-found', `Unknown resource: ${resourceSlug}`)
210
252
  }
211
253
  }
254
+ let communityToolId: string | null = null
255
+ if (!resourceId && communityToolSlug) {
256
+ communityToolId = await communityToolIdForSlug(communityToolSlug)
257
+ if (!communityToolId) {
258
+ throw new FeedbackError(
259
+ 'not-found',
260
+ `Unknown community tool: ${communityToolSlug}`,
261
+ )
262
+ }
263
+ }
264
+ const onEntry = !!resourceId || !!communityToolId
212
265
 
213
266
  const row = await getDbClient().$transaction(async (tx) => {
214
267
  const created = await tx.dbFeedback.create({
215
268
  data: {
216
269
  resourceId,
217
- subject: resourceId
270
+ communityToolId,
271
+ subject: onEntry
218
272
  ? null
219
273
  : stripHiddenText(subject!).trim().slice(0, MAX_SUBJECT_LENGTH),
220
274
  authorHash: actor.hash,
221
275
  authorAlias: actor.alias,
276
+ authorUserId: user?.id ?? null,
277
+ authorName: user?.name ?? null,
222
278
  body: normaliseBody(body),
223
279
  },
224
280
  select: { id: true },
@@ -258,7 +314,7 @@ export async function addFeedback(
258
314
  async function loadOwnForEdit(id: string, actor: Actor) {
259
315
  const row = await getDbClient().dbFeedback.findUnique({ where: { id } })
260
316
  if (!row) throw new FeedbackError('not-found', 'This no longer exists')
261
- if (row.authorHash !== actor.hash) {
317
+ if (row.kind === MARKER_KIND || row.authorHash !== actor.hash) {
262
318
  throw new FeedbackError('not-author', 'Only the author can change this')
263
319
  }
264
320
  if (Date.now() > row.createdAt.getTime() + EDIT_WINDOW_MS) {
@@ -299,7 +355,7 @@ export async function dismissFeedback(
299
355
  ): Promise<{ id: string }> {
300
356
  const row = await getDbClient().dbFeedback.findUnique({ where: { id } })
301
357
  if (!row) throw new FeedbackError('not-found', 'This no longer exists')
302
- if (row.authorHash !== actor.hash) {
358
+ if (row.kind === MARKER_KIND || row.authorHash !== actor.hash) {
303
359
  throw new FeedbackError('not-author', 'Only the author can withdraw this')
304
360
  }
305
361
  if (row.status !== null) {
@@ -9,6 +9,8 @@ import type { Resource } from '../types/common/appCatalogTypes'
9
9
  import type { BetterAuth } from '../modules/auth/auth'
10
10
  import { createAuthRouter } from '../modules/auth/authRouter.js'
11
11
  import { createFeedbackRouter } from '../modules/feedback/feedbackRouter'
12
+ import { createCommunityToolsRouter } from '../modules/communityTools/router'
13
+ import type { CommunityToolsOptions } from '../middleware/types'
12
14
  import { publicProcedure, router, t } from './trpcSetup'
13
15
  import { z } from 'zod'
14
16
 
@@ -35,7 +37,10 @@ const updateAppInputSchema = z.object({
35
37
  */
36
38
  export function createTrpcRouter(
37
39
  auth?: BetterAuth,
38
- options?: { devLoginEnabled?: boolean },
40
+ options?: {
41
+ devLoginEnabled?: boolean
42
+ communityTools?: CommunityToolsOptions
43
+ },
39
44
  ) {
40
45
  return router({
41
46
  appCatalog: router({
@@ -69,6 +74,8 @@ export function createTrpcRouter(
69
74
 
70
75
  feedback: createFeedbackRouter(),
71
76
 
77
+ communityTools: createCommunityToolsRouter(options?.communityTools),
78
+
72
79
  // Auth routes (requires auth instance)
73
80
  auth: createAuthRouter(t, auth, options),
74
81
  })