@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
@@ -0,0 +1,295 @@
1
+ import { getDbClient } from '../../db/client'
2
+ import type { Actor } from '../visitor/visitorIdentity'
3
+
4
+ /** How long an author may edit their own wording. */
5
+ export const EDIT_WINDOW_MS = 60 * 60 * 1000
6
+
7
+ export const MAX_BODY_LENGTH = 1000
8
+ export const MAX_SUBJECT_LENGTH = 120
9
+ export const MAX_ATTACHMENTS = 3
10
+
11
+ /** One piece of feedback as a client sees it. Never carries an author hash. */
12
+ export interface FeedbackView {
13
+ id: string
14
+ authorName: string
15
+ /** Null when the visitor sent a bare flag with no words. */
16
+ body: string | null
17
+ /** What they were looking for, on a catalog-level ask. Null on entry feedback. */
18
+ subject: string | null
19
+ /** Slug of the entry this is about, or null for a catalog-level ask. */
20
+ resourceSlug: string | null
21
+ /** Display name of that entry, so the tracker can label a row without a second query. */
22
+ resourceName: string | null
23
+ attachmentIds: string[]
24
+ status: 'applied' | 'acknowledged' | null
25
+ reviewerReply: string | null
26
+ /** When a maintainer last touched it — what "unread" is computed against. */
27
+ reviewedAt: string | null
28
+ createdAt: string
29
+ editedAt: string | null
30
+ /** Whether this browser wrote it — what makes Edit and Dismiss appear. */
31
+ isMine: boolean
32
+ /**
33
+ * The absolute instant this browser's edit window closes, or null when it is not
34
+ * theirs. Absolute rather than a remaining duration so a client with a skewed clock
35
+ * hides the control at the same moment the server starts refusing the edit.
36
+ */
37
+ canEditUntil: string | null
38
+ }
39
+
40
+ type Row = {
41
+ id: string
42
+ authorHash: string
43
+ authorAlias: string
44
+ body: string | null
45
+ subject: string | null
46
+ status: 'applied' | 'acknowledged' | null
47
+ reviewerReply: string | null
48
+ reviewedAt: Date | null
49
+ editedAt: Date | null
50
+ createdAt: Date
51
+ resource?: { slug: string; displayName: string } | null
52
+ attachments?: { id: string }[]
53
+ }
54
+
55
+ function toView(row: Row, actor: Actor | null): FeedbackView {
56
+ const isMine = !!actor && row.authorHash === actor.hash
57
+ return {
58
+ id: row.id,
59
+ authorName: row.authorAlias,
60
+ body: row.body,
61
+ subject: row.subject,
62
+ resourceSlug: row.resource?.slug ?? null,
63
+ resourceName: row.resource?.displayName ?? null,
64
+ attachmentIds: (row.attachments ?? []).map((a) => a.id),
65
+ status: row.status,
66
+ reviewerReply: row.reviewerReply,
67
+ reviewedAt: row.reviewedAt?.toISOString() ?? null,
68
+ createdAt: row.createdAt.toISOString(),
69
+ editedAt: row.editedAt?.toISOString() ?? null,
70
+ isMine,
71
+ canEditUntil: isMine
72
+ ? new Date(row.createdAt.getTime() + EDIT_WINDOW_MS).toISOString()
73
+ : null,
74
+ }
75
+ }
76
+
77
+ const WITH_RELATIONS = {
78
+ resource: { select: { slug: true, displayName: true } },
79
+ attachments: { select: { id: true } },
80
+ } as const
81
+
82
+ async function resourceIdForSlug(slug: string): Promise<string | null> {
83
+ const resource = await getDbClient().dbResource.findUnique({
84
+ where: { slug },
85
+ select: { id: true },
86
+ })
87
+ return resource?.id ?? null
88
+ }
89
+
90
+ export interface FeedbackList {
91
+ items: FeedbackView[]
92
+ /** Unreviewed count — what the badge shows. `status IS NULL` is the whole definition. */
93
+ openCount: number
94
+ }
95
+
96
+ /** Oldest first: a thread reads top to bottom. */
97
+ export async function listFeedback(
98
+ resourceSlug: string,
99
+ actor: Actor | null,
100
+ ): Promise<FeedbackList> {
101
+ const resourceId = await resourceIdForSlug(resourceSlug)
102
+ if (!resourceId) return { items: [], openCount: 0 }
103
+
104
+ const rows = await getDbClient().dbFeedback.findMany({
105
+ where: { resourceId },
106
+ orderBy: { createdAt: 'asc' },
107
+ include: WITH_RELATIONS,
108
+ })
109
+ return {
110
+ items: rows.map((row) => toView(row, actor)),
111
+ openCount: rows.filter((row) => row.status === null).length,
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Everything this browser filed, across entries, for the tracker.
117
+ *
118
+ * Sorted the way the tracker renders: unreviewed first, then newest. Postgres sorts
119
+ * NULLs last by default, so `status` ascending would bury the pending rows — hence the
120
+ * explicit two-key ordering rather than relying on the default.
121
+ */
122
+ export async function listMyFeedback(
123
+ actor: Actor | null,
124
+ ): Promise<FeedbackView[]> {
125
+ if (!actor) return []
126
+ const rows = await getDbClient().dbFeedback.findMany({
127
+ where: { authorHash: actor.hash },
128
+ orderBy: [
129
+ { status: { sort: 'asc', nulls: 'first' } },
130
+ { createdAt: 'desc' },
131
+ ],
132
+ include: WITH_RELATIONS,
133
+ })
134
+ return rows.map((row) => toView(row, actor))
135
+ }
136
+
137
+ export class FeedbackError extends Error {
138
+ constructor(
139
+ readonly reason:
140
+ | 'not-found'
141
+ | 'not-author'
142
+ | 'window-closed'
143
+ | 'already-reviewed'
144
+ | 'empty'
145
+ | 'too-many-attachments',
146
+ message: string,
147
+ ) {
148
+ super(message)
149
+ }
150
+ }
151
+
152
+ function normaliseBody(body: string | undefined | null): string | null {
153
+ if (body === undefined || body === null) return null
154
+ const trimmed = body.trim()
155
+ // A bare flag is a valid signal, so empty is stored as absent rather than refused.
156
+ return trimmed ? trimmed.slice(0, MAX_BODY_LENGTH) : null
157
+ }
158
+
159
+ export interface AddFeedbackInput {
160
+ /** Omitted for a catalog-level ask. */
161
+ resourceSlug?: string
162
+ /** Required when there is no resourceSlug — it is the substance of the ask. */
163
+ subject?: string
164
+ body?: string
165
+ attachmentIds?: string[]
166
+ }
167
+
168
+ export async function addFeedback(
169
+ input: AddFeedbackInput,
170
+ actor: Actor,
171
+ ): Promise<FeedbackView> {
172
+ const { resourceSlug, subject, body, attachmentIds = [] } = input
173
+
174
+ if (!resourceSlug && !subject?.trim()) {
175
+ throw new FeedbackError(
176
+ 'empty',
177
+ 'Tell us which entry this is about, or what you were looking for',
178
+ )
179
+ }
180
+ if (attachmentIds.length > MAX_ATTACHMENTS) {
181
+ throw new FeedbackError(
182
+ 'too-many-attachments',
183
+ `Up to ${MAX_ATTACHMENTS} images`,
184
+ )
185
+ }
186
+
187
+ let resourceId: string | null = null
188
+ if (resourceSlug) {
189
+ resourceId = await resourceIdForSlug(resourceSlug)
190
+ if (!resourceId) {
191
+ throw new FeedbackError('not-found', `Unknown resource: ${resourceSlug}`)
192
+ }
193
+ }
194
+
195
+ const row = await getDbClient().$transaction(async (tx) => {
196
+ const created = await tx.dbFeedback.create({
197
+ data: {
198
+ resourceId,
199
+ subject: resourceId
200
+ ? null
201
+ : subject!.trim().slice(0, MAX_SUBJECT_LENGTH),
202
+ authorHash: actor.hash,
203
+ authorAlias: actor.alias,
204
+ body: normaliseBody(body),
205
+ },
206
+ select: { id: true },
207
+ })
208
+
209
+ // Claim only images this browser uploaded and nobody has claimed yet, so an id
210
+ // guessed from someone else's upload cannot be pulled onto your feedback.
211
+ //
212
+ // `updateMany` with the conditions in the WHERE, rather than a nested `connect`:
213
+ // `connect` takes only the id, and because `feedbackId` is a single FK it would
214
+ // MOVE a claimed image off its owner's feedback instead of refusing.
215
+ if (attachmentIds.length > 0) {
216
+ await tx.dbFeedbackAttachment.updateMany({
217
+ where: {
218
+ id: { in: attachmentIds },
219
+ authorHash: actor.hash,
220
+ feedbackId: null,
221
+ },
222
+ data: { feedbackId: created.id },
223
+ })
224
+ }
225
+
226
+ return tx.dbFeedback.findUniqueOrThrow({
227
+ where: { id: created.id },
228
+ include: WITH_RELATIONS,
229
+ })
230
+ })
231
+ return toView(row, actor)
232
+ }
233
+
234
+ /**
235
+ * Load a row and check this actor may still change its wording.
236
+ *
237
+ * Enforced here, not in the UI: the control disappearing is a courtesy, and a request
238
+ * that arrives a second late must still be refused.
239
+ */
240
+ async function loadOwnForEdit(id: string, actor: Actor) {
241
+ const row = await getDbClient().dbFeedback.findUnique({ where: { id } })
242
+ if (!row) throw new FeedbackError('not-found', 'This no longer exists')
243
+ if (row.authorHash !== actor.hash) {
244
+ throw new FeedbackError('not-author', 'Only the author can change this')
245
+ }
246
+ if (Date.now() > row.createdAt.getTime() + EDIT_WINDOW_MS) {
247
+ throw new FeedbackError(
248
+ 'window-closed',
249
+ 'Wording can only be changed in the first hour',
250
+ )
251
+ }
252
+ return row
253
+ }
254
+
255
+ export async function editFeedback(
256
+ id: string,
257
+ body: string,
258
+ actor: Actor,
259
+ ): Promise<FeedbackView> {
260
+ await loadOwnForEdit(id, actor)
261
+ const row = await getDbClient().dbFeedback.update({
262
+ where: { id },
263
+ data: { body: normaliseBody(body), editedAt: new Date() },
264
+ include: WITH_RELATIONS,
265
+ })
266
+ return toView(row, actor)
267
+ }
268
+
269
+ /**
270
+ * Withdraw your own feedback.
271
+ *
272
+ * Allowed for as long as nobody has reviewed it, with no time limit — the edit window
273
+ * exists to stop someone rewriting words others have already read, and that reasoning
274
+ * does not apply to withdrawing something no maintainer has acted on. Once reviewed it
275
+ * is public record carrying a maintainer's reply, so withdrawing would delete their
276
+ * answer; from then on it is refused.
277
+ */
278
+ export async function dismissFeedback(
279
+ id: string,
280
+ actor: Actor,
281
+ ): Promise<{ id: string }> {
282
+ const row = await getDbClient().dbFeedback.findUnique({ where: { id } })
283
+ if (!row) throw new FeedbackError('not-found', 'This no longer exists')
284
+ if (row.authorHash !== actor.hash) {
285
+ throw new FeedbackError('not-author', 'Only the author can withdraw this')
286
+ }
287
+ if (row.status !== null) {
288
+ throw new FeedbackError(
289
+ 'already-reviewed',
290
+ 'This has been answered, so it stays on the entry',
291
+ )
292
+ }
293
+ await getDbClient().dbFeedback.delete({ where: { id } })
294
+ return { id }
295
+ }
@@ -18,6 +18,25 @@ export interface Visitor {
18
18
  alias: string
19
19
  }
20
20
 
21
+ /** Who the server thinks is acting, whether or not they ever signed in. */
22
+ export type ActorKind = 'user' | 'visitor'
23
+
24
+ /**
25
+ * One identity for every surface that needs one.
26
+ *
27
+ * `hash` is the secret-derived half: it authorises editing and withdrawing, is stored on
28
+ * rows, and never leaves the server. `publicId` is the half a client may hold — it
29
+ * identifies without authorising, which is what analytics needs and what the httpOnly
30
+ * cookie deliberately cannot give it. Same split better-auth already uses between its
31
+ * session cookie and `user.id`.
32
+ */
33
+ export interface Actor {
34
+ hash: string
35
+ publicId: string
36
+ alias: string
37
+ kind: ActorKind
38
+ }
39
+
21
40
  const ADJECTIVES = [
22
41
  'Ancient',
23
42
  'Brave',
@@ -144,3 +163,68 @@ export function resolveVisitor(
144
163
  const hash = hashToken(token)
145
164
  return { hash, alias: aliasForHash(hash) }
146
165
  }
166
+
167
+ /**
168
+ * A non-authorising id derived from the hash.
169
+ *
170
+ * Safe to hand a client: knowing it proves nothing and authorises nothing, because the
171
+ * check is always against the full hash of the cookie the browser actually sent.
172
+ */
173
+ export function publicIdFromHash(hash: string): string {
174
+ return hash.slice(0, 16)
175
+ }
176
+
177
+ /**
178
+ * Who is acting, signed in or not.
179
+ *
180
+ * `userId` is passed in rather than looked up, so this stays synchronous and testable in
181
+ * a node environment — the caller already has the session when it builds the request
182
+ * context. When login arrives this starts returning `kind: 'user'` and nothing
183
+ * downstream has to change.
184
+ */
185
+ export function resolveActor(
186
+ req: { headers: { cookie?: string } },
187
+ res?: Parameters<typeof resolveVisitor>[1],
188
+ userId?: string | null,
189
+ ): Actor | null {
190
+ if (userId) {
191
+ return {
192
+ hash: hashToken(`user:${userId}`),
193
+ publicId: publicIdFromHash(hashToken(`user:${userId}`)),
194
+ alias: aliasForHash(hashToken(`user:${userId}`)),
195
+ kind: 'user',
196
+ }
197
+ }
198
+ const visitor = resolveVisitor(req, res)
199
+ if (!visitor) return null
200
+ return {
201
+ hash: visitor.hash,
202
+ publicId: publicIdFromHash(visitor.hash),
203
+ alias: visitor.alias,
204
+ kind: 'visitor',
205
+ }
206
+ }
207
+
208
+ /**
209
+ * A deterministic identity from a seed, for tests and local development.
210
+ *
211
+ * Returns the raw token *and* its hash, so a database fixture and a browser cookie can
212
+ * refer to the same person: seed rows with `hash`, hand the browser `token`. Without
213
+ * this, nothing scoped to one visitor is testable — the cookie is httpOnly on purpose,
214
+ * so page scripts cannot set it.
215
+ */
216
+ export function visitorFromSeed(seed: string): {
217
+ token: string
218
+ hash: string
219
+ publicId: string
220
+ alias: string
221
+ } {
222
+ const token = createHash('sha256').update(`seed:${seed}`).digest('hex')
223
+ const hash = hashToken(token)
224
+ return {
225
+ token,
226
+ hash,
227
+ publicId: publicIdFromHash(hash),
228
+ alias: aliasForHash(hash),
229
+ }
230
+ }
@@ -1,5 +1,5 @@
1
1
  import type { AppCatalogCompanySpecificBackend } from '../types'
2
- import type { Visitor } from '../modules/comments/visitorIdentity'
2
+ import type { Actor } from '../modules/visitor/visitorIdentity'
3
3
  import type { User } from 'better-auth/types'
4
4
 
5
5
  export interface AcTrpcContext {
@@ -7,25 +7,26 @@ export interface AcTrpcContext {
7
7
  user: User | null
8
8
  isAdmin: boolean
9
9
  /**
10
- * The anonymous browser behind this request, independent of `user` — the catalog
11
- * is browsable logged out, and comments are attributed to this instead. Null when
12
- * the context was built without a response to issue the cookie on.
10
+ * Who is acting on this request — the signed-in user when there is one, otherwise the
11
+ * anonymous browser behind it. The catalog is browsable logged out and feedback is
12
+ * attributed to this rather than to `user`. Null when the context was built without a
13
+ * response to issue the cookie on.
13
14
  */
14
- visitor: Visitor | null
15
+ actor: Actor | null
15
16
  }
16
17
 
17
18
  export interface AcTrpcContextOptions {
18
19
  companySpecificBackend: AppCatalogCompanySpecificBackend
19
20
  user?: User | null
20
21
  isAdmin?: boolean
21
- visitor?: Visitor | null
22
+ actor?: Actor | null
22
23
  }
23
24
 
24
25
  export function createAcTrpcContext({
25
26
  companySpecificBackend,
26
27
  user = null,
27
28
  isAdmin = false,
28
- visitor = null,
29
+ actor = null,
29
30
  }: AcTrpcContextOptions): AcTrpcContext {
30
- return { companySpecificBackend, user, isAdmin, visitor }
31
+ return { companySpecificBackend, user, isAdmin, actor }
31
32
  }
@@ -8,7 +8,7 @@ import type { Resource } from '../types/common/appCatalogTypes'
8
8
 
9
9
  import type { BetterAuth } from '../modules/auth/auth'
10
10
  import { createAuthRouter } from '../modules/auth/authRouter.js'
11
- import { createCommentsRouter } from '../modules/comments/commentsRouter'
11
+ import { createFeedbackRouter } from '../modules/feedback/feedbackRouter'
12
12
  import { publicProcedure, router, t } from './trpcSetup'
13
13
  import { z } from 'zod'
14
14
 
@@ -67,7 +67,7 @@ export function createTrpcRouter(
67
67
  }),
68
68
  }),
69
69
 
70
- comments: createCommentsRouter(),
70
+ feedback: createFeedbackRouter(),
71
71
 
72
72
  // Auth routes (requires auth instance)
73
73
  auth: createAuthRouter(t, auth, options),
@@ -203,6 +203,18 @@ export interface Resource {
203
203
  tiers?: TierVariant[]
204
204
  /** Deep links that take one typed identifier — see {@link QuickJump}. */
205
205
  quickJumps?: QuickJump[]
206
+ /**
207
+ * What this resource's children are called, as a plural noun ("Accounts",
208
+ * "Namespaces", "Databases"). Set on the PARENT, not the children.
209
+ *
210
+ * The detail card names its children tab with this. Children are modelled as
211
+ * plain resources carrying a `parentSlug`, so nothing in the data says what
212
+ * kind of thing they are — and a tab reading "Accounts 430" tells the reader
213
+ * what is in there, where the generic fallback tells them nothing. Omit it and
214
+ * the card says "Resources"; only the handful of entries that actually have
215
+ * children need to set it.
216
+ */
217
+ childrenLabel?: string
206
218
 
207
219
  // --- Fields merged from former SubResource ---
208
220
  /** Slug of parent resource (undefined for top-level applications) */
@@ -57,6 +57,18 @@ export type ApprovalMethod = {
57
57
  * Displayed approval method
58
58
  */
59
59
  displayName: string
60
+ /**
61
+ * One or two words for places that cannot afford `displayName`, above all the
62
+ * detail card's access tab — which names the route it describes, so the reader
63
+ * knows what they are in for before they open it.
64
+ *
65
+ * `displayName` is written to stand alone in prose and routinely will not fit:
66
+ * a name ending in "Bot" or "Portal" repeats what the context already says,
67
+ * and the longest in use runs past thirty characters. Falls back to
68
+ * `displayName` when unset, so this is only worth setting where that reads
69
+ * badly in a tab.
70
+ */
71
+ shortName?: string
60
72
  /**
61
73
  * Optionally - older name of approval method if there were migration in organization.
62
74
  */