@igstack/app-catalog-backend-core 6.3.0 → 6.4.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 (106) hide show
  1. package/dist/db/syncAppCatalog.mjs +1 -0
  2. package/dist/db/syncAppCatalog.mjs.map +1 -1
  3. package/dist/generated/prisma/client.mjs.map +1 -1
  4. package/dist/generated/prisma/commonInputTypes.d.mts +72 -72
  5. package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
  6. package/dist/generated/prisma/internal/class.d.mts +17 -5
  7. package/dist/generated/prisma/internal/class.d.mts.map +1 -1
  8. package/dist/generated/prisma/internal/class.mjs +4 -4
  9. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  10. package/dist/generated/prisma/internal/prismaNamespace.d.mts +145 -51
  11. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  12. package/dist/generated/prisma/models/DbApprovalMethod.d.mts +30 -3
  13. package/dist/generated/prisma/models/DbApprovalMethod.d.mts.map +1 -1
  14. package/dist/generated/prisma/models/DbAsset.d.mts +2 -11
  15. package/dist/generated/prisma/models/DbAsset.d.mts.map +1 -1
  16. package/dist/generated/prisma/models/DbFeedback.d.mts +1565 -0
  17. package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -0
  18. package/dist/generated/prisma/models/DbFeedbackAttachment.d.mts +1431 -0
  19. package/dist/generated/prisma/models/DbFeedbackAttachment.d.mts.map +1 -0
  20. package/dist/generated/prisma/models/DbResource.d.mts +108 -60
  21. package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
  22. package/dist/generated/prisma/models/SourceReference.d.mts +1 -1
  23. package/dist/generated/prisma/models.d.mts +4 -3
  24. package/dist/index.d.mts +5 -3
  25. package/dist/index.mjs +5 -3
  26. package/dist/middleware/createAcMiddleware.mjs +2 -2
  27. package/dist/middleware/createAcMiddleware.mjs.map +1 -1
  28. package/dist/middleware/featureRegistry.mjs +3 -1
  29. package/dist/middleware/featureRegistry.mjs.map +1 -1
  30. package/dist/modules/appCatalog/service.d.mts.map +1 -1
  31. package/dist/modules/appCatalog/service.mjs +1 -0
  32. package/dist/modules/appCatalog/service.mjs.map +1 -1
  33. package/dist/modules/feedback/feedbackAttachmentController.d.mts +22 -0
  34. package/dist/modules/feedback/feedbackAttachmentController.d.mts.map +1 -0
  35. package/dist/modules/feedback/feedbackAttachmentController.mjs +192 -0
  36. package/dist/modules/feedback/feedbackAttachmentController.mjs.map +1 -0
  37. package/dist/modules/feedback/feedbackRouter.d.mts +54 -0
  38. package/dist/modules/feedback/feedbackRouter.d.mts.map +1 -0
  39. package/dist/modules/feedback/feedbackRouter.mjs +59 -0
  40. package/dist/modules/feedback/feedbackRouter.mjs.map +1 -0
  41. package/dist/modules/feedback/service.d.mts +42 -0
  42. package/dist/modules/feedback/service.d.mts.map +1 -0
  43. package/dist/modules/feedback/service.mjs +165 -0
  44. package/dist/modules/feedback/service.mjs.map +1 -0
  45. package/dist/modules/visitor/visitorIdentity.d.mts +86 -0
  46. package/dist/modules/visitor/visitorIdentity.d.mts.map +1 -0
  47. package/dist/modules/{comments → visitor}/visitorIdentity.mjs +53 -2
  48. package/dist/modules/visitor/visitorIdentity.mjs.map +1 -0
  49. package/dist/server/acTrpcContext.d.mts +8 -7
  50. package/dist/server/acTrpcContext.d.mts.map +1 -1
  51. package/dist/server/acTrpcContext.mjs +2 -2
  52. package/dist/server/acTrpcContext.mjs.map +1 -1
  53. package/dist/server/controller.d.mts +15 -8
  54. package/dist/server/controller.d.mts.map +1 -1
  55. package/dist/server/controller.mjs +2 -2
  56. package/dist/server/controller.mjs.map +1 -1
  57. package/dist/types/common/appCatalogTypes.d.mts +12 -0
  58. package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
  59. package/dist/types/common/approvalMethodTypes.d.mts +12 -0
  60. package/dist/types/common/approvalMethodTypes.d.mts.map +1 -1
  61. package/package.json +5 -5
  62. package/prisma/migrations/20261004000000_feedback_replaces_comments/migration.sql +74 -0
  63. package/prisma/schema.prisma +84 -24
  64. package/src/__tests__/feedbackAttachmentUpload.test.ts +192 -0
  65. package/src/__tests__/{comments.test.ts → visitorIdentity.test.ts} +1 -1
  66. package/src/db/syncAppCatalog.ts +1 -0
  67. package/src/generated/prisma/browser.ts +22 -7
  68. package/src/generated/prisma/client.ts +22 -7
  69. package/src/generated/prisma/commonInputTypes.ts +85 -85
  70. package/src/generated/prisma/internal/class.ts +18 -8
  71. package/src/generated/prisma/internal/prismaNamespace.ts +143 -47
  72. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +24 -3
  73. package/src/generated/prisma/models/DbApprovalMethod.ts +29 -1
  74. package/src/generated/prisma/models/DbAsset.ts +0 -12
  75. package/src/generated/prisma/models/DbFeedback.ts +1814 -0
  76. package/src/generated/prisma/models/DbFeedbackAttachment.ts +1661 -0
  77. package/src/generated/prisma/models/DbResource.ts +107 -58
  78. package/src/generated/prisma/models.ts +2 -1
  79. package/src/index.ts +24 -4
  80. package/src/middleware/createAcMiddleware.ts +4 -3
  81. package/src/middleware/featureRegistry.ts +7 -0
  82. package/src/modules/appCatalog/service.ts +1 -0
  83. package/src/modules/feedback/feedbackAttachmentController.ts +248 -0
  84. package/src/modules/feedback/feedbackRouter.ts +102 -0
  85. package/src/modules/feedback/service.ts +295 -0
  86. package/src/modules/{comments → visitor}/visitorIdentity.ts +84 -0
  87. package/src/server/acTrpcContext.ts +9 -8
  88. package/src/server/controller.ts +2 -2
  89. package/src/types/common/appCatalogTypes.ts +12 -0
  90. package/src/types/common/approvalMethodTypes.ts +12 -0
  91. package/dist/generated/prisma/models/DbComment.d.mts +0 -1340
  92. package/dist/generated/prisma/models/DbComment.d.mts.map +0 -1
  93. package/dist/modules/comments/commentsRouter.d.mts +0 -47
  94. package/dist/modules/comments/commentsRouter.d.mts.map +0 -1
  95. package/dist/modules/comments/commentsRouter.mjs +0 -55
  96. package/dist/modules/comments/commentsRouter.mjs.map +0 -1
  97. package/dist/modules/comments/service.d.mts +0 -40
  98. package/dist/modules/comments/service.d.mts.map +0 -1
  99. package/dist/modules/comments/service.mjs +0 -88
  100. package/dist/modules/comments/service.mjs.map +0 -1
  101. package/dist/modules/comments/visitorIdentity.d.mts +0 -15
  102. package/dist/modules/comments/visitorIdentity.d.mts.map +0 -1
  103. package/dist/modules/comments/visitorIdentity.mjs.map +0 -1
  104. package/src/generated/prisma/models/DbComment.ts +0 -1561
  105. package/src/modules/comments/commentsRouter.ts +0 -85
  106. package/src/modules/comments/service.ts +0 -174
@@ -17,7 +17,8 @@ export type * from './models/DbGroup'
17
17
  export type * from './models/DbGroupMembership'
18
18
  export type * from './models/DbApprovalMethod'
19
19
  export type * from './models/DbResource'
20
- export type * from './models/DbComment'
20
+ export type * from './models/DbFeedback'
21
+ export type * from './models/DbFeedbackAttachment'
21
22
  export type * from './models/DbAppTagDefinition'
22
23
  export type * from './models/DbAsset'
23
24
  export type * from './models/Source'
package/src/index.ts CHANGED
@@ -27,14 +27,29 @@ export { registerAuthRoutes } from './modules/auth/registerAuthRoutes'
27
27
  export { createAuthRouter, type AuthRouter } from './modules/auth/authRouter'
28
28
 
29
29
  // Comments
30
- // CommentView is part of the router's inferred type, so it has to be nameable
30
+ // FeedbackView is part of the router's inferred type, so it has to be nameable
31
31
  // from outside the package or every consumer's `useTRPC` becomes unportable.
32
- export { createCommentsRouter } from './modules/comments/commentsRouter'
32
+ export { createFeedbackRouter } from './modules/feedback/feedbackRouter'
33
33
  export {
34
34
  EDIT_WINDOW_MS,
35
+ MAX_ATTACHMENTS,
35
36
  MAX_BODY_LENGTH,
36
- type CommentView,
37
- } from './modules/comments/service'
37
+ MAX_SUBJECT_LENGTH,
38
+ type FeedbackList,
39
+ type FeedbackView,
40
+ } from './modules/feedback/service'
41
+ // The identity every surface shares — rows store `hash`, clients may hold `publicId`,
42
+ // and `visitorFromSeed` is how a test or a dev session becomes a chosen visitor.
43
+ export {
44
+ publicIdFromHash,
45
+ resolveActor,
46
+ resolveVisitor,
47
+ visitorFromSeed,
48
+ VISITOR_COOKIE,
49
+ type Actor,
50
+ type ActorKind,
51
+ type Visitor,
52
+ } from './modules/visitor/visitorIdentity'
38
53
 
39
54
  // Icon management
40
55
  export {
@@ -60,6 +75,11 @@ export {
60
75
  type ScreenshotRestControllerConfig,
61
76
  } from './modules/assets/screenshotRestController'
62
77
 
78
+ export {
79
+ registerFeedbackAttachmentController,
80
+ type FeedbackAttachmentControllerConfig,
81
+ } from './modules/feedback/feedbackAttachmentController'
82
+
63
83
  export { syncAssets, type SyncAssetsConfig } from './modules/assets/syncAssets'
64
84
 
65
85
  // App Catalog utilities
@@ -12,7 +12,7 @@ import { createTrpcRouter } from '../server/controller'
12
12
  import { createAcTrpcContext } from '../server/acTrpcContext'
13
13
  import { createAuth } from '../modules/auth/auth'
14
14
  import { createMockUserFromDevConfig } from '../modules/auth/devMockUserUtils'
15
- import { resolveVisitor } from '../modules/comments/visitorIdentity'
15
+ import { resolveActor } from '../modules/visitor/visitorIdentity'
16
16
 
17
17
  export async function createAcMiddleware(
18
18
  options: AcMiddlewareOptions,
@@ -82,8 +82,9 @@ export async function createAcMiddleware(
82
82
  user,
83
83
  isAdmin,
84
84
  // Issued here, on every request, so the cookie already exists by the time
85
- // someone writes a comment.
86
- visitor: resolveVisitor(req, res),
85
+ // someone sends feedback. Falls back to the anonymous browser when there is no
86
+ // signed-in user, which today is always.
87
+ actor: resolveActor(req, res, user?.id ?? null),
87
88
  })
88
89
  }
89
90
 
@@ -8,6 +8,7 @@ import type {
8
8
  import { registerIconRestController } from '../modules/icons/iconRestController'
9
9
  import { registerAssetRestController } from '../modules/assets/assetRestController'
10
10
  import { registerScreenshotRestController } from '../modules/assets/screenshotRestController'
11
+ import { registerFeedbackAttachmentController } from '../modules/feedback/feedbackAttachmentController'
11
12
  import { createMockSessionResponse } from '../modules/auth/devMockUserUtils'
12
13
  import { getCookie } from '../utils/cookies'
13
14
 
@@ -132,6 +133,12 @@ export function registerFeatures(
132
133
  basePath: `${basePath}/screenshots`,
133
134
  })
134
135
 
136
+ // Images on a correction request. Kept off the asset endpoints on purpose — these
137
+ // are visitor uploads and must not share a namespace with published artwork.
138
+ registerFeedbackAttachmentController(router, {
139
+ basePath: `${basePath}/feedback-attachments`,
140
+ })
141
+
135
142
  // Optional toggleable features
136
143
  const toggles = options.features || {}
137
144
 
@@ -205,6 +205,7 @@ function rowToResource(row: ResourceRowWithSourceRefs): Resource {
205
205
  urlIssues,
206
206
  tiers,
207
207
  quickJumps,
208
+ childrenLabel: row.childrenLabel ?? undefined,
208
209
  // Fields from former SubResource
209
210
  parentSlug: row.parentSlug ?? undefined,
210
211
  tier: row.tier ?? undefined,
@@ -0,0 +1,248 @@
1
+ import type { Request, RequestHandler, Response, Router } from 'express'
2
+ import multer from 'multer'
3
+ import sharp from 'sharp'
4
+ import { getDbClient } from '../../db'
5
+ import {
6
+ isNotModified,
7
+ setRevalidatingCacheHeaders,
8
+ } from '../assets/assetCache'
9
+ import { generateChecksum } from '../assets/assetUtils'
10
+ import { resolveActor } from '../visitor/visitorIdentity'
11
+ import { MAX_ATTACHMENTS } from './service'
12
+
13
+ /** Long edge, in pixels. A correction request needs a readable screenshot, not a print. */
14
+ const MAX_EDGE = 2000
15
+
16
+ /** Per upload. Generous for a screenshot; multer rejects above it before we decode. */
17
+ const MAX_UPLOAD_BYTES = 8 * 1024 * 1024
18
+
19
+ /** Uploads one browser may make per window, and the window. */
20
+ const RATE_LIMIT = 20
21
+ const RATE_WINDOW_MS = 60 * 60 * 1000
22
+
23
+ /** Orphans older than this are swept — see the sweep below. */
24
+ const ORPHAN_MAX_AGE_MS = 24 * 60 * 60 * 1000
25
+
26
+ /**
27
+ * Everything is re-encoded to this one format, so the bytes we store are always
28
+ * produced by our own encoder from decoded pixels. That is the actual defence: it
29
+ * drops EXIF, colour profiles, trailing data and anything polyglot that a
30
+ * content-type header would have happily waved through.
31
+ */
32
+ const STORED_MIME = 'image/webp'
33
+
34
+ const upload = multer({
35
+ storage: multer.memoryStorage(),
36
+ limits: { fileSize: MAX_UPLOAD_BYTES, files: MAX_ATTACHMENTS },
37
+ })
38
+
39
+ /**
40
+ * Multer's own errors, answered as JSON.
41
+ *
42
+ * Handed straight to express they reach the default error page — an HTML stack trace
43
+ * carrying absolute server paths, from an endpoint any passer-by can POST to. The
44
+ * limits are also the first thing a person hits by accident, so they deserve a
45
+ * sentence rather than a 500.
46
+ */
47
+ const acceptImages: RequestHandler = (req, res, next) => {
48
+ upload.array('image', MAX_ATTACHMENTS)(req, res, (err: unknown) => {
49
+ if (err instanceof multer.MulterError) {
50
+ const message =
51
+ err.code === 'LIMIT_FILE_COUNT'
52
+ ? `Up to ${MAX_ATTACHMENTS} images`
53
+ : err.code === 'LIMIT_FILE_SIZE'
54
+ ? `Each image must be under ${MAX_UPLOAD_BYTES / 1024 / 1024}MB`
55
+ : err.code === 'LIMIT_UNEXPECTED_FILE'
56
+ ? 'Unexpected upload field'
57
+ : 'Could not read the upload'
58
+ res.status(400).json({ error: message })
59
+ return
60
+ }
61
+ if (err) {
62
+ console.error('Error reading feedback attachment upload:', err)
63
+ res.status(500).json({ error: 'Failed to upload image' })
64
+ return
65
+ }
66
+ next()
67
+ })
68
+ }
69
+
70
+ /**
71
+ * ponytail: in-memory window, so the budget is per process rather than per browser
72
+ * across a replica set. Move to a shared store if this ever runs more than one
73
+ * replica and the abuse is real; for a form behind the office network it is enough
74
+ * to stop a script, which is all it is for.
75
+ */
76
+ const recentUploads = new Map<string, number[]>()
77
+
78
+ function overRateLimit(actorHash: string): boolean {
79
+ const now = Date.now()
80
+ const kept = (recentUploads.get(actorHash) ?? []).filter(
81
+ (at) => now - at < RATE_WINDOW_MS,
82
+ )
83
+ if (kept.length >= RATE_LIMIT) {
84
+ recentUploads.set(actorHash, kept)
85
+ return true
86
+ }
87
+ kept.push(now)
88
+ recentUploads.set(actorHash, kept)
89
+ return false
90
+ }
91
+
92
+ /**
93
+ * Delete images nobody ever submitted. Opportunistic rather than scheduled: an
94
+ * abandoned form is the only thing that creates them, so the cheapest place to notice
95
+ * is the next upload, and there is no cron to own.
96
+ */
97
+ async function sweepOrphans(): Promise<void> {
98
+ try {
99
+ await getDbClient().dbFeedbackAttachment.deleteMany({
100
+ where: {
101
+ feedbackId: null,
102
+ createdAt: { lt: new Date(Date.now() - ORPHAN_MAX_AGE_MS) },
103
+ },
104
+ })
105
+ } catch (error) {
106
+ // A failed sweep must never fail the upload that triggered it.
107
+ console.error('Error sweeping orphaned feedback attachments:', error)
108
+ }
109
+ }
110
+
111
+ export interface FeedbackAttachmentControllerConfig {
112
+ /** Base path for the endpoints (e.g. '/api/feedback-attachments'). */
113
+ basePath: string
114
+ }
115
+
116
+ /**
117
+ * Upload and retrieval for the images on a correction request.
118
+ *
119
+ * These are deliberately NOT `DbAsset`: that table's `name` is unique and holds
120
+ * published catalog artwork, so letting a passing visitor write into it would mean
121
+ * untrusted uploads sharing a namespace with the icons the catalog serves.
122
+ *
123
+ * Endpoints:
124
+ * - POST {basePath}/upload - up to MAX_ATTACHMENTS images (multipart, field `image`)
125
+ * - GET {basePath}/:id - the stored image
126
+ */
127
+ export function registerFeedbackAttachmentController(
128
+ router: Router,
129
+ config: FeedbackAttachmentControllerConfig,
130
+ ): void {
131
+ const { basePath } = config
132
+
133
+ router.post(
134
+ `${basePath}/upload`,
135
+ acceptImages,
136
+ async (req: Request, res: Response) => {
137
+ try {
138
+ // Same identity the feedback row gets, and it issues the cookie if this is
139
+ // the visitor's first request — so an upload can be the first thing they do.
140
+ const actor = resolveActor(req, res)
141
+ if (!actor) {
142
+ res.status(412).json({ error: 'Uploading needs cookies enabled' })
143
+ return
144
+ }
145
+
146
+ const files = Array.isArray(req.files) ? req.files : []
147
+ if (files.length === 0) {
148
+ res.status(400).json({ error: 'No image uploaded' })
149
+ return
150
+ }
151
+ if (overRateLimit(actor.hash)) {
152
+ res.status(429).json({ error: 'Too many uploads, try again later' })
153
+ return
154
+ }
155
+
156
+ const prisma = getDbClient()
157
+ const ids: string[] = []
158
+
159
+ for (const file of files) {
160
+ // The declared content type is never consulted. Decoding is the check:
161
+ // if sharp cannot read it as an image, it is not one, whatever the
162
+ // header claimed.
163
+ let content: Buffer
164
+ let width: number | undefined
165
+ let height: number | undefined
166
+ try {
167
+ const out = await sharp(file.buffer, { failOn: 'error' })
168
+ .rotate() // Apply EXIF orientation BEFORE the metadata is dropped.
169
+ .resize({
170
+ width: MAX_EDGE,
171
+ height: MAX_EDGE,
172
+ fit: 'inside',
173
+ withoutEnlargement: true,
174
+ })
175
+ .webp({ quality: 82 })
176
+ .toBuffer({ resolveWithObject: true })
177
+ content = out.data
178
+ width = out.info.width
179
+ height = out.info.height
180
+ } catch {
181
+ res
182
+ .status(400)
183
+ .json({ error: `Not a readable image: ${file.originalname}` })
184
+ return
185
+ }
186
+
187
+ const row = await prisma.dbFeedbackAttachment.create({
188
+ data: {
189
+ authorHash: actor.hash,
190
+ // Prisma's Bytes wants a plain view; sharp hands back a Buffer whose
191
+ // backing store it will not accept as-is.
192
+ content: new Uint8Array(content),
193
+ mimeType: STORED_MIME,
194
+ fileSize: content.byteLength,
195
+ checksum: generateChecksum(content),
196
+ width,
197
+ height,
198
+ // Kept for a maintainer to read, never used as a path or a key.
199
+ originalFilename: file.originalname.slice(0, 200),
200
+ },
201
+ select: { id: true },
202
+ })
203
+ ids.push(row.id)
204
+ }
205
+
206
+ void sweepOrphans()
207
+ res.status(201).json({ ids })
208
+ } catch (error) {
209
+ console.error('Error uploading feedback attachment:', error)
210
+ res.status(500).json({ error: 'Failed to upload image' })
211
+ }
212
+ },
213
+ )
214
+
215
+ router.get(`${basePath}/:id`, async (req: Request, res: Response) => {
216
+ try {
217
+ const attachment = await getDbClient().dbFeedbackAttachment.findUnique({
218
+ where: { id: req.params['id'] ?? '' },
219
+ select: {
220
+ content: true,
221
+ mimeType: true,
222
+ checksum: true,
223
+ originalFilename: true,
224
+ },
225
+ })
226
+ if (!attachment) {
227
+ res.status(404).json({ error: 'Image not found' })
228
+ return
229
+ }
230
+
231
+ const etag = setRevalidatingCacheHeaders(res, attachment.checksum)
232
+ if (isNotModified(req, etag)) {
233
+ res.status(304).end()
234
+ return
235
+ }
236
+
237
+ res.setHeader('Content-Type', attachment.mimeType)
238
+ // Never inline-rendered as a document and never sniffed into one: this is the
239
+ // one place the catalog serves bytes a stranger supplied.
240
+ res.setHeader('X-Content-Type-Options', 'nosniff')
241
+ res.setHeader('Content-Disposition', 'inline')
242
+ res.send(Buffer.from(attachment.content))
243
+ } catch (error) {
244
+ console.error('Error fetching feedback attachment:', error)
245
+ res.status(500).json({ error: 'Failed to fetch image' })
246
+ }
247
+ })
248
+ }
@@ -0,0 +1,102 @@
1
+ import { TRPCError } from '@trpc/server'
2
+ import { z } from 'zod'
3
+ import { publicProcedure, router } from '../../server/trpcSetup'
4
+ import type { Actor } from '../visitor/visitorIdentity'
5
+ import {
6
+ FeedbackError,
7
+ MAX_ATTACHMENTS,
8
+ MAX_BODY_LENGTH,
9
+ MAX_SUBJECT_LENGTH,
10
+ addFeedback,
11
+ dismissFeedback,
12
+ editFeedback,
13
+ listFeedback,
14
+ listMyFeedback,
15
+ } from './service'
16
+
17
+ /**
18
+ * Writing needs a visitor token, and the token is issued on the request that reads the
19
+ * list — so by the time anyone can click Send, the cookie exists. A missing one here
20
+ * means cookies are blocked, which no error code describes well; PRECONDITION_FAILED at
21
+ * least separates it from a permission problem.
22
+ */
23
+ function requireActor(actor: Actor | null): Actor {
24
+ if (!actor) {
25
+ throw new TRPCError({
26
+ code: 'PRECONDITION_FAILED',
27
+ message: 'Sending this needs cookies enabled',
28
+ })
29
+ }
30
+ return actor
31
+ }
32
+
33
+ const REASON_CODES = {
34
+ 'not-found': 'NOT_FOUND',
35
+ 'not-author': 'FORBIDDEN',
36
+ 'window-closed': 'FORBIDDEN',
37
+ 'already-reviewed': 'FORBIDDEN',
38
+ empty: 'BAD_REQUEST',
39
+ 'too-many-attachments': 'BAD_REQUEST',
40
+ } as const
41
+
42
+ async function mapErrors<T>(work: () => Promise<T>): Promise<T> {
43
+ try {
44
+ return await work()
45
+ } catch (error) {
46
+ if (error instanceof FeedbackError) {
47
+ throw new TRPCError({
48
+ code: REASON_CODES[error.reason],
49
+ message: error.message,
50
+ })
51
+ }
52
+ throw error
53
+ }
54
+ }
55
+
56
+ export function createFeedbackRouter() {
57
+ return router({
58
+ /** One entry's thread, plus its unreviewed count so the badge needs no second call. */
59
+ list: publicProcedure
60
+ .input(z.object({ resourceSlug: z.string() }))
61
+ .query(({ input, ctx }) => listFeedback(input.resourceSlug, ctx.actor)),
62
+
63
+ /** Everything this browser filed, for the tracker. Empty means render no button. */
64
+ mine: publicProcedure.query(({ ctx }) => listMyFeedback(ctx.actor)),
65
+
66
+ add: publicProcedure
67
+ .input(
68
+ z
69
+ .object({
70
+ resourceSlug: z.string().optional(),
71
+ subject: z.string().max(MAX_SUBJECT_LENGTH).optional(),
72
+ // Both optional on purpose: a bare flag with no words and no image is the
73
+ // cheapest signal a passing visitor can send, and still worth having.
74
+ body: z.string().max(MAX_BODY_LENGTH).optional(),
75
+ attachmentIds: z.array(z.string()).max(MAX_ATTACHMENTS).optional(),
76
+ })
77
+ .refine((v) => !!v.resourceSlug || !!v.subject?.trim(), {
78
+ message: 'Needs either an entry or a subject',
79
+ }),
80
+ )
81
+ .mutation(({ input, ctx }) =>
82
+ mapErrors(() => addFeedback(input, requireActor(ctx.actor))),
83
+ ),
84
+
85
+ edit: publicProcedure
86
+ .input(
87
+ z.object({ id: z.string(), body: z.string().max(MAX_BODY_LENGTH) }),
88
+ )
89
+ .mutation(({ input, ctx }) =>
90
+ mapErrors(() =>
91
+ editFeedback(input.id, input.body, requireActor(ctx.actor)),
92
+ ),
93
+ ),
94
+
95
+ /** Withdraw your own, for as long as nobody has reviewed it. */
96
+ dismiss: publicProcedure
97
+ .input(z.object({ id: z.string() }))
98
+ .mutation(({ input, ctx }) =>
99
+ mapErrors(() => dismissFeedback(input.id, requireActor(ctx.actor))),
100
+ ),
101
+ })
102
+ }