@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.
- package/dist/db/syncAppCatalog.mjs +1 -0
- package/dist/db/syncAppCatalog.mjs.map +1 -1
- package/dist/generated/prisma/client.mjs.map +1 -1
- package/dist/generated/prisma/commonInputTypes.d.mts +72 -72
- package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.d.mts +17 -5
- package/dist/generated/prisma/internal/class.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.mjs +4 -4
- package/dist/generated/prisma/internal/class.mjs.map +1 -1
- package/dist/generated/prisma/internal/prismaNamespace.d.mts +145 -51
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbApprovalMethod.d.mts +30 -3
- package/dist/generated/prisma/models/DbApprovalMethod.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbAsset.d.mts +2 -11
- package/dist/generated/prisma/models/DbAsset.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbFeedback.d.mts +1565 -0
- package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbFeedbackAttachment.d.mts +1431 -0
- package/dist/generated/prisma/models/DbFeedbackAttachment.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbResource.d.mts +108 -60
- package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
- package/dist/generated/prisma/models/SourceReference.d.mts +1 -1
- package/dist/generated/prisma/models.d.mts +4 -3
- package/dist/index.d.mts +5 -3
- package/dist/index.mjs +5 -3
- package/dist/middleware/createAcMiddleware.mjs +2 -2
- package/dist/middleware/createAcMiddleware.mjs.map +1 -1
- package/dist/middleware/featureRegistry.mjs +3 -1
- package/dist/middleware/featureRegistry.mjs.map +1 -1
- package/dist/modules/appCatalog/service.d.mts.map +1 -1
- package/dist/modules/appCatalog/service.mjs +1 -0
- package/dist/modules/appCatalog/service.mjs.map +1 -1
- package/dist/modules/feedback/feedbackAttachmentController.d.mts +22 -0
- package/dist/modules/feedback/feedbackAttachmentController.d.mts.map +1 -0
- package/dist/modules/feedback/feedbackAttachmentController.mjs +192 -0
- package/dist/modules/feedback/feedbackAttachmentController.mjs.map +1 -0
- package/dist/modules/feedback/feedbackRouter.d.mts +54 -0
- package/dist/modules/feedback/feedbackRouter.d.mts.map +1 -0
- package/dist/modules/feedback/feedbackRouter.mjs +59 -0
- package/dist/modules/feedback/feedbackRouter.mjs.map +1 -0
- package/dist/modules/feedback/service.d.mts +42 -0
- package/dist/modules/feedback/service.d.mts.map +1 -0
- package/dist/modules/feedback/service.mjs +165 -0
- package/dist/modules/feedback/service.mjs.map +1 -0
- package/dist/modules/visitor/visitorIdentity.d.mts +86 -0
- package/dist/modules/visitor/visitorIdentity.d.mts.map +1 -0
- package/dist/modules/{comments → visitor}/visitorIdentity.mjs +53 -2
- package/dist/modules/visitor/visitorIdentity.mjs.map +1 -0
- package/dist/server/acTrpcContext.d.mts +8 -7
- package/dist/server/acTrpcContext.d.mts.map +1 -1
- package/dist/server/acTrpcContext.mjs +2 -2
- package/dist/server/acTrpcContext.mjs.map +1 -1
- package/dist/server/controller.d.mts +15 -8
- package/dist/server/controller.d.mts.map +1 -1
- package/dist/server/controller.mjs +2 -2
- package/dist/server/controller.mjs.map +1 -1
- package/dist/types/common/appCatalogTypes.d.mts +12 -0
- package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
- package/dist/types/common/approvalMethodTypes.d.mts +12 -0
- package/dist/types/common/approvalMethodTypes.d.mts.map +1 -1
- package/package.json +5 -5
- package/prisma/migrations/20261004000000_feedback_replaces_comments/migration.sql +74 -0
- package/prisma/schema.prisma +84 -24
- package/src/__tests__/feedbackAttachmentUpload.test.ts +192 -0
- package/src/__tests__/{comments.test.ts → visitorIdentity.test.ts} +1 -1
- package/src/db/syncAppCatalog.ts +1 -0
- package/src/generated/prisma/browser.ts +22 -7
- package/src/generated/prisma/client.ts +22 -7
- package/src/generated/prisma/commonInputTypes.ts +85 -85
- package/src/generated/prisma/internal/class.ts +18 -8
- package/src/generated/prisma/internal/prismaNamespace.ts +143 -47
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +24 -3
- package/src/generated/prisma/models/DbApprovalMethod.ts +29 -1
- package/src/generated/prisma/models/DbAsset.ts +0 -12
- package/src/generated/prisma/models/DbFeedback.ts +1814 -0
- package/src/generated/prisma/models/DbFeedbackAttachment.ts +1661 -0
- package/src/generated/prisma/models/DbResource.ts +107 -58
- package/src/generated/prisma/models.ts +2 -1
- package/src/index.ts +24 -4
- package/src/middleware/createAcMiddleware.ts +4 -3
- package/src/middleware/featureRegistry.ts +7 -0
- package/src/modules/appCatalog/service.ts +1 -0
- package/src/modules/feedback/feedbackAttachmentController.ts +248 -0
- package/src/modules/feedback/feedbackRouter.ts +102 -0
- package/src/modules/feedback/service.ts +295 -0
- package/src/modules/{comments → visitor}/visitorIdentity.ts +84 -0
- package/src/server/acTrpcContext.ts +9 -8
- package/src/server/controller.ts +2 -2
- package/src/types/common/appCatalogTypes.ts +12 -0
- package/src/types/common/approvalMethodTypes.ts +12 -0
- package/dist/generated/prisma/models/DbComment.d.mts +0 -1340
- package/dist/generated/prisma/models/DbComment.d.mts.map +0 -1
- package/dist/modules/comments/commentsRouter.d.mts +0 -47
- package/dist/modules/comments/commentsRouter.d.mts.map +0 -1
- package/dist/modules/comments/commentsRouter.mjs +0 -55
- package/dist/modules/comments/commentsRouter.mjs.map +0 -1
- package/dist/modules/comments/service.d.mts +0 -40
- package/dist/modules/comments/service.d.mts.map +0 -1
- package/dist/modules/comments/service.mjs +0 -88
- package/dist/modules/comments/service.mjs.map +0 -1
- package/dist/modules/comments/visitorIdentity.d.mts +0 -15
- package/dist/modules/comments/visitorIdentity.d.mts.map +0 -1
- package/dist/modules/comments/visitorIdentity.mjs.map +0 -1
- package/src/generated/prisma/models/DbComment.ts +0 -1561
- package/src/modules/comments/commentsRouter.ts +0 -85
- 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 {
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
+
actor = null,
|
|
29
30
|
}: AcTrpcContextOptions): AcTrpcContext {
|
|
30
|
-
return { companySpecificBackend, user, isAdmin,
|
|
31
|
+
return { companySpecificBackend, user, isAdmin, actor }
|
|
31
32
|
}
|
package/src/server/controller.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
*/
|