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

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 (104) hide show
  1. package/dist/db/syncAppCatalog.mjs +1 -1
  2. package/dist/generated/prisma/client.d.mts +1 -1
  3. package/dist/generated/prisma/client.mjs.map +1 -1
  4. package/dist/generated/prisma/commonInputTypes.d.mts +33 -3
  5. package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
  6. package/dist/generated/prisma/enums.d.mts +6 -1
  7. package/dist/generated/prisma/enums.d.mts.map +1 -1
  8. package/dist/generated/prisma/internal/class.d.mts +84 -0
  9. package/dist/generated/prisma/internal/class.d.mts.map +1 -1
  10. package/dist/generated/prisma/internal/class.mjs +4 -4
  11. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  12. package/dist/generated/prisma/internal/prismaNamespace.d.mts +617 -4
  13. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  14. package/dist/generated/prisma/models/DbCommunityTool.d.mts +2852 -0
  15. package/dist/generated/prisma/models/DbCommunityTool.d.mts.map +1 -0
  16. package/dist/generated/prisma/models/DbCommunityToolLike.d.mts +1102 -0
  17. package/dist/generated/prisma/models/DbCommunityToolLike.d.mts.map +1 -0
  18. package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts +1034 -0
  19. package/dist/generated/prisma/models/DbCommunityToolNamedOwner.d.mts.map +1 -0
  20. package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts +1034 -0
  21. package/dist/generated/prisma/models/DbCommunityToolOwner.d.mts.map +1 -0
  22. package/dist/generated/prisma/models/DbCommunityToolPin.d.mts +1110 -0
  23. package/dist/generated/prisma/models/DbCommunityToolPin.d.mts.map +1 -0
  24. package/dist/generated/prisma/models/DbCommunityToolTag.d.mts +1073 -0
  25. package/dist/generated/prisma/models/DbCommunityToolTag.d.mts.map +1 -0
  26. package/dist/generated/prisma/models/DbCommunityToolView.d.mts +1149 -0
  27. package/dist/generated/prisma/models/DbCommunityToolView.d.mts.map +1 -0
  28. package/dist/generated/prisma/models/DbFeedback.d.mts +361 -2
  29. package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -1
  30. package/dist/generated/prisma/models.d.mts +9 -2
  31. package/dist/index.d.mts +5 -2
  32. package/dist/index.mjs +3 -1
  33. package/dist/middleware/createAcMiddleware.d.mts.map +1 -1
  34. package/dist/middleware/createAcMiddleware.mjs +15 -1
  35. package/dist/middleware/createAcMiddleware.mjs.map +1 -1
  36. package/dist/middleware/database.d.mts +1 -1
  37. package/dist/middleware/types.d.mts +20 -1
  38. package/dist/middleware/types.d.mts.map +1 -1
  39. package/dist/modules/communityTools/capture.d.mts +27 -0
  40. package/dist/modules/communityTools/capture.d.mts.map +1 -0
  41. package/dist/modules/communityTools/capture.mjs +192 -0
  42. package/dist/modules/communityTools/capture.mjs.map +1 -0
  43. package/dist/modules/communityTools/healthJob.mjs +132 -0
  44. package/dist/modules/communityTools/healthJob.mjs.map +1 -0
  45. package/dist/modules/communityTools/router.d.mts +150 -0
  46. package/dist/modules/communityTools/router.d.mts.map +1 -0
  47. package/dist/modules/communityTools/router.mjs +91 -0
  48. package/dist/modules/communityTools/router.mjs.map +1 -0
  49. package/dist/modules/communityTools/service.mjs +252 -0
  50. package/dist/modules/communityTools/service.mjs.map +1 -0
  51. package/dist/modules/communityTools/toView.mjs +81 -0
  52. package/dist/modules/communityTools/toView.mjs.map +1 -0
  53. package/dist/modules/communityTools/writeService.d.mts +55 -0
  54. package/dist/modules/communityTools/writeService.d.mts.map +1 -0
  55. package/dist/modules/communityTools/writeService.mjs +280 -0
  56. package/dist/modules/communityTools/writeService.mjs.map +1 -0
  57. package/dist/modules/feedback/feedbackRouter.d.mts +3 -0
  58. package/dist/modules/feedback/feedbackRouter.mjs +6 -2
  59. package/dist/modules/feedback/feedbackRouter.mjs.map +1 -1
  60. package/dist/modules/feedback/service.d.mts +3 -0
  61. package/dist/modules/feedback/service.d.mts.map +1 -1
  62. package/dist/modules/feedback/service.mjs +50 -17
  63. package/dist/modules/feedback/service.mjs.map +1 -1
  64. package/dist/server/controller.d.mts +143 -0
  65. package/dist/server/controller.d.mts.map +1 -1
  66. package/dist/server/controller.mjs +2 -0
  67. package/dist/server/controller.mjs.map +1 -1
  68. package/dist/server/trpcSetup.mjs +1 -1
  69. package/package.json +12 -5
  70. package/prisma/schema.prisma +161 -0
  71. package/src/__tests__/communityTools.test.ts +781 -0
  72. package/src/__tests__/communityToolsCapture.test.ts +319 -0
  73. package/src/__tests__/communityToolsHealth.test.ts +154 -0
  74. package/src/__tests__/communityToolsRouter.test.ts +58 -0
  75. package/src/__tests__/communityToolsWrite.test.ts +561 -0
  76. package/src/__tests__/feedbackService.test.ts +160 -0
  77. package/src/generated/prisma/browser.ts +35 -0
  78. package/src/generated/prisma/client.ts +35 -0
  79. package/src/generated/prisma/commonInputTypes.ts +34 -0
  80. package/src/generated/prisma/enums.ts +8 -0
  81. package/src/generated/prisma/internal/class.ts +74 -4
  82. package/src/generated/prisma/internal/prismaNamespace.ts +635 -2
  83. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +95 -1
  84. package/src/generated/prisma/models/DbCommunityTool.ts +3135 -0
  85. package/src/generated/prisma/models/DbCommunityToolLike.ts +1313 -0
  86. package/src/generated/prisma/models/DbCommunityToolNamedOwner.ts +1243 -0
  87. package/src/generated/prisma/models/DbCommunityToolOwner.ts +1243 -0
  88. package/src/generated/prisma/models/DbCommunityToolPin.ts +1325 -0
  89. package/src/generated/prisma/models/DbCommunityToolTag.ts +1283 -0
  90. package/src/generated/prisma/models/DbCommunityToolView.ts +1361 -0
  91. package/src/generated/prisma/models/DbFeedback.ts +379 -1
  92. package/src/generated/prisma/models.ts +7 -0
  93. package/src/index.ts +16 -0
  94. package/src/middleware/createAcMiddleware.ts +20 -0
  95. package/src/middleware/types.ts +21 -0
  96. package/src/modules/communityTools/capture.ts +332 -0
  97. package/src/modules/communityTools/healthJob.ts +191 -0
  98. package/src/modules/communityTools/router.ts +172 -0
  99. package/src/modules/communityTools/service.ts +380 -0
  100. package/src/modules/communityTools/toView.ts +112 -0
  101. package/src/modules/communityTools/writeService.ts +408 -0
  102. package/src/modules/feedback/feedbackRouter.ts +20 -6
  103. package/src/modules/feedback/service.ts +68 -12
  104. package/src/server/controller.ts +8 -1
@@ -0,0 +1,172 @@
1
+ import { TRPCError } from '@trpc/server'
2
+ import { z } from 'zod'
3
+ import type { User } from 'better-auth/types'
4
+ import type { CommunityToolsOptions } from '../../middleware/types'
5
+ import {
6
+ adminProcedure as adminBase,
7
+ protectedProcedure as protectedBase,
8
+ publicProcedure as publicBase,
9
+ router,
10
+ t,
11
+ } from '../../server/trpcSetup'
12
+ import type { Actor } from '../visitor/visitorIdentity'
13
+ import * as service from './service'
14
+ import * as write from './writeService'
15
+
16
+ function requireActor(actor: Actor | null): Actor {
17
+ if (!actor) {
18
+ throw new TRPCError({
19
+ code: 'PRECONDITION_FAILED',
20
+ message: 'This action needs cookies enabled',
21
+ })
22
+ }
23
+ return actor
24
+ }
25
+
26
+ function writerOf(ctx: {
27
+ user: User
28
+ isAdmin: boolean
29
+ actor: Actor | null
30
+ }): write.Writer {
31
+ return {
32
+ user: ctx.user,
33
+ isAdmin: ctx.isAdmin,
34
+ actor: requireActor(ctx.actor),
35
+ }
36
+ }
37
+
38
+ /**
39
+ * `options.capturer` is used as given: resolving the playwright default is the caller's
40
+ * job (`createAcMiddleware`), so a router built directly never launches a browser.
41
+ */
42
+ export function createCommunityToolsRouter(options?: CommunityToolsOptions) {
43
+ const enabled = options?.enabled ?? false
44
+ const deps = {
45
+ captureHosts: options?.captureHosts ?? [],
46
+ capturer: options?.capturer ?? null,
47
+ }
48
+
49
+ // One router shape whether enabled or not, so clients see every procedure in the
50
+ // type; when disabled every procedure but `config` answers NOT_FOUND.
51
+ const whenEnabled = t.middleware(({ next }) => {
52
+ if (!enabled) {
53
+ throw new TRPCError({
54
+ code: 'NOT_FOUND',
55
+ message: 'Community tools are disabled',
56
+ })
57
+ }
58
+ return next()
59
+ })
60
+ const publicProcedure = publicBase.use(whenEnabled)
61
+ const protectedProcedure = protectedBase.use(whenEnabled)
62
+ const adminProcedure = adminBase.use(whenEnabled)
63
+
64
+ return router({
65
+ config: publicBase.query(() => ({ enabled })),
66
+
67
+ list: publicProcedure.query(({ ctx }) => {
68
+ return service.listTools(ctx.user, ctx.isAdmin, new Date(), deps)
69
+ }),
70
+
71
+ countByApp: publicProcedure.query(() => service.countByApp()),
72
+
73
+ get: publicProcedure
74
+ .input(z.object({ slug: z.string() }))
75
+ .query(async ({ input, ctx }) => {
76
+ const tool = await service.getTool(
77
+ input.slug,
78
+ ctx.user,
79
+ ctx.isAdmin,
80
+ new Date(),
81
+ deps,
82
+ )
83
+ if (!tool) {
84
+ throw new TRPCError({
85
+ code: 'NOT_FOUND',
86
+ message: 'Tool not found',
87
+ })
88
+ }
89
+ return tool
90
+ }),
91
+
92
+ recordView: publicProcedure
93
+ .input(z.object({ slug: z.string() }))
94
+ .mutation(({ input, ctx }) => {
95
+ const actor = requireActor(ctx.actor)
96
+ return service.recordView(input.slug, actor, ctx.user, new Date())
97
+ }),
98
+
99
+ pins: protectedProcedure.query(({ ctx }) => {
100
+ return service.listPins(
101
+ ctx.user.id,
102
+ ctx.user,
103
+ ctx.isAdmin,
104
+ new Date(),
105
+ deps,
106
+ )
107
+ }),
108
+
109
+ pin: protectedProcedure
110
+ .input(z.object({ slug: z.string() }))
111
+ .mutation(({ input, ctx }) => {
112
+ return service.pinTool(input.slug, ctx.user.id)
113
+ }),
114
+
115
+ like: protectedProcedure
116
+ .input(z.object({ slug: z.string() }))
117
+ .mutation(({ input, ctx }) => {
118
+ return service.likeTool(input.slug, ctx.user)
119
+ }),
120
+
121
+ unlike: protectedProcedure
122
+ .input(z.object({ slug: z.string() }))
123
+ .mutation(({ input, ctx }) => {
124
+ return service.unlikeTool(input.slug, ctx.user.id)
125
+ }),
126
+
127
+ unpin: protectedProcedure
128
+ .input(z.object({ slug: z.string() }))
129
+ .mutation(({ input, ctx }) => {
130
+ return service.unpinTool(input.slug, ctx.user.id)
131
+ }),
132
+
133
+ reorderPins: protectedProcedure
134
+ .input(z.object({ slugs: z.array(z.string()) }))
135
+ .mutation(({ input, ctx }) => {
136
+ return service.reorderPins(input.slugs, ctx.user.id)
137
+ }),
138
+
139
+ preview: protectedProcedure
140
+ .input(z.object({ url: z.string().max(2000) }))
141
+ .mutation(({ input }) => write.previewTool(input.url, deps)),
142
+
143
+ add: protectedProcedure
144
+ .input(write.toolInputSchema)
145
+ .mutation(({ input, ctx }) =>
146
+ write.addTool(input, writerOf(ctx), deps, new Date()),
147
+ ),
148
+
149
+ edit: protectedProcedure
150
+ .input(write.toolInputSchema.extend({ slug: z.string() }))
151
+ .mutation(({ input, ctx }) =>
152
+ write.editTool(input, writerOf(ctx), deps, new Date()),
153
+ ),
154
+
155
+ archive: protectedProcedure
156
+ .input(
157
+ z.object({
158
+ slug: z.string(),
159
+ reason: z.string().trim().min(1).max(500),
160
+ }),
161
+ )
162
+ .mutation(({ input, ctx }) =>
163
+ write.archiveTool(input.slug, input.reason, writerOf(ctx), new Date()),
164
+ ),
165
+
166
+ approve: adminProcedure
167
+ .input(z.object({ slug: z.string() }))
168
+ .mutation(({ input, ctx }) =>
169
+ write.approveTool(input.slug, writerOf(ctx), new Date()),
170
+ ),
171
+ })
172
+ }
@@ -0,0 +1,380 @@
1
+ import { TRPCError } from '@trpc/server'
2
+ import type { User } from 'better-auth/types'
3
+ import { getDbClient } from '../../db/client'
4
+ import type { Actor } from '../visitor/visitorIdentity'
5
+ import type { CommunityTool } from '@igstack/app-catalog-shared-core'
6
+ import type { CaptureDeps } from './healthJob'
7
+ import { LIKES, toView } from './toView'
8
+
9
+ export async function listTools(
10
+ user: User | null,
11
+ isAdmin: boolean,
12
+ now: Date,
13
+ capture: CaptureDeps,
14
+ ): Promise<CommunityTool[]> {
15
+ const db = getDbClient()
16
+
17
+ const tools = await db.dbCommunityTool.findMany({
18
+ include: {
19
+ owners: true,
20
+ namedOwners: true,
21
+ likes: LIKES,
22
+ tags: true,
23
+ },
24
+ })
25
+
26
+ const toolIds = tools.map((t) => t.id)
27
+
28
+ // Fetch all persons in one query
29
+ const ownerSlugs = tools.flatMap((t) => t.owners.map((o) => o.personSlug))
30
+ const uniqueOwnerSlugs = Array.from(new Set(ownerSlugs))
31
+ const persons = await db.dbPerson.findMany({
32
+ where: { slug: { in: uniqueOwnerSlugs } },
33
+ })
34
+ const personMap = new Map(persons.map((p) => [p.slug, p]))
35
+
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
+ const viewCountMap = new Map(
54
+ viewCounts.map((v) => [
55
+ v.toolId,
56
+ { views30d: v.views30d, viewsAll: v.viewsAll },
57
+ ]),
58
+ )
59
+
60
+ return Promise.all(
61
+ tools.map((tool) =>
62
+ toView(
63
+ tool,
64
+ personMap,
65
+ viewCountMap.get(tool.id)!,
66
+ user,
67
+ isAdmin,
68
+ now,
69
+ capture,
70
+ ),
71
+ ),
72
+ )
73
+ }
74
+
75
+ /** How many live tools carry each official app's tag, by app slug. */
76
+ export async function countByApp(): Promise<Record<string, number>> {
77
+ const rows = await getDbClient().dbCommunityToolTag.groupBy({
78
+ by: ['value'],
79
+ where: { kind: 'app', tool: { archivedAt: null } },
80
+ _count: { _all: true },
81
+ })
82
+ return Object.fromEntries(rows.map((r) => [r.value, r._count._all]))
83
+ }
84
+
85
+ export async function getTool(
86
+ slug: string,
87
+ user: User | null,
88
+ isAdmin: boolean,
89
+ now: Date,
90
+ capture: CaptureDeps,
91
+ ): Promise<CommunityTool | null> {
92
+ const db = getDbClient()
93
+
94
+ const tool = await db.dbCommunityTool.findUnique({
95
+ where: { slug },
96
+ include: {
97
+ owners: true,
98
+ namedOwners: true,
99
+ likes: LIKES,
100
+ tags: true,
101
+ },
102
+ })
103
+
104
+ if (!tool) return null
105
+
106
+ const ownerSlugs = tool.owners.map((o) => o.personSlug)
107
+ const persons = await db.dbPerson.findMany({
108
+ where: { slug: { in: ownerSlugs } },
109
+ })
110
+ const personMap = new Map(persons.map((p) => [p.slug, p]))
111
+
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
+ return toView(
125
+ tool,
126
+ personMap,
127
+ { views30d, viewsAll },
128
+ user,
129
+ isAdmin,
130
+ now,
131
+ capture,
132
+ )
133
+ }
134
+
135
+ export async function recordView(
136
+ slug: string,
137
+ actor: Actor,
138
+ user: User | null,
139
+ now: Date,
140
+ ): Promise<{ views30d: number; viewsAll: number }> {
141
+ const db = getDbClient()
142
+
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({
152
+ where: {
153
+ toolId_actorHash_day: {
154
+ toolId: tool.id,
155
+ actorHash: actor.hash,
156
+ day,
157
+ },
158
+ },
159
+ create: {
160
+ toolId: tool.id,
161
+ actorHash: actor.hash,
162
+ userId: user?.id ?? null,
163
+ day,
164
+ at: now,
165
+ },
166
+ update: {
167
+ at: now,
168
+ userId: user?.id ?? null,
169
+ },
170
+ })
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 }
183
+ }
184
+
185
+ export async function listPins(
186
+ userId: string,
187
+ user: User | null,
188
+ isAdmin: boolean,
189
+ now: Date,
190
+ capture: CaptureDeps,
191
+ ): Promise<CommunityTool[]> {
192
+ const db = getDbClient()
193
+
194
+ const pins = await db.dbCommunityToolPin.findMany({
195
+ where: { userId },
196
+ orderBy: { position: 'asc' },
197
+ include: {
198
+ tool: {
199
+ include: {
200
+ owners: true,
201
+ namedOwners: true,
202
+ likes: LIKES,
203
+ tags: true,
204
+ },
205
+ },
206
+ },
207
+ })
208
+
209
+ const toolIds = pins.map((p) => p.tool.id)
210
+
211
+ const ownerSlugs = pins.flatMap((p) => p.tool.owners.map((o) => o.personSlug))
212
+ const uniqueOwnerSlugs = Array.from(new Set(ownerSlugs))
213
+ const persons = await db.dbPerson.findMany({
214
+ where: { slug: { in: uniqueOwnerSlugs } },
215
+ })
216
+ const personMap = new Map(persons.map((p) => [p.slug, p]))
217
+
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
+ const viewCountMap = new Map(
235
+ viewCounts.map((v) => [
236
+ v.toolId,
237
+ { views30d: v.views30d, viewsAll: v.viewsAll },
238
+ ]),
239
+ )
240
+
241
+ return Promise.all(
242
+ pins.map((pin) =>
243
+ toView(
244
+ pin.tool,
245
+ personMap,
246
+ viewCountMap.get(pin.tool.id)!,
247
+ user,
248
+ isAdmin,
249
+ now,
250
+ capture,
251
+ ),
252
+ ),
253
+ )
254
+ }
255
+
256
+ async function requireToolId(slug: string): Promise<string> {
257
+ const tool = await getDbClient().dbCommunityTool.findUnique({
258
+ where: { slug },
259
+ select: { id: true },
260
+ })
261
+ if (!tool) {
262
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Tool not found' })
263
+ }
264
+ return tool.id
265
+ }
266
+
267
+ /** Pinning a pinned tool leaves it where it is. */
268
+ export async function pinTool(slug: string, userId: string): Promise<void> {
269
+ const db = getDbClient()
270
+ const toolId = await requireToolId(slug)
271
+
272
+ const maxPin = await db.dbCommunityToolPin.findFirst({
273
+ where: { userId },
274
+ orderBy: { position: 'desc' },
275
+ })
276
+
277
+ await db.dbCommunityToolPin.upsert({
278
+ where: { userId_toolId: { userId, toolId } },
279
+ create: { userId, toolId, position: maxPin ? maxPin.position + 1 : 0 },
280
+ update: {},
281
+ })
282
+ }
283
+
284
+ /** Prisma's unique-constraint error. */
285
+ function isUniqueViolation(error: unknown): boolean {
286
+ return (
287
+ error instanceof Error &&
288
+ 'code' in error &&
289
+ (error as { code: unknown }).code === 'P2002'
290
+ )
291
+ }
292
+
293
+ /** Liking twice is one like. */
294
+ export async function likeTool(
295
+ slug: string,
296
+ user: { id: string; name: string },
297
+ ): Promise<void> {
298
+ const toolId = await requireToolId(slug)
299
+ try {
300
+ await getDbClient().dbCommunityToolLike.upsert({
301
+ where: { toolId_userId: { toolId, userId: user.id } },
302
+ create: { toolId, userId: user.id, userName: user.name },
303
+ update: {},
304
+ })
305
+ } catch (error) {
306
+ // Two likes at once both miss, and one insert loses the race: still liked.
307
+ if (!isUniqueViolation(error)) throw error
308
+ }
309
+ }
310
+
311
+ /** Unliking what was never liked changes nothing. */
312
+ export async function unlikeTool(slug: string, userId: string): Promise<void> {
313
+ const toolId = await requireToolId(slug)
314
+ await getDbClient().dbCommunityToolLike.deleteMany({
315
+ where: { toolId, userId },
316
+ })
317
+ }
318
+
319
+ export async function unpinTool(slug: string, userId: string): Promise<void> {
320
+ const toolId = await requireToolId(slug)
321
+ await getDbClient().dbCommunityToolPin.deleteMany({
322
+ where: { userId, toolId },
323
+ })
324
+ }
325
+
326
+ export async function reorderPins(
327
+ slugs: string[],
328
+ userId: string,
329
+ ): Promise<void> {
330
+ const db = getDbClient()
331
+
332
+ // Get all pins for this user
333
+ const pins = await db.dbCommunityToolPin.findMany({
334
+ where: { userId },
335
+ include: { tool: true },
336
+ orderBy: { position: 'asc' },
337
+ })
338
+
339
+ // Create a map of slug to pin
340
+ const slugToPin = new Map(pins.map((p) => [p.tool.slug, p]))
341
+
342
+ // Build new order: named slugs first, then unlisted pins in current order
343
+ const namedPins = slugs
344
+ .map((slug) => slugToPin.get(slug))
345
+ .filter((p) => p !== undefined)
346
+ const unlistedPins = pins.filter((p) => !slugs.includes(p.tool.slug))
347
+ const newOrder = [...namedPins, ...unlistedPins]
348
+
349
+ // Update positions
350
+ await Promise.all(
351
+ newOrder.map((pin, index) =>
352
+ db.dbCommunityToolPin.update({
353
+ where: {
354
+ userId_toolId: {
355
+ userId,
356
+ toolId: pin.toolId,
357
+ },
358
+ },
359
+ data: { position: index },
360
+ }),
361
+ ),
362
+ )
363
+ }
364
+
365
+ export async function computeCanEdit(
366
+ tool: { id: string; addedByUserId: string },
367
+ user: User | null,
368
+ isAdmin: boolean,
369
+ ): Promise<boolean> {
370
+ if (!user) return false
371
+ if (isAdmin) return true
372
+ if (tool.addedByUserId === user.id) return true
373
+
374
+ const db = getDbClient()
375
+ const owners = await db.dbCommunityToolOwner.findMany({
376
+ where: { toolId: tool.id },
377
+ })
378
+
379
+ return owners.some((o: { personSlug: string }) => o.personSlug === user.email)
380
+ }
@@ -0,0 +1,112 @@
1
+ import type { User } from 'better-auth/types'
2
+ import type {
3
+ CommunityTag,
4
+ CommunityTool,
5
+ PersonRef,
6
+ } from '@igstack/app-catalog-shared-core'
7
+ import { isCaptureAllowed, lifecycleOf } from '@igstack/app-catalog-shared-core'
8
+ import type { DbPerson, Prisma } from '../../generated/prisma/client'
9
+ import type { CaptureDeps } from './healthJob'
10
+ import { computeCanEdit } from './service'
11
+
12
+ /** The likes a view reads: newest first, for "Liked by". */
13
+ export const LIKES = {
14
+ orderBy: { createdAt: 'desc' },
15
+ select: { userId: true, userName: true },
16
+ } as const satisfies Prisma.DbCommunityTool$likesArgs
17
+
18
+ /** How many likers a view names; the rest are a count. */
19
+ const LIKED_BY_NAMES = 8
20
+
21
+ type DbToolWithRelations = Prisma.DbCommunityToolGetPayload<{
22
+ include: {
23
+ owners: true
24
+ namedOwners: true
25
+ tags: true
26
+ likes: typeof LIKES
27
+ }
28
+ }>
29
+
30
+ export async function toView(
31
+ tool: DbToolWithRelations,
32
+ personMap: Map<string, DbPerson>,
33
+ viewCounts: { views30d: number; viewsAll: number },
34
+ user: User | null,
35
+ isAdmin: boolean,
36
+ now: Date,
37
+ capture: CaptureDeps,
38
+ ): Promise<CommunityTool> {
39
+ const owners: PersonRef[] = tool.owners.map((o) => {
40
+ const person = personMap.get(o.personSlug)
41
+ if (person) {
42
+ const name = `${person.firstName} ${person.lastName}`.trim()
43
+ return { slug: o.personSlug, name: name || o.personSlug }
44
+ }
45
+ return { slug: o.personSlug, name: o.personSlug }
46
+ })
47
+ // Outside the directory: a name to ask for, never an editor.
48
+ for (const o of tool.namedOwners) owners.push({ slug: null, name: o.name })
49
+
50
+ const tags: CommunityTag[] = tool.tags.map((t) => ({
51
+ kind: t.kind,
52
+ value: t.value,
53
+ }))
54
+
55
+ const lifecycle = lifecycleOf(
56
+ {
57
+ failingSince: tool.failingSince,
58
+ archivedAt: tool.archivedAt,
59
+ archivedBy: tool.archivedBy,
60
+ archivedReason: tool.archivedReason,
61
+ },
62
+ now,
63
+ )
64
+
65
+ // Never visited yet, and a visit will come: the same conditions `checkTool`
66
+ // runs under. A finished visit sets `lastCheckAt`, failed or not.
67
+ const capturePending =
68
+ !tool.screenshotAssetId &&
69
+ !tool.lastCheckAt &&
70
+ capture.capturer !== null &&
71
+ isCaptureAllowed(tool.url, capture.captureHosts)
72
+
73
+ const canEdit = await computeCanEdit(
74
+ { id: tool.id, addedByUserId: tool.addedByUserId },
75
+ user,
76
+ isAdmin,
77
+ )
78
+
79
+ return {
80
+ slug: tool.slug,
81
+ name: tool.name,
82
+ url: tool.url,
83
+ host: tool.host,
84
+ summary: tool.summary,
85
+ descriptionMd: tool.descriptionMd,
86
+ owners,
87
+ addedBy: {
88
+ userId: tool.addedByUserId,
89
+ name: tool.addedByName,
90
+ },
91
+ createdAt: tool.createdAt.toISOString(),
92
+ // ponytail: every like row is read per view; a counted column if likes run to thousands.
93
+ likes: tool.likes.length,
94
+ likedByMe: !!user && tool.likes.some((l) => l.userId === user.id),
95
+ // The viewer's own like is `likedByMe`; the names are everyone else's.
96
+ likedBy: tool.likes
97
+ .filter((l) => l.userId !== user?.id)
98
+ .slice(0, LIKED_BY_NAMES)
99
+ .map((l) => l.userName),
100
+ siteUpdatedAt: tool.siteUpdatedAt?.toISOString() ?? null,
101
+ tags,
102
+ views30d: viewCounts.views30d,
103
+ viewsAll: viewCounts.viewsAll,
104
+ reviewed: tool.reviewedAt !== null,
105
+ lifecycle,
106
+ screenshotId: tool.screenshotAssetId ?? null,
107
+ faviconId: tool.faviconAssetId ?? null,
108
+ capturedAt: tool.capturedAt?.toISOString() ?? null,
109
+ capturePending,
110
+ canEdit,
111
+ }
112
+ }