@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.
- package/dist/db/syncAppCatalog.mjs +1 -1
- package/dist/generated/prisma/client.d.mts +1 -1
- package/dist/generated/prisma/client.mjs.map +1 -1
- package/dist/generated/prisma/commonInputTypes.d.mts +33 -3
- package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
- package/dist/generated/prisma/enums.d.mts +6 -1
- package/dist/generated/prisma/enums.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.d.mts +84 -0
- 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 +617 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbCommunityTool.d.mts +2852 -0
- package/dist/generated/prisma/models/DbCommunityTool.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolLike.d.mts +1102 -0
- package/dist/generated/prisma/models/DbCommunityToolLike.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts +1034 -0
- package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts +1034 -0
- package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolPin.d.mts +1110 -0
- package/dist/generated/prisma/models/DbCommunityToolPin.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolTag.d.mts +1073 -0
- package/dist/generated/prisma/models/DbCommunityToolTag.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbCommunityToolView.d.mts +1149 -0
- package/dist/generated/prisma/models/DbCommunityToolView.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbFeedback.d.mts +361 -2
- package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -1
- package/dist/generated/prisma/models.d.mts +9 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +3 -1
- package/dist/middleware/createAcMiddleware.d.mts.map +1 -1
- package/dist/middleware/createAcMiddleware.mjs +15 -1
- package/dist/middleware/createAcMiddleware.mjs.map +1 -1
- package/dist/middleware/database.d.mts +1 -1
- package/dist/middleware/types.d.mts +20 -1
- package/dist/middleware/types.d.mts.map +1 -1
- package/dist/modules/communityTools/capture.d.mts +27 -0
- package/dist/modules/communityTools/capture.d.mts.map +1 -0
- package/dist/modules/communityTools/capture.mjs +192 -0
- package/dist/modules/communityTools/capture.mjs.map +1 -0
- package/dist/modules/communityTools/healthJob.mjs +132 -0
- package/dist/modules/communityTools/healthJob.mjs.map +1 -0
- package/dist/modules/communityTools/router.d.mts +150 -0
- package/dist/modules/communityTools/router.d.mts.map +1 -0
- package/dist/modules/communityTools/router.mjs +91 -0
- package/dist/modules/communityTools/router.mjs.map +1 -0
- package/dist/modules/communityTools/service.mjs +252 -0
- package/dist/modules/communityTools/service.mjs.map +1 -0
- package/dist/modules/communityTools/toView.mjs +81 -0
- package/dist/modules/communityTools/toView.mjs.map +1 -0
- package/dist/modules/communityTools/writeService.d.mts +55 -0
- package/dist/modules/communityTools/writeService.d.mts.map +1 -0
- package/dist/modules/communityTools/writeService.mjs +280 -0
- package/dist/modules/communityTools/writeService.mjs.map +1 -0
- package/dist/modules/feedback/feedbackRouter.d.mts +3 -0
- package/dist/modules/feedback/feedbackRouter.mjs +6 -2
- package/dist/modules/feedback/feedbackRouter.mjs.map +1 -1
- package/dist/modules/feedback/service.d.mts +3 -0
- package/dist/modules/feedback/service.d.mts.map +1 -1
- package/dist/modules/feedback/service.mjs +50 -17
- package/dist/modules/feedback/service.mjs.map +1 -1
- package/dist/server/controller.d.mts +143 -0
- package/dist/server/controller.d.mts.map +1 -1
- package/dist/server/controller.mjs +2 -0
- package/dist/server/controller.mjs.map +1 -1
- package/dist/server/trpcSetup.mjs +1 -1
- package/package.json +12 -5
- package/prisma/schema.prisma +161 -0
- package/src/__tests__/communityTools.test.ts +781 -0
- package/src/__tests__/communityToolsCapture.test.ts +319 -0
- package/src/__tests__/communityToolsHealth.test.ts +154 -0
- package/src/__tests__/communityToolsRouter.test.ts +58 -0
- package/src/__tests__/communityToolsWrite.test.ts +561 -0
- package/src/__tests__/feedbackService.test.ts +160 -0
- package/src/generated/prisma/browser.ts +35 -0
- package/src/generated/prisma/client.ts +35 -0
- package/src/generated/prisma/commonInputTypes.ts +34 -0
- package/src/generated/prisma/enums.ts +8 -0
- package/src/generated/prisma/internal/class.ts +74 -4
- package/src/generated/prisma/internal/prismaNamespace.ts +635 -2
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +95 -1
- package/src/generated/prisma/models/DbCommunityTool.ts +3135 -0
- package/src/generated/prisma/models/DbCommunityToolLike.ts +1313 -0
- package/src/generated/prisma/models/DbCommunityToolNamedOwner.ts +1243 -0
- package/src/generated/prisma/models/DbCommunityToolOwner.ts +1243 -0
- package/src/generated/prisma/models/DbCommunityToolPin.ts +1325 -0
- package/src/generated/prisma/models/DbCommunityToolTag.ts +1283 -0
- package/src/generated/prisma/models/DbCommunityToolView.ts +1361 -0
- package/src/generated/prisma/models/DbFeedback.ts +379 -1
- package/src/generated/prisma/models.ts +7 -0
- package/src/index.ts +16 -0
- package/src/middleware/createAcMiddleware.ts +20 -0
- package/src/middleware/types.ts +21 -0
- package/src/modules/communityTools/capture.ts +332 -0
- package/src/modules/communityTools/healthJob.ts +191 -0
- package/src/modules/communityTools/router.ts +172 -0
- package/src/modules/communityTools/service.ts +380 -0
- package/src/modules/communityTools/toView.ts +112 -0
- package/src/modules/communityTools/writeService.ts +408 -0
- package/src/modules/feedback/feedbackRouter.ts +20 -6
- package/src/modules/feedback/service.ts +68 -12
- 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(
|
|
61
|
-
|
|
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(
|
|
80
|
-
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
121
|
+
target: FeedbackTarget,
|
|
99
122
|
actor: Actor | null,
|
|
100
123
|
): Promise<FeedbackList> {
|
|
101
|
-
|
|
102
|
-
if (
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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) {
|
package/src/server/controller.ts
CHANGED
|
@@ -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?: {
|
|
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
|
})
|