@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.
- package/dist/generated/prisma/client.mjs.map +1 -1
- package/dist/generated/prisma/internal/class.d.mts +12 -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 +90 -3
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbCommunityTool.d.mts +289 -4
- package/dist/generated/prisma/models/DbCommunityTool.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbCommunityToolOpen.d.mts +1171 -0
- package/dist/generated/prisma/models/DbCommunityToolOpen.d.mts.map +1 -0
- package/dist/generated/prisma/models.d.mts +2 -1
- package/dist/modules/communityTools/capture.d.mts +8 -1
- package/dist/modules/communityTools/capture.d.mts.map +1 -1
- package/dist/modules/communityTools/capture.mjs +40 -14
- package/dist/modules/communityTools/capture.mjs.map +1 -1
- package/dist/modules/communityTools/healthJob.mjs +3 -2
- package/dist/modules/communityTools/healthJob.mjs.map +1 -1
- package/dist/modules/communityTools/router.d.mts +14 -1
- package/dist/modules/communityTools/router.mjs +7 -2
- package/dist/modules/communityTools/router.mjs.map +1 -1
- package/dist/modules/communityTools/service.mjs +39 -82
- package/dist/modules/communityTools/service.mjs.map +1 -1
- package/dist/modules/communityTools/toView.mjs +7 -2
- package/dist/modules/communityTools/toView.mjs.map +1 -1
- package/dist/modules/communityTools/writeService.mjs +1 -0
- package/dist/modules/communityTools/writeService.mjs.map +1 -1
- package/dist/server/controller.d.mts +9 -0
- package/dist/server/controller.d.mts.map +1 -1
- package/package.json +4 -4
- package/prisma/schema.prisma +25 -2
- package/src/__tests__/communityTools.test.ts +106 -42
- package/src/__tests__/communityToolsCapture.test.ts +34 -0
- package/src/__tests__/communityToolsHealth.test.ts +62 -0
- package/src/__tests__/communityToolsWrite.test.ts +6 -1
- package/src/generated/prisma/browser.ts +6 -1
- package/src/generated/prisma/client.ts +6 -1
- package/src/generated/prisma/internal/class.ts +14 -4
- package/src/generated/prisma/internal/prismaNamespace.ts +90 -1
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +14 -0
- package/src/generated/prisma/models/DbCommunityTool.ts +299 -2
- package/src/generated/prisma/models/DbCommunityToolOpen.ts +1383 -0
- package/src/generated/prisma/models/DbCommunityToolView.ts +1 -1
- package/src/generated/prisma/models.ts +1 -0
- package/src/modules/communityTools/capture.ts +54 -18
- package/src/modules/communityTools/healthJob.ts +7 -1
- package/src/modules/communityTools/router.ts +26 -1
- package/src/modules/communityTools/service.ts +52 -92
- package/src/modules/communityTools/toView.ts +9 -0
- 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
|
-
*
|
|
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
|
-
| {
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
return
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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,
|