@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
@@ -30,10 +30,9 @@ describe('Community tools service', () => {
30
30
  findMany: vi.fn(),
31
31
  findUnique: vi.fn(),
32
32
  },
33
- dbCommunityToolView: {
34
- upsert: vi.fn(),
33
+ dbCommunityToolOpen: {
34
+ create: vi.fn(),
35
35
  count: vi.fn(),
36
- findMany: vi.fn(),
37
36
  },
38
37
  dbCommunityToolPin: {
39
38
  findMany: vi.fn(),
@@ -87,61 +86,99 @@ describe('Community tools service', () => {
87
86
  })
88
87
  })
89
88
 
90
- describe('recordView', () => {
91
- it('counts view twice same day once', async () => {
92
- const actor = {
93
- hash: 'test-hash',
94
- publicId: 'test-public',
95
- alias: 'Test User',
96
- kind: 'visitor' as const,
89
+ describe('recordOpen', () => {
90
+ const actor = {
91
+ hash: 'test-hash',
92
+ publicId: 'test-public',
93
+ alias: 'Test User',
94
+ kind: 'visitor' as const,
95
+ }
96
+
97
+ /** The open table in memory: what was written is what is counted. */
98
+ function opensTable() {
99
+ const rows: { toolId: string; actorHash: string; at: Date }[] = []
100
+ type Where = {
101
+ toolId: string
102
+ actorHash?: string
103
+ at?: { gte?: Date; gt?: Date }
97
104
  }
105
+ const matches = (r: (typeof rows)[number], w: Where) =>
106
+ r.toolId === w.toolId &&
107
+ (w.actorHash === undefined || r.actorHash === w.actorHash) &&
108
+ (!w.at?.gte || r.at >= w.at.gte) &&
109
+ (!w.at?.gt || r.at > w.at.gt)
110
+ mockDb.dbCommunityToolOpen.create = vi.fn(
111
+ ({ data }: { data: (typeof rows)[number] }) => {
112
+ rows.push(data)
113
+ return Promise.resolve(data)
114
+ },
115
+ ) as never
116
+ mockDb.dbCommunityToolOpen.count = vi.fn(({ where }: { where: Where }) =>
117
+ Promise.resolve(rows.filter((r) => matches(r, where)).length),
118
+ ) as never
119
+ return rows
120
+ }
98
121
 
122
+ beforeEach(() => {
99
123
  mockDb.dbCommunityTool.findUnique = vi
100
124
  .fn()
101
125
  .mockResolvedValue({ id: 'tool-1' })
102
- mockDb.dbCommunityToolView.upsert = vi.fn().mockResolvedValue({})
126
+ })
103
127
 
104
- // Mock count to return different values for each call
105
- let countCallIndex = 0
106
- mockDb.dbCommunityToolView.count = vi.fn().mockImplementation(() => {
107
- const results = [1, 5, 1, 5] // views30d, viewsAll for each recordView call
108
- return Promise.resolve(results[countCallIndex++])
109
- })
128
+ it('counts every open, two on the same day as two', async () => {
129
+ const rows = opensTable()
130
+ const at = new Date('2024-01-15T10:00:00Z')
110
131
 
111
- // First view
112
- const result1 = await service.recordView(
132
+ await service.recordOpen('test-slug', 'card', actor, null, at)
133
+ const second = await service.recordOpen(
113
134
  'test-slug',
135
+ 'site',
114
136
  actor,
115
137
  null,
116
- new Date('2024-01-15'),
138
+ new Date(at.getTime() + 5 * 60_000),
117
139
  )
118
140
 
119
- // Second view same day
120
- const result2 = await service.recordView(
141
+ expect(second).toEqual({ views30d: 2, viewsAll: 2 })
142
+ expect(rows.map((r) => (r as unknown as { kind: string }).kind)).toEqual([
143
+ 'card',
144
+ 'site',
145
+ ])
146
+ })
147
+
148
+ it('counts the last 30 days in views30d, every open in viewsAll', async () => {
149
+ const rows = opensTable()
150
+ rows.push({
151
+ toolId: 'tool-1',
152
+ actorHash: 'someone',
153
+ at: new Date('2023-12-01T00:00:00Z'),
154
+ })
155
+
156
+ const counts = await service.recordOpen(
121
157
  'test-slug',
158
+ 'card',
122
159
  actor,
123
160
  null,
124
- new Date('2024-01-15'),
161
+ new Date('2024-01-15T10:00:00Z'),
125
162
  )
126
163
 
127
- // Both calls should upsert the same row (same day)
128
- expect(mockDb.dbCommunityToolView.upsert).toHaveBeenCalledTimes(2)
129
- const calls = vi.mocked(mockDb.dbCommunityToolView.upsert).mock.calls
130
- const call1 = calls[0]?.[0]
131
- const call2 = calls[1]?.[0]
132
-
133
- // Same composite key (toolId, actorHash, day)
134
- expect(call1?.where).toEqual({
135
- toolId_actorHash_day: {
136
- toolId: 'tool-1',
137
- actorHash: 'test-hash',
138
- day: new Date('2024-01-15T00:00:00.000Z'),
139
- },
140
- })
141
- expect(call2?.where).toEqual(call1?.where)
164
+ expect(counts).toEqual({ views30d: 1, viewsAll: 2 })
165
+ })
166
+
167
+ it('ignores a visitor opening one tool more than 30 times a minute', async () => {
168
+ opensTable()
169
+ const at = new Date('2024-01-15T10:00:00Z')
170
+ let counts = { views30d: 0, viewsAll: 0 }
171
+ for (let i = 0; i < 35; i++) {
172
+ counts = await service.recordOpen(
173
+ 'test-slug',
174
+ 'site',
175
+ actor,
176
+ null,
177
+ new Date(at.getTime() + i * 1000),
178
+ )
179
+ }
142
180
 
143
- expect(result1).toEqual({ views30d: 1, viewsAll: 5 })
144
- expect(result2).toEqual({ views30d: 1, viewsAll: 5 })
181
+ expect(counts.viewsAll).toBe(30)
145
182
  })
146
183
  })
147
184
 
@@ -173,10 +210,12 @@ describe('Community tools service', () => {
173
210
  failingSince: null,
174
211
  archivedAt: null,
175
212
  archivedBy: null,
213
+ unreachableSince: null,
176
214
  archivedReason: null,
177
215
  owners: [],
178
216
  namedOwners: [],
179
217
  likes: [] as { userId: string; userName: string }[],
218
+ _count: { feedback: 0 },
180
219
  tags: [],
181
220
  },
182
221
  },
@@ -202,10 +241,12 @@ describe('Community tools service', () => {
202
241
  failingSince: null,
203
242
  archivedAt: null,
204
243
  archivedBy: null,
244
+ unreachableSince: null,
205
245
  archivedReason: null,
206
246
  owners: [],
207
247
  namedOwners: [],
208
248
  likes: [] as { userId: string; userName: string }[],
249
+ _count: { feedback: 0 },
209
250
  tags: [],
210
251
  },
211
252
  },
@@ -231,17 +272,19 @@ describe('Community tools service', () => {
231
272
  failingSince: null,
232
273
  archivedAt: null,
233
274
  archivedBy: null,
275
+ unreachableSince: null,
234
276
  archivedReason: null,
235
277
  owners: [],
236
278
  namedOwners: [],
237
279
  likes: [] as { userId: string; userName: string }[],
280
+ _count: { feedback: 0 },
238
281
  tags: [],
239
282
  },
240
283
  },
241
284
  ])
242
285
 
243
286
  mockDb.dbPerson.findMany = vi.fn().mockResolvedValue([])
244
- mockDb.dbCommunityToolView.count = vi.fn().mockResolvedValue(0)
287
+ mockDb.dbCommunityToolOpen.count = vi.fn().mockResolvedValue(0)
245
288
 
246
289
  const now = new Date('2024-01-15T12:00:00Z')
247
290
  const result = await service.listPins(
@@ -407,10 +450,12 @@ describe('Community tools service', () => {
407
450
  failingSince: null,
408
451
  archivedAt: null,
409
452
  archivedBy: null,
453
+ unreachableSince: null,
410
454
  archivedReason: null,
411
455
  owners: [],
412
456
  namedOwners: [],
413
457
  likes: [] as { userId: string; userName: string }[],
458
+ _count: { feedback: 0 },
414
459
  tags: [],
415
460
  },
416
461
  {
@@ -432,16 +477,18 @@ describe('Community tools service', () => {
432
477
  failingSince: new Date('2024-01-10T00:00:00Z'),
433
478
  archivedAt: null,
434
479
  archivedBy: null,
480
+ unreachableSince: null,
435
481
  archivedReason: null,
436
482
  owners: [],
437
483
  namedOwners: [],
438
484
  likes: [] as { userId: string; userName: string }[],
485
+ _count: { feedback: 0 },
439
486
  tags: [],
440
487
  },
441
488
  ])
442
489
 
443
490
  mockDb.dbPerson.findMany = vi.fn().mockResolvedValue([])
444
- mockDb.dbCommunityToolView.count = vi.fn().mockResolvedValue(0)
491
+ mockDb.dbCommunityToolOpen.count = vi.fn().mockResolvedValue(0)
445
492
 
446
493
  const result = await service.listTools(null, false, now, NO_CAPTURE)
447
494
 
@@ -481,6 +528,7 @@ describe('Community tools service', () => {
481
528
  archivedAt: null,
482
529
  archivedReason: null,
483
530
  archivedBy: null,
531
+ unreachableSince: null,
484
532
  screenshotAssetId: null,
485
533
  faviconAssetId: null,
486
534
  capturedAt: null,
@@ -488,6 +536,7 @@ describe('Community tools service', () => {
488
536
  owners: [],
489
537
  namedOwners: [],
490
538
  likes: [] as { userId: string; userName: string }[],
539
+ _count: { feedback: 0 },
491
540
  tags: [],
492
541
  ...over,
493
542
  }) as Parameters<typeof toView>[0]
@@ -542,6 +591,19 @@ describe('Community tools service', () => {
542
591
  expect(anonymous.likedByMe).toBe(false)
543
592
  })
544
593
 
594
+ it('counts the comments the query counted', async () => {
595
+ const view = await toView(
596
+ row({ _count: { feedback: 3 } }),
597
+ new Map(),
598
+ { views30d: 0, viewsAll: 0 },
599
+ null,
600
+ false,
601
+ new Date('2024-01-15T12:00:00Z'),
602
+ CAPTURES,
603
+ )
604
+ expect(view.comments).toBe(3)
605
+ })
606
+
545
607
  it('lists an owner outside the directory by name, with no slug', async () => {
546
608
  const view = await toView(
547
609
  row({
@@ -593,6 +655,7 @@ describe('Community tools service', () => {
593
655
  failingSince: null,
594
656
  archivedAt: null,
595
657
  archivedBy: null,
658
+ unreachableSince: null,
596
659
  },
597
660
  { ...CAPTURES, capturer: launchFails },
598
661
  now,
@@ -636,6 +699,7 @@ describe('Community tools service', () => {
636
699
  failingSince: null,
637
700
  archivedAt: null,
638
701
  archivedBy: null,
702
+ unreachableSince: null,
639
703
  },
640
704
  { ...CAPTURES, capturer: { capture } },
641
705
  new Date('2024-01-15T12:00:00Z'),
@@ -5,6 +5,7 @@ import {
5
5
  allowRequest,
6
6
  capturePage,
7
7
  chromiumFenceArgs,
8
+ failureOf,
8
9
  fenceRoute,
9
10
  followWithinHosts,
10
11
  oneAtATime,
@@ -55,6 +56,39 @@ describe('Community tools capture', () => {
55
56
  expect(page.screenshot).not.toHaveBeenCalled()
56
57
  })
57
58
 
59
+ it('tells an error status and an unknown name from no answer at all', async () => {
60
+ const { page } = fakePage({})
61
+ page.goto.mockResolvedValueOnce({
62
+ ok: () => false,
63
+ status: () => 500,
64
+ headers: () => ({}),
65
+ })
66
+ expect(await capturePage(page, URL_OK, HOSTS)).toEqual({
67
+ ok: false,
68
+ error: 'HTTP 500',
69
+ })
70
+ page.goto.mockResolvedValueOnce(null)
71
+ expect(await capturePage(page, URL_OK, HOSTS)).toEqual({
72
+ ok: false,
73
+ error: 'No response',
74
+ unreachable: true,
75
+ })
76
+ expect(
77
+ failureOf(new Error('getaddrinfo ENOTFOUND gone.pages.example.com')),
78
+ ).toEqual({
79
+ ok: false,
80
+ error: 'getaddrinfo ENOTFOUND gone.pages.example.com',
81
+ })
82
+ expect(failureOf(new Error('connect ETIMEDOUT 10.0.0.1:443'))).toEqual({
83
+ ok: false,
84
+ error: 'connect ETIMEDOUT 10.0.0.1:443',
85
+ unreachable: true,
86
+ })
87
+ expect(failureOf(new Error('connect ECONNREFUSED'))).toMatchObject({
88
+ unreachable: true,
89
+ })
90
+ })
91
+
58
92
  it('skips the favicon fetch for an off-list href', async () => {
59
93
  const { page } = fakePage({
60
94
  iconHref: 'https://intranet.example.org/x.ico',
@@ -2,6 +2,7 @@
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  import * as dbClient from '../db/client'
4
4
  import type { PrismaClient } from '../generated/prisma/client'
5
+ import { failureOf } from '../modules/communityTools/capture'
5
6
  import type { ScreenshotCapturer } from '../modules/communityTools/capture'
6
7
  import {
7
8
  runHealthCheckOnce,
@@ -20,6 +21,7 @@ function tool(id: string, fields: Record<string, unknown> = {}) {
20
21
  failingSince: null,
21
22
  archivedAt: null,
22
23
  archivedBy: null,
24
+ unreachableSince: null,
23
25
  ...fields,
24
26
  }
25
27
  }
@@ -82,6 +84,66 @@ describe('Community tools health job', () => {
82
84
  })
83
85
  })
84
86
 
87
+ it('a timeout is not a failure; a 500 and an unknown name are', async () => {
88
+ const since = new Date(NOW.getTime() - 5 * DAY)
89
+ db.dbCommunityTool.findMany.mockResolvedValue([
90
+ tool('timeout', { failingSince: since }),
91
+ tool('http-500'),
92
+ tool('nxdomain'),
93
+ ])
94
+ const results: Record<string, unknown> = {
95
+ 'https://timeout.pages.example.com/': failureOf(
96
+ new Error('connect ETIMEDOUT 10.0.0.1:443'),
97
+ ),
98
+ 'https://http-500.pages.example.com/': { ok: false, error: 'HTTP 500' },
99
+ 'https://nxdomain.pages.example.com/': failureOf(
100
+ new Error('getaddrinfo ENOTFOUND nxdomain.pages.example.com'),
101
+ ),
102
+ }
103
+ const capturer: ScreenshotCapturer = {
104
+ capture: vi.fn((url: string) => Promise.resolve(results[url] as never)),
105
+ }
106
+
107
+ await runHealthCheckOnce({ ...opts, capturer }, NOW)
108
+
109
+ expect(db.dbCommunityTool.update.mock.calls.map((c) => c[0])).toEqual([
110
+ // The streak is left as it was, and nothing is archived.
111
+ {
112
+ where: { id: 'timeout' },
113
+ data: { lastCheckAt: NOW, unreachableSince: NOW },
114
+ },
115
+ {
116
+ where: { id: 'http-500' },
117
+ data: { lastCheckAt: NOW, failingSince: NOW },
118
+ },
119
+ {
120
+ where: { id: 'nxdomain' },
121
+ data: { lastCheckAt: NOW, failingSince: NOW },
122
+ },
123
+ ])
124
+ })
125
+
126
+ it('a month of timeouts never archives', async () => {
127
+ db.dbCommunityTool.findMany.mockResolvedValue([
128
+ tool('timeout', {
129
+ failingSince: new Date(NOW.getTime() - 40 * DAY),
130
+ unreachableSince: new Date(NOW.getTime() - 35 * DAY),
131
+ }),
132
+ ])
133
+ const capturer: ScreenshotCapturer = {
134
+ capture: vi
135
+ .fn()
136
+ .mockResolvedValue(failureOf(new Error('Timeout 20000ms exceeded'))),
137
+ }
138
+
139
+ await runHealthCheckOnce({ ...opts, capturer }, NOW)
140
+
141
+ expect(db.dbCommunityTool.update).toHaveBeenCalledWith({
142
+ where: { id: 'timeout' },
143
+ data: { lastCheckAt: NOW },
144
+ })
145
+ })
146
+
85
147
  it('health run continues with the next tool after an error', async () => {
86
148
  db.dbCommunityTool.findMany.mockResolvedValue([tool('a'), tool('b')])
87
149
  const flaky: ScreenshotCapturer = {
@@ -50,6 +50,7 @@ const dbTool = {
50
50
  archivedAt: null,
51
51
  archivedReason: null,
52
52
  archivedBy: null,
53
+ unreachableSince: null,
53
54
  screenshotAssetId: null,
54
55
  faviconAssetId: null,
55
56
  capturedAt: null,
@@ -57,6 +58,7 @@ const dbTool = {
57
58
  owners: [{ toolId: 'tool-1', personSlug: 'ann@example.com' }],
58
59
  namedOwners: [] as { toolId: string; name: string }[],
59
60
  likes: [] as { userId: string; userName: string }[],
61
+ _count: { feedback: 0 },
60
62
  tags: [],
61
63
  }
62
64
 
@@ -86,7 +88,7 @@ describe('Community tools write service', () => {
86
88
  dbCommunityToolOwner: {
87
89
  findMany: vi.fn().mockResolvedValue(dbTool.owners),
88
90
  },
89
- dbCommunityToolView: { count: vi.fn().mockResolvedValue(0) },
91
+ dbCommunityToolOpen: { count: vi.fn().mockResolvedValue(0) },
90
92
  dbPerson: {
91
93
  findMany: vi
92
94
  .fn()
@@ -354,6 +356,7 @@ describe('Community tools write service', () => {
354
356
  failingSince: NOW,
355
357
  archivedAt: NOW,
356
358
  archivedBy: 'auto',
359
+ unreachableSince: null,
357
360
  archivedReason: 'Not working for 14 days',
358
361
  })
359
362
  const capture = vi.fn().mockResolvedValue({ ok: false, error: 'x' })
@@ -371,6 +374,7 @@ describe('Community tools write service', () => {
371
374
  lastCheckAt: null,
372
375
  archivedAt: null,
373
376
  archivedBy: null,
377
+ unreachableSince: null,
374
378
  archivedReason: null,
375
379
  })
376
380
  await vi.waitFor(() => expect(capture).toHaveBeenCalled())
@@ -382,6 +386,7 @@ describe('Community tools write service', () => {
382
386
  failingSince: NOW,
383
387
  archivedAt: NOW,
384
388
  archivedBy: 'Ann Owner',
389
+ unreachableSince: null,
385
390
  })
386
391
 
387
392
  await write.editTool(
@@ -132,9 +132,14 @@ export type DbCommunityToolNamedOwner = Prisma.DbCommunityToolNamedOwnerModel
132
132
  * Tags attached to community tools (app associations and topics).
133
133
  */
134
134
  export type DbCommunityToolTag = Prisma.DbCommunityToolTagModel
135
+ /**
136
+ * Model DbCommunityToolOpen
137
+ * One card open or click through to the site.
138
+ */
139
+ export type DbCommunityToolOpen = Prisma.DbCommunityToolOpenModel
135
140
  /**
136
141
  * Model DbCommunityToolView
137
- * View tracking for community tools (one row per viewer per tool per day).
142
+ * Superseded by DbCommunityToolOpen and no longer written; kept so the schema stays additive.
138
143
  */
139
144
  export type DbCommunityToolView = Prisma.DbCommunityToolViewModel
140
145
  /**
@@ -156,9 +156,14 @@ export type DbCommunityToolNamedOwner = Prisma.DbCommunityToolNamedOwnerModel
156
156
  * Tags attached to community tools (app associations and topics).
157
157
  */
158
158
  export type DbCommunityToolTag = Prisma.DbCommunityToolTagModel
159
+ /**
160
+ * Model DbCommunityToolOpen
161
+ * One card open or click through to the site.
162
+ */
163
+ export type DbCommunityToolOpen = Prisma.DbCommunityToolOpenModel
159
164
  /**
160
165
  * Model DbCommunityToolView
161
- * View tracking for community tools (one row per viewer per tool per day).
166
+ * Superseded by DbCommunityToolOpen and no longer written; kept so the schema stays additive.
162
167
  */
163
168
  export type DbCommunityToolView = Prisma.DbCommunityToolViewModel
164
169
  /**