@igstack/app-catalog-backend-core 6.11.0 → 6.12.1

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 (50) hide show
  1. package/dist/generated/prisma/client.mjs.map +1 -1
  2. package/dist/generated/prisma/internal/class.d.mts +12 -0
  3. package/dist/generated/prisma/internal/class.d.mts.map +1 -1
  4. package/dist/generated/prisma/internal/class.mjs +4 -4
  5. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  6. package/dist/generated/prisma/internal/prismaNamespace.d.mts +90 -3
  7. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  8. package/dist/generated/prisma/models/DbCommunityTool.d.mts +289 -4
  9. package/dist/generated/prisma/models/DbCommunityTool.d.mts.map +1 -1
  10. package/dist/generated/prisma/models/DbCommunityToolOpen.d.mts +1171 -0
  11. package/dist/generated/prisma/models/DbCommunityToolOpen.d.mts.map +1 -0
  12. package/dist/generated/prisma/models.d.mts +2 -1
  13. package/dist/modules/communityTools/capture.d.mts +8 -1
  14. package/dist/modules/communityTools/capture.d.mts.map +1 -1
  15. package/dist/modules/communityTools/capture.mjs +40 -14
  16. package/dist/modules/communityTools/capture.mjs.map +1 -1
  17. package/dist/modules/communityTools/healthJob.mjs +3 -2
  18. package/dist/modules/communityTools/healthJob.mjs.map +1 -1
  19. package/dist/modules/communityTools/router.d.mts +14 -1
  20. package/dist/modules/communityTools/router.mjs +7 -2
  21. package/dist/modules/communityTools/router.mjs.map +1 -1
  22. package/dist/modules/communityTools/service.mjs +39 -82
  23. package/dist/modules/communityTools/service.mjs.map +1 -1
  24. package/dist/modules/communityTools/toView.mjs +7 -2
  25. package/dist/modules/communityTools/toView.mjs.map +1 -1
  26. package/dist/modules/communityTools/writeService.mjs +1 -0
  27. package/dist/modules/communityTools/writeService.mjs.map +1 -1
  28. package/dist/server/controller.d.mts +9 -0
  29. package/dist/server/controller.d.mts.map +1 -1
  30. package/package.json +4 -4
  31. package/prisma/schema.prisma +25 -2
  32. package/src/__tests__/communityTools.test.ts +106 -42
  33. package/src/__tests__/communityToolsCapture.test.ts +34 -0
  34. package/src/__tests__/communityToolsHealth.test.ts +62 -0
  35. package/src/__tests__/communityToolsWrite.test.ts +6 -1
  36. package/src/generated/prisma/browser.ts +6 -1
  37. package/src/generated/prisma/client.ts +6 -1
  38. package/src/generated/prisma/internal/class.ts +14 -4
  39. package/src/generated/prisma/internal/prismaNamespace.ts +90 -1
  40. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +14 -0
  41. package/src/generated/prisma/models/DbCommunityTool.ts +299 -2
  42. package/src/generated/prisma/models/DbCommunityToolOpen.ts +1383 -0
  43. package/src/generated/prisma/models/DbCommunityToolView.ts +1 -1
  44. package/src/generated/prisma/models.ts +1 -0
  45. package/src/modules/communityTools/capture.ts +54 -18
  46. package/src/modules/communityTools/healthJob.ts +7 -1
  47. package/src/modules/communityTools/router.ts +26 -1
  48. package/src/modules/communityTools/service.ts +52 -92
  49. package/src/modules/communityTools/toView.ts +9 -0
  50. package/src/modules/communityTools/writeService.ts +1 -0
@@ -16,7 +16,7 @@ import type * as Prisma from "../internal/prismaNamespace"
16
16
 
17
17
  /**
18
18
  * Model DbCommunityToolView
19
- * View tracking for community tools (one row per viewer per tool per day).
19
+ * Superseded by DbCommunityToolOpen and no longer written; kept so the schema stays additive.
20
20
  */
21
21
  export type DbCommunityToolViewModel = runtime.Types.Result.DefaultSelection<Prisma.$DbCommunityToolViewPayload>
22
22
 
@@ -28,6 +28,7 @@ export type * from './models/DbCommunityToolOwner'
28
28
  export type * from './models/DbCommunityToolLike'
29
29
  export type * from './models/DbCommunityToolNamedOwner'
30
30
  export type * from './models/DbCommunityToolTag'
31
+ export type * from './models/DbCommunityToolOpen'
31
32
  export type * from './models/DbCommunityToolView'
32
33
  export type * from './models/DbCommunityToolPin'
33
34
  export type * from './commonInputTypes'
@@ -10,13 +10,23 @@ export type CaptureResult =
10
10
  /** The page's `Last-Modified`: a static host sets it on each deploy. */
11
11
  lastModified: Date | null
12
12
  }
13
- | { ok: false; error: string }
13
+ | {
14
+ ok: false
15
+ error: string
16
+ /**
17
+ * No answer at all (a timeout, a refused connection), which may be the
18
+ * server's own network. Absent for a definitive failure: an HTTP error
19
+ * status, or a name that does not resolve.
20
+ */
21
+ unreachable?: true
22
+ }
14
23
 
15
24
  /**
16
25
  * Visits a page and photographs it, reaching only `allowedHosts`.
17
26
  *
18
27
  * Resolves `{ ok: false }` when the page itself failed (no response, an error status, a
19
- * timeout, a redirect off the list) — that is the tool's health. Throws when the
28
+ * timeout, a redirect off the list) — that is the tool's health; `unreachable` when
29
+ * it never answered. Throws when the
20
30
  * browser could not be started, which is ours: the health job must not mark every tool
21
31
  * broken because Chromium is missing on one pod.
22
32
  */
@@ -191,6 +201,7 @@ export async function capturePage(
191
201
  return {
192
202
  ok: false,
193
203
  error: response ? `HTTP ${response.status()}` : 'No response',
204
+ ...(!response && { unreachable: true as const }),
194
205
  }
195
206
  }
196
207
  // `load` is only the HTML and its assets: a page that fetches its data after
@@ -240,6 +251,19 @@ export async function capturePage(
240
251
  }
241
252
  }
242
253
 
254
+ /**
255
+ * A capture that threw, from the browser or the network under it. Only a name
256
+ * that does not resolve is definitive; anything else got no answer at all.
257
+ */
258
+ export function failureOf(
259
+ error: unknown,
260
+ ): Extract<CaptureResult, { ok: false }> {
261
+ const message = error instanceof Error ? error.message : String(error)
262
+ return /ENOTFOUND|ERR_NAME_NOT_RESOLVED/.test(message)
263
+ ? { ok: false, error: message }
264
+ : { ok: false, error: message, unreachable: true }
265
+ }
266
+
243
267
  /** An HTTP date header as a Date; null when absent or unreadable. */
244
268
  export function httpDate(value: string | undefined): Date | null {
245
269
  if (!value) return null
@@ -293,6 +317,9 @@ export function createPlaywrightCapturer(): ScreenshotCapturer | null {
293
317
  viewport: VIEWPORT,
294
318
  serviceWorkers: 'block',
295
319
  })
320
+ // The fence answers the page itself, so the browser only sees that it
321
+ // failed; why it failed (no such name, or no answer) is kept here.
322
+ let navigationError: unknown = null
296
323
  // Second layer under the resolver fence: per-request and per-hop checks.
297
324
  await context.route('**/*', (route) =>
298
325
  fenceRoute(
@@ -300,17 +327,25 @@ export function createPlaywrightCapturer(): ScreenshotCapturer | null {
300
327
  url: route.request().url(),
301
328
  abort: (code) => route.abort(code),
302
329
  continue: () => route.continue(),
303
- fetch: (hop, mode) =>
304
- mode === 'get'
305
- ? context.request.get(hop, {
306
- maxRedirects: 0,
307
- timeout: HOP_TIMEOUT_MS,
308
- })
309
- : route.fetch({
310
- url: hop,
311
- maxRedirects: 0,
312
- timeout: HOP_TIMEOUT_MS,
313
- }),
330
+ fetch: async (hop, mode) => {
331
+ try {
332
+ return await (mode === 'get'
333
+ ? context.request.get(hop, {
334
+ maxRedirects: 0,
335
+ timeout: HOP_TIMEOUT_MS,
336
+ })
337
+ : route.fetch({
338
+ url: hop,
339
+ maxRedirects: 0,
340
+ timeout: HOP_TIMEOUT_MS,
341
+ }))
342
+ } catch (error) {
343
+ if (route.request().isNavigationRequest()) {
344
+ navigationError ??= error
345
+ }
346
+ throw error
347
+ }
348
+ },
314
349
  fulfill: (response) => route.fulfill({ response }),
315
350
  },
316
351
  allowedHosts,
@@ -319,12 +354,13 @@ export function createPlaywrightCapturer(): ScreenshotCapturer | null {
319
354
  // A screenshot needs no live socket, and `route` does not see them.
320
355
  await context.routeWebSocket(/.*/, (ws) => ws.close())
321
356
  const page = await context.newPage()
322
- return await capturePage(page, url, allowedHosts)
323
- } catch (error) {
324
- return {
325
- ok: false,
326
- error: error instanceof Error ? error.message : String(error),
357
+ try {
358
+ return await capturePage(page, url, allowedHosts)
359
+ } catch (error) {
360
+ return failureOf(navigationError ?? error)
327
361
  }
362
+ } catch (error) {
363
+ return failureOf(error)
328
364
  } finally {
329
365
  await browser.close()
330
366
  }
@@ -28,6 +28,7 @@ interface CheckableTool {
28
28
  failingSince: Date | null
29
29
  archivedAt: Date | null
30
30
  archivedBy: string | null
31
+ unreachableSince: Date | null
31
32
  }
32
33
 
33
34
  /** Stores the screenshot and, when it decodes, the favicon as assets. */
@@ -97,7 +98,11 @@ export async function checkTool(
97
98
  where: { id: tool.id },
98
99
  data: {
99
100
  lastCheckAt: now,
100
- ...nextLifecycle(tool, result.ok, now),
101
+ ...nextLifecycle(
102
+ tool,
103
+ result.ok ? 'ok' : result.unreachable ? 'unreachable' : 'failed',
104
+ now,
105
+ ),
101
106
  ...(result.ok && (await storeCapture(tool.slug, result, now))),
102
107
  // A visit without the header leaves the date it last had.
103
108
  ...(result.ok &&
@@ -143,6 +148,7 @@ export async function runHealthCheckOnce(
143
148
  failingSince: true,
144
149
  archivedAt: true,
145
150
  archivedBy: true,
151
+ unreachableSince: true,
146
152
  },
147
153
  })
148
154
  const due = tools.filter(
@@ -89,11 +89,36 @@ export function createCommunityToolsRouter(options?: CommunityToolsOptions) {
89
89
  return tool
90
90
  }),
91
91
 
92
+ /** The card opened. */
92
93
  recordView: publicProcedure
93
94
  .input(z.object({ slug: z.string() }))
94
95
  .mutation(({ input, ctx }) => {
95
96
  const actor = requireActor(ctx.actor)
96
- return service.recordView(input.slug, actor, ctx.user, new Date())
97
+ return service.recordOpen(
98
+ input.slug,
99
+ 'card',
100
+ actor,
101
+ ctx.user,
102
+ new Date(),
103
+ )
104
+ }),
105
+
106
+ /**
107
+ * A click through to the site. The page leaves as it is sent, so the
108
+ * client posts it with `keepalive` rather than through the tRPC client.
109
+ */
110
+ recordSiteOpen: publicProcedure
111
+ .input(z.object({ slug: z.string() }))
112
+ .mutation(async ({ input, ctx }) => {
113
+ const actor = requireActor(ctx.actor)
114
+ await service.recordOpen(
115
+ input.slug,
116
+ 'site',
117
+ actor,
118
+ ctx.user,
119
+ new Date(),
120
+ )
121
+ return { ok: true }
97
122
  }),
98
123
 
99
124
  pins: protectedProcedure.query(({ ctx }) => {
@@ -4,7 +4,7 @@ import { getDbClient } from '../../db/client'
4
4
  import type { Actor } from '../visitor/visitorIdentity'
5
5
  import type { CommunityTool } from '@igstack/app-catalog-shared-core'
6
6
  import type { CaptureDeps } from './healthJob'
7
- import { LIKES, toView } from './toView'
7
+ import { COMMENTS, LIKES, toView } from './toView'
8
8
 
9
9
  export async function listTools(
10
10
  user: User | null,
@@ -19,6 +19,7 @@ export async function listTools(
19
19
  owners: true,
20
20
  namedOwners: true,
21
21
  likes: LIKES,
22
+ _count: COMMENTS,
22
23
  tags: true,
23
24
  },
24
25
  })
@@ -33,28 +34,10 @@ export async function listTools(
33
34
  })
34
35
  const personMap = new Map(persons.map((p) => [p.slug, p]))
35
36
 
36
- // Get view counts for all tools
37
- const today = new Date(now)
38
- today.setUTCHours(0, 0, 0, 0)
39
- const thirtyDaysAgo = new Date(today)
40
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
41
-
42
- const viewCounts = await Promise.all(
43
- toolIds.map(async (toolId) => {
44
- const [views30d, viewsAll] = await Promise.all([
45
- db.dbCommunityToolView.count({
46
- where: { toolId, day: { gte: thirtyDaysAgo } },
47
- }),
48
- db.dbCommunityToolView.count({ where: { toolId } }),
49
- ])
50
- return { toolId, views30d, viewsAll }
51
- }),
52
- )
53
37
  const viewCountMap = new Map(
54
- viewCounts.map((v) => [
55
- v.toolId,
56
- { views30d: v.views30d, viewsAll: v.viewsAll },
57
- ]),
38
+ await Promise.all(
39
+ toolIds.map(async (id) => [id, await openCounts(id, now)] as const),
40
+ ),
58
41
  )
59
42
 
60
43
  return Promise.all(
@@ -97,6 +80,7 @@ export async function getTool(
97
80
  owners: true,
98
81
  namedOwners: true,
99
82
  likes: LIKES,
83
+ _count: COMMENTS,
100
84
  tags: true,
101
85
  },
102
86
  })
@@ -109,22 +93,10 @@ export async function getTool(
109
93
  })
110
94
  const personMap = new Map(persons.map((p) => [p.slug, p]))
111
95
 
112
- const today = new Date(now)
113
- today.setUTCHours(0, 0, 0, 0)
114
- const thirtyDaysAgo = new Date(today)
115
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
116
-
117
- const [views30d, viewsAll] = await Promise.all([
118
- db.dbCommunityToolView.count({
119
- where: { toolId: tool.id, day: { gte: thirtyDaysAgo } },
120
- }),
121
- db.dbCommunityToolView.count({ where: { toolId: tool.id } }),
122
- ])
123
-
124
96
  return toView(
125
97
  tool,
126
98
  personMap,
127
- { views30d, viewsAll },
99
+ await openCounts(tool.id, now),
128
100
  user,
129
101
  isAdmin,
130
102
  now,
@@ -132,54 +104,58 @@ export async function getTool(
132
104
  )
133
105
  }
134
106
 
135
- export async function recordView(
107
+ /** Opens since the first of the last 30 UTC days, and ever. */
108
+ async function openCounts(
109
+ toolId: string,
110
+ now: Date,
111
+ ): Promise<{ views30d: number; viewsAll: number }> {
112
+ const db = getDbClient()
113
+ const since = new Date(now)
114
+ since.setUTCHours(0, 0, 0, 0)
115
+ since.setUTCDate(since.getUTCDate() - 29)
116
+ const [views30d, viewsAll] = await Promise.all([
117
+ db.dbCommunityToolOpen.count({ where: { toolId, at: { gte: since } } }),
118
+ db.dbCommunityToolOpen.count({ where: { toolId } }),
119
+ ])
120
+ return { views30d, viewsAll }
121
+ }
122
+
123
+ /** More opens than this by one visitor of one tool in a minute are ignored. */
124
+ const OPENS_PER_MINUTE = 30
125
+
126
+ /**
127
+ * Counts one card open or click through to the site: every one counts, short
128
+ * of a visitor opening the same tool more than OPENS_PER_MINUTE times a minute.
129
+ */
130
+ export async function recordOpen(
136
131
  slug: string,
132
+ kind: 'card' | 'site',
137
133
  actor: Actor,
138
134
  user: User | null,
139
135
  now: Date,
140
136
  ): Promise<{ views30d: number; viewsAll: number }> {
141
137
  const db = getDbClient()
138
+ const toolId = await requireToolId(slug)
142
139
 
143
- const tool = await db.dbCommunityTool.findUnique({ where: { slug } })
144
- if (!tool) {
145
- throw new Error('Tool not found')
146
- }
147
-
148
- const day = new Date(now)
149
- day.setUTCHours(0, 0, 0, 0)
150
-
151
- await db.dbCommunityToolView.upsert({
140
+ const recent = await db.dbCommunityToolOpen.count({
152
141
  where: {
153
- toolId_actorHash_day: {
154
- toolId: tool.id,
155
- actorHash: actor.hash,
156
- day,
157
- },
158
- },
159
- create: {
160
- toolId: tool.id,
142
+ toolId,
161
143
  actorHash: actor.hash,
162
- userId: user?.id ?? null,
163
- day,
164
- at: now,
165
- },
166
- update: {
167
- at: now,
168
- userId: user?.id ?? null,
144
+ at: { gt: new Date(now.getTime() - 60_000) },
169
145
  },
170
146
  })
171
-
172
- const thirtyDaysAgo = new Date(day)
173
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
174
-
175
- const [views30d, viewsAll] = await Promise.all([
176
- db.dbCommunityToolView.count({
177
- where: { toolId: tool.id, day: { gte: thirtyDaysAgo } },
178
- }),
179
- db.dbCommunityToolView.count({ where: { toolId: tool.id } }),
180
- ])
181
-
182
- return { views30d, viewsAll }
147
+ if (recent < OPENS_PER_MINUTE) {
148
+ await db.dbCommunityToolOpen.create({
149
+ data: {
150
+ toolId,
151
+ kind,
152
+ actorHash: actor.hash,
153
+ userId: user?.id ?? null,
154
+ at: now,
155
+ },
156
+ })
157
+ }
158
+ return openCounts(toolId, now)
183
159
  }
184
160
 
185
161
  export async function listPins(
@@ -200,6 +176,7 @@ export async function listPins(
200
176
  owners: true,
201
177
  namedOwners: true,
202
178
  likes: LIKES,
179
+ _count: COMMENTS,
203
180
  tags: true,
204
181
  },
205
182
  },
@@ -215,27 +192,10 @@ export async function listPins(
215
192
  })
216
193
  const personMap = new Map(persons.map((p) => [p.slug, p]))
217
194
 
218
- const today = new Date(now)
219
- today.setUTCHours(0, 0, 0, 0)
220
- const thirtyDaysAgo = new Date(today)
221
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
222
-
223
- const viewCounts = await Promise.all(
224
- toolIds.map(async (toolId) => {
225
- const [views30d, viewsAll] = await Promise.all([
226
- db.dbCommunityToolView.count({
227
- where: { toolId, day: { gte: thirtyDaysAgo } },
228
- }),
229
- db.dbCommunityToolView.count({ where: { toolId } }),
230
- ])
231
- return { toolId, views30d, viewsAll }
232
- }),
233
- )
234
195
  const viewCountMap = new Map(
235
- viewCounts.map((v) => [
236
- v.toolId,
237
- { views30d: v.views30d, viewsAll: v.viewsAll },
238
- ]),
196
+ await Promise.all(
197
+ toolIds.map(async (id) => [id, await openCounts(id, now)] as const),
198
+ ),
239
199
  )
240
200
 
241
201
  return Promise.all(
@@ -15,6 +15,11 @@ export const LIKES = {
15
15
  select: { userId: true, userName: true },
16
16
  } as const satisfies Prisma.DbCommunityTool$likesArgs
17
17
 
18
+ /** Visitors' comments, counted in the same query: a review line the system writes has a `kind`. */
19
+ export const COMMENTS = {
20
+ select: { feedback: { where: { kind: null } } },
21
+ } as const satisfies Prisma.DbCommunityToolCountOutputTypeDefaultArgs
22
+
18
23
  /** How many likers a view names; the rest are a count. */
19
24
  const LIKED_BY_NAMES = 8
20
25
 
@@ -24,6 +29,7 @@ type DbToolWithRelations = Prisma.DbCommunityToolGetPayload<{
24
29
  namedOwners: true
25
30
  tags: true
26
31
  likes: typeof LIKES
32
+ _count: typeof COMMENTS
27
33
  }
28
34
  }>
29
35
 
@@ -58,6 +64,7 @@ export async function toView(
58
64
  archivedAt: tool.archivedAt,
59
65
  archivedBy: tool.archivedBy,
60
66
  archivedReason: tool.archivedReason,
67
+ unreachableSince: tool.unreachableSince,
61
68
  },
62
69
  now,
63
70
  )
@@ -89,6 +96,7 @@ export async function toView(
89
96
  name: tool.addedByName,
90
97
  },
91
98
  createdAt: tool.createdAt.toISOString(),
99
+ comments: tool._count.feedback,
92
100
  // ponytail: every like row is read per view; a counted column if likes run to thousands.
93
101
  likes: tool.likes.length,
94
102
  likedByMe: !!user && tool.likes.some((l) => l.userId === user.id),
@@ -107,6 +115,7 @@ export async function toView(
107
115
  faviconId: tool.faviconAssetId ?? null,
108
116
  capturedAt: tool.capturedAt?.toISOString() ?? null,
109
117
  capturePending,
118
+ serverUnreachable: tool.unreachableSince !== null,
110
119
  canEdit,
111
120
  }
112
121
  }
@@ -319,6 +319,7 @@ export async function editTool(
319
319
  // automatic archive says nothing about the new one.
320
320
  const newSite = host !== tool.host && {
321
321
  failingSince: null,
322
+ unreachableSince: null,
322
323
  lastCheckAt: null,
323
324
  ...(tool.archivedBy === 'auto' && {
324
325
  archivedAt: null,