@igstack/app-catalog-backend-core 6.12.0 → 6.12.2

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 +35 -81
  23. package/dist/modules/communityTools/service.mjs.map +1 -1
  24. package/dist/modules/communityTools/toView.mjs +3 -1
  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 +87 -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 +5 -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 +48 -91
  49. package/src/modules/communityTools/toView.ts +2 -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 }) => {
@@ -34,28 +34,10 @@ export async function listTools(
34
34
  })
35
35
  const personMap = new Map(persons.map((p) => [p.slug, p]))
36
36
 
37
- // Get view counts for all tools
38
- const today = new Date(now)
39
- today.setUTCHours(0, 0, 0, 0)
40
- const thirtyDaysAgo = new Date(today)
41
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
42
-
43
- const viewCounts = await Promise.all(
44
- toolIds.map(async (toolId) => {
45
- const [views30d, viewsAll] = await Promise.all([
46
- db.dbCommunityToolView.count({
47
- where: { toolId, day: { gte: thirtyDaysAgo } },
48
- }),
49
- db.dbCommunityToolView.count({ where: { toolId } }),
50
- ])
51
- return { toolId, views30d, viewsAll }
52
- }),
53
- )
54
37
  const viewCountMap = new Map(
55
- viewCounts.map((v) => [
56
- v.toolId,
57
- { views30d: v.views30d, viewsAll: v.viewsAll },
58
- ]),
38
+ await Promise.all(
39
+ toolIds.map(async (id) => [id, await openCounts(id, now)] as const),
40
+ ),
59
41
  )
60
42
 
61
43
  return Promise.all(
@@ -111,22 +93,10 @@ export async function getTool(
111
93
  })
112
94
  const personMap = new Map(persons.map((p) => [p.slug, p]))
113
95
 
114
- const today = new Date(now)
115
- today.setUTCHours(0, 0, 0, 0)
116
- const thirtyDaysAgo = new Date(today)
117
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
118
-
119
- const [views30d, viewsAll] = await Promise.all([
120
- db.dbCommunityToolView.count({
121
- where: { toolId: tool.id, day: { gte: thirtyDaysAgo } },
122
- }),
123
- db.dbCommunityToolView.count({ where: { toolId: tool.id } }),
124
- ])
125
-
126
96
  return toView(
127
97
  tool,
128
98
  personMap,
129
- { views30d, viewsAll },
99
+ await openCounts(tool.id, now),
130
100
  user,
131
101
  isAdmin,
132
102
  now,
@@ -134,54 +104,58 @@ export async function getTool(
134
104
  )
135
105
  }
136
106
 
137
- 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(
138
131
  slug: string,
132
+ kind: 'card' | 'site',
139
133
  actor: Actor,
140
134
  user: User | null,
141
135
  now: Date,
142
136
  ): Promise<{ views30d: number; viewsAll: number }> {
143
137
  const db = getDbClient()
138
+ const toolId = await requireToolId(slug)
144
139
 
145
- const tool = await db.dbCommunityTool.findUnique({ where: { slug } })
146
- if (!tool) {
147
- throw new Error('Tool not found')
148
- }
149
-
150
- const day = new Date(now)
151
- day.setUTCHours(0, 0, 0, 0)
152
-
153
- await db.dbCommunityToolView.upsert({
140
+ const recent = await db.dbCommunityToolOpen.count({
154
141
  where: {
155
- toolId_actorHash_day: {
156
- toolId: tool.id,
157
- actorHash: actor.hash,
158
- day,
159
- },
160
- },
161
- create: {
162
- toolId: tool.id,
142
+ toolId,
163
143
  actorHash: actor.hash,
164
- userId: user?.id ?? null,
165
- day,
166
- at: now,
167
- },
168
- update: {
169
- at: now,
170
- userId: user?.id ?? null,
144
+ at: { gt: new Date(now.getTime() - 60_000) },
171
145
  },
172
146
  })
173
-
174
- const thirtyDaysAgo = new Date(day)
175
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
176
-
177
- const [views30d, viewsAll] = await Promise.all([
178
- db.dbCommunityToolView.count({
179
- where: { toolId: tool.id, day: { gte: thirtyDaysAgo } },
180
- }),
181
- db.dbCommunityToolView.count({ where: { toolId: tool.id } }),
182
- ])
183
-
184
- 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)
185
159
  }
186
160
 
187
161
  export async function listPins(
@@ -218,27 +192,10 @@ export async function listPins(
218
192
  })
219
193
  const personMap = new Map(persons.map((p) => [p.slug, p]))
220
194
 
221
- const today = new Date(now)
222
- today.setUTCHours(0, 0, 0, 0)
223
- const thirtyDaysAgo = new Date(today)
224
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
225
-
226
- const viewCounts = await Promise.all(
227
- toolIds.map(async (toolId) => {
228
- const [views30d, viewsAll] = await Promise.all([
229
- db.dbCommunityToolView.count({
230
- where: { toolId, day: { gte: thirtyDaysAgo } },
231
- }),
232
- db.dbCommunityToolView.count({ where: { toolId } }),
233
- ])
234
- return { toolId, views30d, viewsAll }
235
- }),
236
- )
237
195
  const viewCountMap = new Map(
238
- viewCounts.map((v) => [
239
- v.toolId,
240
- { views30d: v.views30d, viewsAll: v.viewsAll },
241
- ]),
196
+ await Promise.all(
197
+ toolIds.map(async (id) => [id, await openCounts(id, now)] as const),
198
+ ),
242
199
  )
243
200
 
244
201
  return Promise.all(
@@ -64,6 +64,7 @@ export async function toView(
64
64
  archivedAt: tool.archivedAt,
65
65
  archivedBy: tool.archivedBy,
66
66
  archivedReason: tool.archivedReason,
67
+ unreachableSince: tool.unreachableSince,
67
68
  },
68
69
  now,
69
70
  )
@@ -114,6 +115,7 @@ export async function toView(
114
115
  faviconId: tool.faviconAssetId ?? null,
115
116
  capturedAt: tool.capturedAt?.toISOString() ?? null,
116
117
  capturePending,
118
+ serverUnreachable: tool.unreachableSince !== null,
117
119
  canEdit,
118
120
  }
119
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,