@igstack/app-catalog-backend-core 6.12.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 +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
@@ -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,6 +210,7 @@ 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: [],
@@ -203,6 +241,7 @@ describe('Community tools service', () => {
203
241
  failingSince: null,
204
242
  archivedAt: null,
205
243
  archivedBy: null,
244
+ unreachableSince: null,
206
245
  archivedReason: null,
207
246
  owners: [],
208
247
  namedOwners: [],
@@ -233,6 +272,7 @@ describe('Community tools service', () => {
233
272
  failingSince: null,
234
273
  archivedAt: null,
235
274
  archivedBy: null,
275
+ unreachableSince: null,
236
276
  archivedReason: null,
237
277
  owners: [],
238
278
  namedOwners: [],
@@ -244,7 +284,7 @@ describe('Community tools service', () => {
244
284
  ])
245
285
 
246
286
  mockDb.dbPerson.findMany = vi.fn().mockResolvedValue([])
247
- mockDb.dbCommunityToolView.count = vi.fn().mockResolvedValue(0)
287
+ mockDb.dbCommunityToolOpen.count = vi.fn().mockResolvedValue(0)
248
288
 
249
289
  const now = new Date('2024-01-15T12:00:00Z')
250
290
  const result = await service.listPins(
@@ -410,6 +450,7 @@ describe('Community tools service', () => {
410
450
  failingSince: null,
411
451
  archivedAt: null,
412
452
  archivedBy: null,
453
+ unreachableSince: null,
413
454
  archivedReason: null,
414
455
  owners: [],
415
456
  namedOwners: [],
@@ -436,6 +477,7 @@ describe('Community tools service', () => {
436
477
  failingSince: new Date('2024-01-10T00:00:00Z'),
437
478
  archivedAt: null,
438
479
  archivedBy: null,
480
+ unreachableSince: null,
439
481
  archivedReason: null,
440
482
  owners: [],
441
483
  namedOwners: [],
@@ -446,7 +488,7 @@ describe('Community tools service', () => {
446
488
  ])
447
489
 
448
490
  mockDb.dbPerson.findMany = vi.fn().mockResolvedValue([])
449
- mockDb.dbCommunityToolView.count = vi.fn().mockResolvedValue(0)
491
+ mockDb.dbCommunityToolOpen.count = vi.fn().mockResolvedValue(0)
450
492
 
451
493
  const result = await service.listTools(null, false, now, NO_CAPTURE)
452
494
 
@@ -486,6 +528,7 @@ describe('Community tools service', () => {
486
528
  archivedAt: null,
487
529
  archivedReason: null,
488
530
  archivedBy: null,
531
+ unreachableSince: null,
489
532
  screenshotAssetId: null,
490
533
  faviconAssetId: null,
491
534
  capturedAt: null,
@@ -612,6 +655,7 @@ describe('Community tools service', () => {
612
655
  failingSince: null,
613
656
  archivedAt: null,
614
657
  archivedBy: null,
658
+ unreachableSince: null,
615
659
  },
616
660
  { ...CAPTURES, capturer: launchFails },
617
661
  now,
@@ -655,6 +699,7 @@ describe('Community tools service', () => {
655
699
  failingSince: null,
656
700
  archivedAt: null,
657
701
  archivedBy: null,
702
+ unreachableSince: null,
658
703
  },
659
704
  { ...CAPTURES, capturer: { capture } },
660
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,
@@ -87,7 +88,7 @@ describe('Community tools write service', () => {
87
88
  dbCommunityToolOwner: {
88
89
  findMany: vi.fn().mockResolvedValue(dbTool.owners),
89
90
  },
90
- dbCommunityToolView: { count: vi.fn().mockResolvedValue(0) },
91
+ dbCommunityToolOpen: { count: vi.fn().mockResolvedValue(0) },
91
92
  dbPerson: {
92
93
  findMany: vi
93
94
  .fn()
@@ -355,6 +356,7 @@ describe('Community tools write service', () => {
355
356
  failingSince: NOW,
356
357
  archivedAt: NOW,
357
358
  archivedBy: 'auto',
359
+ unreachableSince: null,
358
360
  archivedReason: 'Not working for 14 days',
359
361
  })
360
362
  const capture = vi.fn().mockResolvedValue({ ok: false, error: 'x' })
@@ -372,6 +374,7 @@ describe('Community tools write service', () => {
372
374
  lastCheckAt: null,
373
375
  archivedAt: null,
374
376
  archivedBy: null,
377
+ unreachableSince: null,
375
378
  archivedReason: null,
376
379
  })
377
380
  await vi.waitFor(() => expect(capture).toHaveBeenCalled())
@@ -383,6 +386,7 @@ describe('Community tools write service', () => {
383
386
  failingSince: NOW,
384
387
  archivedAt: NOW,
385
388
  archivedBy: 'Ann Owner',
389
+ unreachableSince: null,
386
390
  })
387
391
 
388
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
  /**