@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
@@ -17,25 +17,7 @@ async function listTools(user, isAdmin, now, capture) {
17
17
  const uniqueOwnerSlugs = Array.from(new Set(ownerSlugs));
18
18
  const persons = await db.dbPerson.findMany({ where: { slug: { in: uniqueOwnerSlugs } } });
19
19
  const personMap = new Map(persons.map((p) => [p.slug, p]));
20
- const today = new Date(now);
21
- today.setUTCHours(0, 0, 0, 0);
22
- const thirtyDaysAgo = new Date(today);
23
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29);
24
- const viewCounts = await Promise.all(toolIds.map(async (toolId) => {
25
- const [views30d, viewsAll] = await Promise.all([db.dbCommunityToolView.count({ where: {
26
- toolId,
27
- day: { gte: thirtyDaysAgo }
28
- } }), db.dbCommunityToolView.count({ where: { toolId } })]);
29
- return {
30
- toolId,
31
- views30d,
32
- viewsAll
33
- };
34
- }));
35
- const viewCountMap = new Map(viewCounts.map((v) => [v.toolId, {
36
- views30d: v.views30d,
37
- viewsAll: v.viewsAll
38
- }]));
20
+ const viewCountMap = new Map(await Promise.all(toolIds.map(async (id) => [id, await openCounts(id, now)])));
39
21
  return Promise.all(tools.map((tool) => toView(tool, personMap, viewCountMap.get(tool.id), user, isAdmin, now, capture)));
40
22
  }
41
23
  /** How many live tools carry each official app's tag, by app slug. */
@@ -65,55 +47,45 @@ async function getTool(slug, user, isAdmin, now, capture) {
65
47
  if (!tool) return null;
66
48
  const ownerSlugs = tool.owners.map((o) => o.personSlug);
67
49
  const persons = await db.dbPerson.findMany({ where: { slug: { in: ownerSlugs } } });
68
- const personMap = new Map(persons.map((p) => [p.slug, p]));
69
- const today = new Date(now);
70
- today.setUTCHours(0, 0, 0, 0);
71
- const thirtyDaysAgo = new Date(today);
72
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29);
73
- const [views30d, viewsAll] = await Promise.all([db.dbCommunityToolView.count({ where: {
74
- toolId: tool.id,
75
- day: { gte: thirtyDaysAgo }
76
- } }), db.dbCommunityToolView.count({ where: { toolId: tool.id } })]);
77
- return toView(tool, personMap, {
78
- views30d,
79
- viewsAll
80
- }, user, isAdmin, now, capture);
50
+ return toView(tool, new Map(persons.map((p) => [p.slug, p])), await openCounts(tool.id, now), user, isAdmin, now, capture);
81
51
  }
82
- async function recordView(slug, actor, user, now) {
52
+ /** Opens since the first of the last 30 UTC days, and ever. */
53
+ async function openCounts(toolId, now) {
83
54
  const db = getDbClient();
84
- const tool = await db.dbCommunityTool.findUnique({ where: { slug } });
85
- if (!tool) throw new Error("Tool not found");
86
- const day = new Date(now);
87
- day.setUTCHours(0, 0, 0, 0);
88
- await db.dbCommunityToolView.upsert({
89
- where: { toolId_actorHash_day: {
90
- toolId: tool.id,
91
- actorHash: actor.hash,
92
- day
93
- } },
94
- create: {
95
- toolId: tool.id,
96
- actorHash: actor.hash,
97
- userId: user?.id ?? null,
98
- day,
99
- at: now
100
- },
101
- update: {
102
- at: now,
103
- userId: user?.id ?? null
104
- }
105
- });
106
- const thirtyDaysAgo = new Date(day);
107
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29);
108
- const [views30d, viewsAll] = await Promise.all([db.dbCommunityToolView.count({ where: {
109
- toolId: tool.id,
110
- day: { gte: thirtyDaysAgo }
111
- } }), db.dbCommunityToolView.count({ where: { toolId: tool.id } })]);
55
+ const since = new Date(now);
56
+ since.setUTCHours(0, 0, 0, 0);
57
+ since.setUTCDate(since.getUTCDate() - 29);
58
+ const [views30d, viewsAll] = await Promise.all([db.dbCommunityToolOpen.count({ where: {
59
+ toolId,
60
+ at: { gte: since }
61
+ } }), db.dbCommunityToolOpen.count({ where: { toolId } })]);
112
62
  return {
113
63
  views30d,
114
64
  viewsAll
115
65
  };
116
66
  }
67
+ /** More opens than this by one visitor of one tool in a minute are ignored. */
68
+ const OPENS_PER_MINUTE = 30;
69
+ /**
70
+ * Counts one card open or click through to the site: every one counts, short
71
+ * of a visitor opening the same tool more than OPENS_PER_MINUTE times a minute.
72
+ */
73
+ async function recordOpen(slug, kind, actor, user, now) {
74
+ const db = getDbClient();
75
+ const toolId = await requireToolId(slug);
76
+ if (await db.dbCommunityToolOpen.count({ where: {
77
+ toolId,
78
+ actorHash: actor.hash,
79
+ at: { gt: /* @__PURE__ */ new Date(now.getTime() - 6e4) }
80
+ } }) < OPENS_PER_MINUTE) await db.dbCommunityToolOpen.create({ data: {
81
+ toolId,
82
+ kind,
83
+ actorHash: actor.hash,
84
+ userId: user?.id ?? null,
85
+ at: now
86
+ } });
87
+ return openCounts(toolId, now);
88
+ }
117
89
  async function listPins(userId, user, isAdmin, now, capture) {
118
90
  const db = getDbClient();
119
91
  const pins = await db.dbCommunityToolPin.findMany({
@@ -132,25 +104,7 @@ async function listPins(userId, user, isAdmin, now, capture) {
132
104
  const uniqueOwnerSlugs = Array.from(new Set(ownerSlugs));
133
105
  const persons = await db.dbPerson.findMany({ where: { slug: { in: uniqueOwnerSlugs } } });
134
106
  const personMap = new Map(persons.map((p) => [p.slug, p]));
135
- const today = new Date(now);
136
- today.setUTCHours(0, 0, 0, 0);
137
- const thirtyDaysAgo = new Date(today);
138
- thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29);
139
- const viewCounts = await Promise.all(toolIds.map(async (toolId) => {
140
- const [views30d, viewsAll] = await Promise.all([db.dbCommunityToolView.count({ where: {
141
- toolId,
142
- day: { gte: thirtyDaysAgo }
143
- } }), db.dbCommunityToolView.count({ where: { toolId } })]);
144
- return {
145
- toolId,
146
- views30d,
147
- viewsAll
148
- };
149
- }));
150
- const viewCountMap = new Map(viewCounts.map((v) => [v.toolId, {
151
- views30d: v.views30d,
152
- viewsAll: v.viewsAll
153
- }]));
107
+ const viewCountMap = new Map(await Promise.all(toolIds.map(async (id) => [id, await openCounts(id, now)])));
154
108
  return Promise.all(pins.map((pin) => toView(pin.tool, personMap, viewCountMap.get(pin.tool.id), user, isAdmin, now, capture)));
155
109
  }
156
110
  async function requireToolId(slug) {
@@ -251,5 +205,5 @@ async function computeCanEdit(tool, user, isAdmin) {
251
205
  }
252
206
 
253
207
  //#endregion
254
- export { computeCanEdit, countByApp, getTool, likeTool, listPins, listTools, pinTool, recordView, reorderPins, unlikeTool, unpinTool };
208
+ export { computeCanEdit, countByApp, getTool, likeTool, listPins, listTools, pinTool, recordOpen, reorderPins, unlikeTool, unpinTool };
255
209
  //# sourceMappingURL=service.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"service.mjs","names":[],"sources":["../../../src/modules/communityTools/service.ts"],"sourcesContent":["import { TRPCError } from '@trpc/server'\nimport type { User } from 'better-auth/types'\nimport { getDbClient } from '../../db/client'\nimport type { Actor } from '../visitor/visitorIdentity'\nimport type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport type { CaptureDeps } from './healthJob'\nimport { COMMENTS, LIKES, toView } from './toView'\n\nexport async function listTools(\n user: User | null,\n isAdmin: boolean,\n now: Date,\n capture: CaptureDeps,\n): Promise<CommunityTool[]> {\n const db = getDbClient()\n\n const tools = await db.dbCommunityTool.findMany({\n include: {\n owners: true,\n namedOwners: true,\n likes: LIKES,\n _count: COMMENTS,\n tags: true,\n },\n })\n\n const toolIds = tools.map((t) => t.id)\n\n // Fetch all persons in one query\n const ownerSlugs = tools.flatMap((t) => t.owners.map((o) => o.personSlug))\n const uniqueOwnerSlugs = Array.from(new Set(ownerSlugs))\n const persons = await db.dbPerson.findMany({\n where: { slug: { in: uniqueOwnerSlugs } },\n })\n const personMap = new Map(persons.map((p) => [p.slug, p]))\n\n // Get view counts for all tools\n const today = new Date(now)\n today.setUTCHours(0, 0, 0, 0)\n const thirtyDaysAgo = new Date(today)\n thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)\n\n const viewCounts = await Promise.all(\n toolIds.map(async (toolId) => {\n const [views30d, viewsAll] = await Promise.all([\n db.dbCommunityToolView.count({\n where: { toolId, day: { gte: thirtyDaysAgo } },\n }),\n db.dbCommunityToolView.count({ where: { toolId } }),\n ])\n return { toolId, views30d, viewsAll }\n }),\n )\n const viewCountMap = new Map(\n viewCounts.map((v) => [\n v.toolId,\n { views30d: v.views30d, viewsAll: v.viewsAll },\n ]),\n )\n\n return Promise.all(\n tools.map((tool) =>\n toView(\n tool,\n personMap,\n viewCountMap.get(tool.id)!,\n user,\n isAdmin,\n now,\n capture,\n ),\n ),\n )\n}\n\n/** How many live tools carry each official app's tag, by app slug. */\nexport async function countByApp(): Promise<Record<string, number>> {\n const rows = await getDbClient().dbCommunityToolTag.groupBy({\n by: ['value'],\n where: { kind: 'app', tool: { archivedAt: null } },\n _count: { _all: true },\n })\n return Object.fromEntries(rows.map((r) => [r.value, r._count._all]))\n}\n\nexport async function getTool(\n slug: string,\n user: User | null,\n isAdmin: boolean,\n now: Date,\n capture: CaptureDeps,\n): Promise<CommunityTool | null> {\n const db = getDbClient()\n\n const tool = await db.dbCommunityTool.findUnique({\n where: { slug },\n include: {\n owners: true,\n namedOwners: true,\n likes: LIKES,\n _count: COMMENTS,\n tags: true,\n },\n })\n\n if (!tool) return null\n\n const ownerSlugs = tool.owners.map((o) => o.personSlug)\n const persons = await db.dbPerson.findMany({\n where: { slug: { in: ownerSlugs } },\n })\n const personMap = new Map(persons.map((p) => [p.slug, p]))\n\n const today = new Date(now)\n today.setUTCHours(0, 0, 0, 0)\n const thirtyDaysAgo = new Date(today)\n thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)\n\n const [views30d, viewsAll] = await Promise.all([\n db.dbCommunityToolView.count({\n where: { toolId: tool.id, day: { gte: thirtyDaysAgo } },\n }),\n db.dbCommunityToolView.count({ where: { toolId: tool.id } }),\n ])\n\n return toView(\n tool,\n personMap,\n { views30d, viewsAll },\n user,\n isAdmin,\n now,\n capture,\n )\n}\n\nexport async function recordView(\n slug: string,\n actor: Actor,\n user: User | null,\n now: Date,\n): Promise<{ views30d: number; viewsAll: number }> {\n const db = getDbClient()\n\n const tool = await db.dbCommunityTool.findUnique({ where: { slug } })\n if (!tool) {\n throw new Error('Tool not found')\n }\n\n const day = new Date(now)\n day.setUTCHours(0, 0, 0, 0)\n\n await db.dbCommunityToolView.upsert({\n where: {\n toolId_actorHash_day: {\n toolId: tool.id,\n actorHash: actor.hash,\n day,\n },\n },\n create: {\n toolId: tool.id,\n actorHash: actor.hash,\n userId: user?.id ?? null,\n day,\n at: now,\n },\n update: {\n at: now,\n userId: user?.id ?? null,\n },\n })\n\n const thirtyDaysAgo = new Date(day)\n thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)\n\n const [views30d, viewsAll] = await Promise.all([\n db.dbCommunityToolView.count({\n where: { toolId: tool.id, day: { gte: thirtyDaysAgo } },\n }),\n db.dbCommunityToolView.count({ where: { toolId: tool.id } }),\n ])\n\n return { views30d, viewsAll }\n}\n\nexport async function listPins(\n userId: string,\n user: User | null,\n isAdmin: boolean,\n now: Date,\n capture: CaptureDeps,\n): Promise<CommunityTool[]> {\n const db = getDbClient()\n\n const pins = await db.dbCommunityToolPin.findMany({\n where: { userId },\n orderBy: { position: 'asc' },\n include: {\n tool: {\n include: {\n owners: true,\n namedOwners: true,\n likes: LIKES,\n _count: COMMENTS,\n tags: true,\n },\n },\n },\n })\n\n const toolIds = pins.map((p) => p.tool.id)\n\n const ownerSlugs = pins.flatMap((p) => p.tool.owners.map((o) => o.personSlug))\n const uniqueOwnerSlugs = Array.from(new Set(ownerSlugs))\n const persons = await db.dbPerson.findMany({\n where: { slug: { in: uniqueOwnerSlugs } },\n })\n const personMap = new Map(persons.map((p) => [p.slug, p]))\n\n const today = new Date(now)\n today.setUTCHours(0, 0, 0, 0)\n const thirtyDaysAgo = new Date(today)\n thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)\n\n const viewCounts = await Promise.all(\n toolIds.map(async (toolId) => {\n const [views30d, viewsAll] = await Promise.all([\n db.dbCommunityToolView.count({\n where: { toolId, day: { gte: thirtyDaysAgo } },\n }),\n db.dbCommunityToolView.count({ where: { toolId } }),\n ])\n return { toolId, views30d, viewsAll }\n }),\n )\n const viewCountMap = new Map(\n viewCounts.map((v) => [\n v.toolId,\n { views30d: v.views30d, viewsAll: v.viewsAll },\n ]),\n )\n\n return Promise.all(\n pins.map((pin) =>\n toView(\n pin.tool,\n personMap,\n viewCountMap.get(pin.tool.id)!,\n user,\n isAdmin,\n now,\n capture,\n ),\n ),\n )\n}\n\nasync function requireToolId(slug: string): Promise<string> {\n const tool = await getDbClient().dbCommunityTool.findUnique({\n where: { slug },\n select: { id: true },\n })\n if (!tool) {\n throw new TRPCError({ code: 'NOT_FOUND', message: 'Tool not found' })\n }\n return tool.id\n}\n\n/** Pinning a pinned tool leaves it where it is. */\nexport async function pinTool(slug: string, userId: string): Promise<void> {\n const db = getDbClient()\n const toolId = await requireToolId(slug)\n\n const maxPin = await db.dbCommunityToolPin.findFirst({\n where: { userId },\n orderBy: { position: 'desc' },\n })\n\n await db.dbCommunityToolPin.upsert({\n where: { userId_toolId: { userId, toolId } },\n create: { userId, toolId, position: maxPin ? maxPin.position + 1 : 0 },\n update: {},\n })\n}\n\n/** Prisma's unique-constraint error. */\nfunction isUniqueViolation(error: unknown): boolean {\n return (\n error instanceof Error &&\n 'code' in error &&\n (error as { code: unknown }).code === 'P2002'\n )\n}\n\n/** Liking twice is one like. */\nexport async function likeTool(\n slug: string,\n user: { id: string; name: string },\n): Promise<void> {\n const toolId = await requireToolId(slug)\n try {\n await getDbClient().dbCommunityToolLike.upsert({\n where: { toolId_userId: { toolId, userId: user.id } },\n create: { toolId, userId: user.id, userName: user.name },\n update: {},\n })\n } catch (error) {\n // Two likes at once both miss, and one insert loses the race: still liked.\n if (!isUniqueViolation(error)) throw error\n }\n}\n\n/** Unliking what was never liked changes nothing. */\nexport async function unlikeTool(slug: string, userId: string): Promise<void> {\n const toolId = await requireToolId(slug)\n await getDbClient().dbCommunityToolLike.deleteMany({\n where: { toolId, userId },\n })\n}\n\nexport async function unpinTool(slug: string, userId: string): Promise<void> {\n const toolId = await requireToolId(slug)\n await getDbClient().dbCommunityToolPin.deleteMany({\n where: { userId, toolId },\n })\n}\n\nexport async function reorderPins(\n slugs: string[],\n userId: string,\n): Promise<void> {\n const db = getDbClient()\n\n // Get all pins for this user\n const pins = await db.dbCommunityToolPin.findMany({\n where: { userId },\n include: { tool: true },\n orderBy: { position: 'asc' },\n })\n\n // Create a map of slug to pin\n const slugToPin = new Map(pins.map((p) => [p.tool.slug, p]))\n\n // Build new order: named slugs first, then unlisted pins in current order\n const namedPins = slugs\n .map((slug) => slugToPin.get(slug))\n .filter((p) => p !== undefined)\n const unlistedPins = pins.filter((p) => !slugs.includes(p.tool.slug))\n const newOrder = [...namedPins, ...unlistedPins]\n\n // Update positions\n await Promise.all(\n newOrder.map((pin, index) =>\n db.dbCommunityToolPin.update({\n where: {\n userId_toolId: {\n userId,\n toolId: pin.toolId,\n },\n },\n data: { position: index },\n }),\n ),\n )\n}\n\nexport async function computeCanEdit(\n tool: { id: string; addedByUserId: string },\n user: User | null,\n isAdmin: boolean,\n): Promise<boolean> {\n if (!user) return false\n if (isAdmin) return true\n if (tool.addedByUserId === user.id) return true\n\n const db = getDbClient()\n const owners = await db.dbCommunityToolOwner.findMany({\n where: { toolId: tool.id },\n })\n\n return owners.some((o: { personSlug: string }) => o.personSlug === user.email)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"service.mjs","names":[],"sources":["../../../src/modules/communityTools/service.ts"],"sourcesContent":["import { TRPCError } from '@trpc/server'\nimport type { User } from 'better-auth/types'\nimport { getDbClient } from '../../db/client'\nimport type { Actor } from '../visitor/visitorIdentity'\nimport type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport type { CaptureDeps } from './healthJob'\nimport { COMMENTS, LIKES, toView } from './toView'\n\nexport async function listTools(\n user: User | null,\n isAdmin: boolean,\n now: Date,\n capture: CaptureDeps,\n): Promise<CommunityTool[]> {\n const db = getDbClient()\n\n const tools = await db.dbCommunityTool.findMany({\n include: {\n owners: true,\n namedOwners: true,\n likes: LIKES,\n _count: COMMENTS,\n tags: true,\n },\n })\n\n const toolIds = tools.map((t) => t.id)\n\n // Fetch all persons in one query\n const ownerSlugs = tools.flatMap((t) => t.owners.map((o) => o.personSlug))\n const uniqueOwnerSlugs = Array.from(new Set(ownerSlugs))\n const persons = await db.dbPerson.findMany({\n where: { slug: { in: uniqueOwnerSlugs } },\n })\n const personMap = new Map(persons.map((p) => [p.slug, p]))\n\n const viewCountMap = new Map(\n await Promise.all(\n toolIds.map(async (id) => [id, await openCounts(id, now)] as const),\n ),\n )\n\n return Promise.all(\n tools.map((tool) =>\n toView(\n tool,\n personMap,\n viewCountMap.get(tool.id)!,\n user,\n isAdmin,\n now,\n capture,\n ),\n ),\n )\n}\n\n/** How many live tools carry each official app's tag, by app slug. */\nexport async function countByApp(): Promise<Record<string, number>> {\n const rows = await getDbClient().dbCommunityToolTag.groupBy({\n by: ['value'],\n where: { kind: 'app', tool: { archivedAt: null } },\n _count: { _all: true },\n })\n return Object.fromEntries(rows.map((r) => [r.value, r._count._all]))\n}\n\nexport async function getTool(\n slug: string,\n user: User | null,\n isAdmin: boolean,\n now: Date,\n capture: CaptureDeps,\n): Promise<CommunityTool | null> {\n const db = getDbClient()\n\n const tool = await db.dbCommunityTool.findUnique({\n where: { slug },\n include: {\n owners: true,\n namedOwners: true,\n likes: LIKES,\n _count: COMMENTS,\n tags: true,\n },\n })\n\n if (!tool) return null\n\n const ownerSlugs = tool.owners.map((o) => o.personSlug)\n const persons = await db.dbPerson.findMany({\n where: { slug: { in: ownerSlugs } },\n })\n const personMap = new Map(persons.map((p) => [p.slug, p]))\n\n return toView(\n tool,\n personMap,\n await openCounts(tool.id, now),\n user,\n isAdmin,\n now,\n capture,\n )\n}\n\n/** Opens since the first of the last 30 UTC days, and ever. */\nasync function openCounts(\n toolId: string,\n now: Date,\n): Promise<{ views30d: number; viewsAll: number }> {\n const db = getDbClient()\n const since = new Date(now)\n since.setUTCHours(0, 0, 0, 0)\n since.setUTCDate(since.getUTCDate() - 29)\n const [views30d, viewsAll] = await Promise.all([\n db.dbCommunityToolOpen.count({ where: { toolId, at: { gte: since } } }),\n db.dbCommunityToolOpen.count({ where: { toolId } }),\n ])\n return { views30d, viewsAll }\n}\n\n/** More opens than this by one visitor of one tool in a minute are ignored. */\nconst OPENS_PER_MINUTE = 30\n\n/**\n * Counts one card open or click through to the site: every one counts, short\n * of a visitor opening the same tool more than OPENS_PER_MINUTE times a minute.\n */\nexport async function recordOpen(\n slug: string,\n kind: 'card' | 'site',\n actor: Actor,\n user: User | null,\n now: Date,\n): Promise<{ views30d: number; viewsAll: number }> {\n const db = getDbClient()\n const toolId = await requireToolId(slug)\n\n const recent = await db.dbCommunityToolOpen.count({\n where: {\n toolId,\n actorHash: actor.hash,\n at: { gt: new Date(now.getTime() - 60_000) },\n },\n })\n if (recent < OPENS_PER_MINUTE) {\n await db.dbCommunityToolOpen.create({\n data: {\n toolId,\n kind,\n actorHash: actor.hash,\n userId: user?.id ?? null,\n at: now,\n },\n })\n }\n return openCounts(toolId, now)\n}\n\nexport async function listPins(\n userId: string,\n user: User | null,\n isAdmin: boolean,\n now: Date,\n capture: CaptureDeps,\n): Promise<CommunityTool[]> {\n const db = getDbClient()\n\n const pins = await db.dbCommunityToolPin.findMany({\n where: { userId },\n orderBy: { position: 'asc' },\n include: {\n tool: {\n include: {\n owners: true,\n namedOwners: true,\n likes: LIKES,\n _count: COMMENTS,\n tags: true,\n },\n },\n },\n })\n\n const toolIds = pins.map((p) => p.tool.id)\n\n const ownerSlugs = pins.flatMap((p) => p.tool.owners.map((o) => o.personSlug))\n const uniqueOwnerSlugs = Array.from(new Set(ownerSlugs))\n const persons = await db.dbPerson.findMany({\n where: { slug: { in: uniqueOwnerSlugs } },\n })\n const personMap = new Map(persons.map((p) => [p.slug, p]))\n\n const viewCountMap = new Map(\n await Promise.all(\n toolIds.map(async (id) => [id, await openCounts(id, now)] as const),\n ),\n )\n\n return Promise.all(\n pins.map((pin) =>\n toView(\n pin.tool,\n personMap,\n viewCountMap.get(pin.tool.id)!,\n user,\n isAdmin,\n now,\n capture,\n ),\n ),\n )\n}\n\nasync function requireToolId(slug: string): Promise<string> {\n const tool = await getDbClient().dbCommunityTool.findUnique({\n where: { slug },\n select: { id: true },\n })\n if (!tool) {\n throw new TRPCError({ code: 'NOT_FOUND', message: 'Tool not found' })\n }\n return tool.id\n}\n\n/** Pinning a pinned tool leaves it where it is. */\nexport async function pinTool(slug: string, userId: string): Promise<void> {\n const db = getDbClient()\n const toolId = await requireToolId(slug)\n\n const maxPin = await db.dbCommunityToolPin.findFirst({\n where: { userId },\n orderBy: { position: 'desc' },\n })\n\n await db.dbCommunityToolPin.upsert({\n where: { userId_toolId: { userId, toolId } },\n create: { userId, toolId, position: maxPin ? maxPin.position + 1 : 0 },\n update: {},\n })\n}\n\n/** Prisma's unique-constraint error. */\nfunction isUniqueViolation(error: unknown): boolean {\n return (\n error instanceof Error &&\n 'code' in error &&\n (error as { code: unknown }).code === 'P2002'\n )\n}\n\n/** Liking twice is one like. */\nexport async function likeTool(\n slug: string,\n user: { id: string; name: string },\n): Promise<void> {\n const toolId = await requireToolId(slug)\n try {\n await getDbClient().dbCommunityToolLike.upsert({\n where: { toolId_userId: { toolId, userId: user.id } },\n create: { toolId, userId: user.id, userName: user.name },\n update: {},\n })\n } catch (error) {\n // Two likes at once both miss, and one insert loses the race: still liked.\n if (!isUniqueViolation(error)) throw error\n }\n}\n\n/** Unliking what was never liked changes nothing. */\nexport async function unlikeTool(slug: string, userId: string): Promise<void> {\n const toolId = await requireToolId(slug)\n await getDbClient().dbCommunityToolLike.deleteMany({\n where: { toolId, userId },\n })\n}\n\nexport async function unpinTool(slug: string, userId: string): Promise<void> {\n const toolId = await requireToolId(slug)\n await getDbClient().dbCommunityToolPin.deleteMany({\n where: { userId, toolId },\n })\n}\n\nexport async function reorderPins(\n slugs: string[],\n userId: string,\n): Promise<void> {\n const db = getDbClient()\n\n // Get all pins for this user\n const pins = await db.dbCommunityToolPin.findMany({\n where: { userId },\n include: { tool: true },\n orderBy: { position: 'asc' },\n })\n\n // Create a map of slug to pin\n const slugToPin = new Map(pins.map((p) => [p.tool.slug, p]))\n\n // Build new order: named slugs first, then unlisted pins in current order\n const namedPins = slugs\n .map((slug) => slugToPin.get(slug))\n .filter((p) => p !== undefined)\n const unlistedPins = pins.filter((p) => !slugs.includes(p.tool.slug))\n const newOrder = [...namedPins, ...unlistedPins]\n\n // Update positions\n await Promise.all(\n newOrder.map((pin, index) =>\n db.dbCommunityToolPin.update({\n where: {\n userId_toolId: {\n userId,\n toolId: pin.toolId,\n },\n },\n data: { position: index },\n }),\n ),\n )\n}\n\nexport async function computeCanEdit(\n tool: { id: string; addedByUserId: string },\n user: User | null,\n isAdmin: boolean,\n): Promise<boolean> {\n if (!user) return false\n if (isAdmin) return true\n if (tool.addedByUserId === user.id) return true\n\n const db = getDbClient()\n const owners = await db.dbCommunityToolOwner.findMany({\n where: { toolId: tool.id },\n })\n\n return owners.some((o: { personSlug: string }) => o.personSlug === user.email)\n}\n"],"mappings":"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"}
@@ -41,7 +41,8 @@ async function toView(tool, personMap, viewCounts, user, isAdmin, now, capture)
41
41
  failingSince: tool.failingSince,
42
42
  archivedAt: tool.archivedAt,
43
43
  archivedBy: tool.archivedBy,
44
- archivedReason: tool.archivedReason
44
+ archivedReason: tool.archivedReason,
45
+ unreachableSince: tool.unreachableSince
45
46
  }, now);
46
47
  const capturePending = !tool.screenshotAssetId && !tool.lastCheckAt && capture.capturer !== null && isCaptureAllowed(tool.url, capture.captureHosts);
47
48
  const canEdit = await computeCanEdit({
@@ -75,6 +76,7 @@ async function toView(tool, personMap, viewCounts, user, isAdmin, now, capture)
75
76
  faviconId: tool.faviconAssetId ?? null,
76
77
  capturedAt: tool.capturedAt?.toISOString() ?? null,
77
78
  capturePending,
79
+ serverUnreachable: tool.unreachableSince !== null,
78
80
  canEdit
79
81
  };
80
82
  }
@@ -1 +1 @@
1
- {"version":3,"file":"toView.mjs","names":[],"sources":["../../../src/modules/communityTools/toView.ts"],"sourcesContent":["import type { User } from 'better-auth/types'\nimport type {\n CommunityTag,\n CommunityTool,\n PersonRef,\n} from '@igstack/app-catalog-shared-core'\nimport { isCaptureAllowed, lifecycleOf } from '@igstack/app-catalog-shared-core'\nimport type { DbPerson, Prisma } from '../../generated/prisma/client'\nimport type { CaptureDeps } from './healthJob'\nimport { computeCanEdit } from './service'\n\n/** The likes a view reads: newest first, for \"Liked by\". */\nexport const LIKES = {\n orderBy: { createdAt: 'desc' },\n select: { userId: true, userName: true },\n} as const satisfies Prisma.DbCommunityTool$likesArgs\n\n/** Visitors' comments, counted in the same query: a review line the system writes has a `kind`. */\nexport const COMMENTS = {\n select: { feedback: { where: { kind: null } } },\n} as const satisfies Prisma.DbCommunityToolCountOutputTypeDefaultArgs\n\n/** How many likers a view names; the rest are a count. */\nconst LIKED_BY_NAMES = 8\n\ntype DbToolWithRelations = Prisma.DbCommunityToolGetPayload<{\n include: {\n owners: true\n namedOwners: true\n tags: true\n likes: typeof LIKES\n _count: typeof COMMENTS\n }\n}>\n\nexport async function toView(\n tool: DbToolWithRelations,\n personMap: Map<string, DbPerson>,\n viewCounts: { views30d: number; viewsAll: number },\n user: User | null,\n isAdmin: boolean,\n now: Date,\n capture: CaptureDeps,\n): Promise<CommunityTool> {\n const owners: PersonRef[] = tool.owners.map((o) => {\n const person = personMap.get(o.personSlug)\n if (person) {\n const name = `${person.firstName} ${person.lastName}`.trim()\n return { slug: o.personSlug, name: name || o.personSlug }\n }\n return { slug: o.personSlug, name: o.personSlug }\n })\n // Outside the directory: a name to ask for, never an editor.\n for (const o of tool.namedOwners) owners.push({ slug: null, name: o.name })\n\n const tags: CommunityTag[] = tool.tags.map((t) => ({\n kind: t.kind,\n value: t.value,\n }))\n\n const lifecycle = lifecycleOf(\n {\n failingSince: tool.failingSince,\n archivedAt: tool.archivedAt,\n archivedBy: tool.archivedBy,\n archivedReason: tool.archivedReason,\n },\n now,\n )\n\n // Never visited yet, and a visit will come: the same conditions `checkTool`\n // runs under. A finished visit sets `lastCheckAt`, failed or not.\n const capturePending =\n !tool.screenshotAssetId &&\n !tool.lastCheckAt &&\n capture.capturer !== null &&\n isCaptureAllowed(tool.url, capture.captureHosts)\n\n const canEdit = await computeCanEdit(\n { id: tool.id, addedByUserId: tool.addedByUserId },\n user,\n isAdmin,\n )\n\n return {\n slug: tool.slug,\n name: tool.name,\n url: tool.url,\n host: tool.host,\n summary: tool.summary,\n descriptionMd: tool.descriptionMd,\n owners,\n addedBy: {\n userId: tool.addedByUserId,\n name: tool.addedByName,\n },\n createdAt: tool.createdAt.toISOString(),\n comments: tool._count.feedback,\n // ponytail: every like row is read per view; a counted column if likes run to thousands.\n likes: tool.likes.length,\n likedByMe: !!user && tool.likes.some((l) => l.userId === user.id),\n // The viewer's own like is `likedByMe`; the names are everyone else's.\n likedBy: tool.likes\n .filter((l) => l.userId !== user?.id)\n .slice(0, LIKED_BY_NAMES)\n .map((l) => l.userName),\n siteUpdatedAt: tool.siteUpdatedAt?.toISOString() ?? null,\n tags,\n views30d: viewCounts.views30d,\n viewsAll: viewCounts.viewsAll,\n reviewed: tool.reviewedAt !== null,\n lifecycle,\n screenshotId: tool.screenshotAssetId ?? null,\n faviconId: tool.faviconAssetId ?? null,\n capturedAt: tool.capturedAt?.toISOString() ?? null,\n capturePending,\n canEdit,\n }\n}\n"],"mappings":";;;;;AAYA,MAAa,QAAQ;CACnB,SAAS,EAAE,WAAW,QAAQ;CAC9B,QAAQ;EAAE,QAAQ;EAAM,UAAU;EAAM;CACzC;;AAGD,MAAa,WAAW,EACtB,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,MAAM,EAAE,EAAE,EAChD;;AAGD,MAAM,iBAAiB;AAYvB,eAAsB,OACpB,MACA,WACA,YACA,MACA,SACA,KACA,SACwB;CACxB,MAAM,SAAsB,KAAK,OAAO,KAAK,MAAM;EACjD,MAAM,SAAS,UAAU,IAAI,EAAE,WAAW;AAC1C,MAAI,QAAQ;GACV,MAAM,OAAO,GAAG,OAAO,UAAU,GAAG,OAAO,WAAW,MAAM;AAC5D,UAAO;IAAE,MAAM,EAAE;IAAY,MAAM,QAAQ,EAAE;IAAY;;AAE3D,SAAO;GAAE,MAAM,EAAE;GAAY,MAAM,EAAE;GAAY;GACjD;AAEF,MAAK,MAAM,KAAK,KAAK,YAAa,QAAO,KAAK;EAAE,MAAM;EAAM,MAAM,EAAE;EAAM,CAAC;CAE3E,MAAM,OAAuB,KAAK,KAAK,KAAK,OAAO;EACjD,MAAM,EAAE;EACR,OAAO,EAAE;EACV,EAAE;CAEH,MAAM,YAAY,YAChB;EACE,cAAc,KAAK;EACnB,YAAY,KAAK;EACjB,YAAY,KAAK;EACjB,gBAAgB,KAAK;EACtB,EACD,IACD;CAID,MAAM,iBACJ,CAAC,KAAK,qBACN,CAAC,KAAK,eACN,QAAQ,aAAa,QACrB,iBAAiB,KAAK,KAAK,QAAQ,aAAa;CAElD,MAAM,UAAU,MAAM,eACpB;EAAE,IAAI,KAAK;EAAI,eAAe,KAAK;EAAe,EAClD,MACA,QACD;AAED,QAAO;EACL,MAAM,KAAK;EACX,MAAM,KAAK;EACX,KAAK,KAAK;EACV,MAAM,KAAK;EACX,SAAS,KAAK;EACd,eAAe,KAAK;EACpB;EACA,SAAS;GACP,QAAQ,KAAK;GACb,MAAM,KAAK;GACZ;EACD,WAAW,KAAK,UAAU,aAAa;EACvC,UAAU,KAAK,OAAO;EAEtB,OAAO,KAAK,MAAM;EAClB,WAAW,CAAC,CAAC,QAAQ,KAAK,MAAM,MAAM,MAAM,EAAE,WAAW,KAAK,GAAG;EAEjE,SAAS,KAAK,MACX,QAAQ,MAAM,EAAE,WAAW,MAAM,GAAG,CACpC,MAAM,GAAG,eAAe,CACxB,KAAK,MAAM,EAAE,SAAS;EACzB,eAAe,KAAK,eAAe,aAAa,IAAI;EACpD;EACA,UAAU,WAAW;EACrB,UAAU,WAAW;EACrB,UAAU,KAAK,eAAe;EAC9B;EACA,cAAc,KAAK,qBAAqB;EACxC,WAAW,KAAK,kBAAkB;EAClC,YAAY,KAAK,YAAY,aAAa,IAAI;EAC9C;EACA;EACD"}
1
+ {"version":3,"file":"toView.mjs","names":[],"sources":["../../../src/modules/communityTools/toView.ts"],"sourcesContent":["import type { User } from 'better-auth/types'\nimport type {\n CommunityTag,\n CommunityTool,\n PersonRef,\n} from '@igstack/app-catalog-shared-core'\nimport { isCaptureAllowed, lifecycleOf } from '@igstack/app-catalog-shared-core'\nimport type { DbPerson, Prisma } from '../../generated/prisma/client'\nimport type { CaptureDeps } from './healthJob'\nimport { computeCanEdit } from './service'\n\n/** The likes a view reads: newest first, for \"Liked by\". */\nexport const LIKES = {\n orderBy: { createdAt: 'desc' },\n select: { userId: true, userName: true },\n} as const satisfies Prisma.DbCommunityTool$likesArgs\n\n/** Visitors' comments, counted in the same query: a review line the system writes has a `kind`. */\nexport const COMMENTS = {\n select: { feedback: { where: { kind: null } } },\n} as const satisfies Prisma.DbCommunityToolCountOutputTypeDefaultArgs\n\n/** How many likers a view names; the rest are a count. */\nconst LIKED_BY_NAMES = 8\n\ntype DbToolWithRelations = Prisma.DbCommunityToolGetPayload<{\n include: {\n owners: true\n namedOwners: true\n tags: true\n likes: typeof LIKES\n _count: typeof COMMENTS\n }\n}>\n\nexport async function toView(\n tool: DbToolWithRelations,\n personMap: Map<string, DbPerson>,\n viewCounts: { views30d: number; viewsAll: number },\n user: User | null,\n isAdmin: boolean,\n now: Date,\n capture: CaptureDeps,\n): Promise<CommunityTool> {\n const owners: PersonRef[] = tool.owners.map((o) => {\n const person = personMap.get(o.personSlug)\n if (person) {\n const name = `${person.firstName} ${person.lastName}`.trim()\n return { slug: o.personSlug, name: name || o.personSlug }\n }\n return { slug: o.personSlug, name: o.personSlug }\n })\n // Outside the directory: a name to ask for, never an editor.\n for (const o of tool.namedOwners) owners.push({ slug: null, name: o.name })\n\n const tags: CommunityTag[] = tool.tags.map((t) => ({\n kind: t.kind,\n value: t.value,\n }))\n\n const lifecycle = lifecycleOf(\n {\n failingSince: tool.failingSince,\n archivedAt: tool.archivedAt,\n archivedBy: tool.archivedBy,\n archivedReason: tool.archivedReason,\n unreachableSince: tool.unreachableSince,\n },\n now,\n )\n\n // Never visited yet, and a visit will come: the same conditions `checkTool`\n // runs under. A finished visit sets `lastCheckAt`, failed or not.\n const capturePending =\n !tool.screenshotAssetId &&\n !tool.lastCheckAt &&\n capture.capturer !== null &&\n isCaptureAllowed(tool.url, capture.captureHosts)\n\n const canEdit = await computeCanEdit(\n { id: tool.id, addedByUserId: tool.addedByUserId },\n user,\n isAdmin,\n )\n\n return {\n slug: tool.slug,\n name: tool.name,\n url: tool.url,\n host: tool.host,\n summary: tool.summary,\n descriptionMd: tool.descriptionMd,\n owners,\n addedBy: {\n userId: tool.addedByUserId,\n name: tool.addedByName,\n },\n createdAt: tool.createdAt.toISOString(),\n comments: tool._count.feedback,\n // ponytail: every like row is read per view; a counted column if likes run to thousands.\n likes: tool.likes.length,\n likedByMe: !!user && tool.likes.some((l) => l.userId === user.id),\n // The viewer's own like is `likedByMe`; the names are everyone else's.\n likedBy: tool.likes\n .filter((l) => l.userId !== user?.id)\n .slice(0, LIKED_BY_NAMES)\n .map((l) => l.userName),\n siteUpdatedAt: tool.siteUpdatedAt?.toISOString() ?? null,\n tags,\n views30d: viewCounts.views30d,\n viewsAll: viewCounts.viewsAll,\n reviewed: tool.reviewedAt !== null,\n lifecycle,\n screenshotId: tool.screenshotAssetId ?? null,\n faviconId: tool.faviconAssetId ?? null,\n capturedAt: tool.capturedAt?.toISOString() ?? null,\n capturePending,\n serverUnreachable: tool.unreachableSince !== null,\n canEdit,\n }\n}\n"],"mappings":";;;;;AAYA,MAAa,QAAQ;CACnB,SAAS,EAAE,WAAW,QAAQ;CAC9B,QAAQ;EAAE,QAAQ;EAAM,UAAU;EAAM;CACzC;;AAGD,MAAa,WAAW,EACtB,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,MAAM,EAAE,EAAE,EAChD;;AAGD,MAAM,iBAAiB;AAYvB,eAAsB,OACpB,MACA,WACA,YACA,MACA,SACA,KACA,SACwB;CACxB,MAAM,SAAsB,KAAK,OAAO,KAAK,MAAM;EACjD,MAAM,SAAS,UAAU,IAAI,EAAE,WAAW;AAC1C,MAAI,QAAQ;GACV,MAAM,OAAO,GAAG,OAAO,UAAU,GAAG,OAAO,WAAW,MAAM;AAC5D,UAAO;IAAE,MAAM,EAAE;IAAY,MAAM,QAAQ,EAAE;IAAY;;AAE3D,SAAO;GAAE,MAAM,EAAE;GAAY,MAAM,EAAE;GAAY;GACjD;AAEF,MAAK,MAAM,KAAK,KAAK,YAAa,QAAO,KAAK;EAAE,MAAM;EAAM,MAAM,EAAE;EAAM,CAAC;CAE3E,MAAM,OAAuB,KAAK,KAAK,KAAK,OAAO;EACjD,MAAM,EAAE;EACR,OAAO,EAAE;EACV,EAAE;CAEH,MAAM,YAAY,YAChB;EACE,cAAc,KAAK;EACnB,YAAY,KAAK;EACjB,YAAY,KAAK;EACjB,gBAAgB,KAAK;EACrB,kBAAkB,KAAK;EACxB,EACD,IACD;CAID,MAAM,iBACJ,CAAC,KAAK,qBACN,CAAC,KAAK,eACN,QAAQ,aAAa,QACrB,iBAAiB,KAAK,KAAK,QAAQ,aAAa;CAElD,MAAM,UAAU,MAAM,eACpB;EAAE,IAAI,KAAK;EAAI,eAAe,KAAK;EAAe,EAClD,MACA,QACD;AAED,QAAO;EACL,MAAM,KAAK;EACX,MAAM,KAAK;EACX,KAAK,KAAK;EACV,MAAM,KAAK;EACX,SAAS,KAAK;EACd,eAAe,KAAK;EACpB;EACA,SAAS;GACP,QAAQ,KAAK;GACb,MAAM,KAAK;GACZ;EACD,WAAW,KAAK,UAAU,aAAa;EACvC,UAAU,KAAK,OAAO;EAEtB,OAAO,KAAK,MAAM;EAClB,WAAW,CAAC,CAAC,QAAQ,KAAK,MAAM,MAAM,MAAM,EAAE,WAAW,KAAK,GAAG;EAEjE,SAAS,KAAK,MACX,QAAQ,MAAM,EAAE,WAAW,MAAM,GAAG,CACpC,MAAM,GAAG,eAAe,CACxB,KAAK,MAAM,EAAE,SAAS;EACzB,eAAe,KAAK,eAAe,aAAa,IAAI;EACpD;EACA,UAAU,WAAW;EACrB,UAAU,WAAW;EACrB,UAAU,KAAK,eAAe;EAC9B;EACA,cAAc,KAAK,qBAAqB;EACxC,WAAW,KAAK,kBAAkB;EAClC,YAAY,KAAK,YAAY,aAAa,IAAI;EAC9C;EACA,mBAAmB,KAAK,qBAAqB;EAC7C;EACD"}
@@ -202,6 +202,7 @@ async function editTool(input, writer, deps, now) {
202
202
  if (changed.length > 0) {
203
203
  const newSite = host !== tool.host && {
204
204
  failingSince: null,
205
+ unreachableSince: null,
205
206
  lastCheckAt: null,
206
207
  ...tool.archivedBy === "auto" && {
207
208
  archivedAt: null,
@@ -1 +1 @@
1
- {"version":3,"file":"writeService.mjs","names":[],"sources":["../../../src/modules/communityTools/writeService.ts"],"sourcesContent":["import { TRPCError } from '@trpc/server'\nimport type { User } from 'better-auth/types'\nimport { z } from 'zod'\nimport type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport {\n TAG_VALUE_MAX,\n hostOf,\n isCaptureAllowed,\n normalizeTags,\n} from '@igstack/app-catalog-shared-core'\nimport { getDbClient } from '../../db/client'\nimport { resizeImage } from '../assets/assetUtils'\nimport { MARKER_KIND } from '../feedback/service'\nimport type { Actor } from '../visitor/visitorIdentity'\nimport { checkTool } from './healthJob'\nimport type { CaptureDeps } from './healthJob'\nimport { computeCanEdit, getTool } from './service'\n\nexport const toolInputSchema = z.object({\n url: z.string().max(2000),\n name: z.string().trim().min(1).max(80),\n summary: z.string().trim().max(160),\n descriptionMd: z.string().max(5000),\n ownerSlugs: z.array(z.string()).max(5),\n /**\n * Owners outside the person directory, by name. Left out of an edit, the\n * tool keeps the ones it has; only a list replaces them.\n */\n ownerNames: z\n .array(z.string().trim().min(1).max(80))\n .max(5)\n .transform((names) => Array.from(new Set(names)))\n .optional(),\n // Topics are stored the way the next person will type them, each tag once;\n // the caps hold for what is stored.\n tags: z\n .array(z.object({ kind: z.enum(['app', 'topic']), value: z.string() }))\n .transform(normalizeTags)\n .pipe(\n z\n .array(\n z.object({\n kind: z.enum(['app', 'topic']),\n value: z.string().min(1).max(TAG_VALUE_MAX),\n }),\n )\n .max(8),\n ),\n})\nexport type ToolInput = z.output<typeof toolInputSchema>\n\nconst MAX_OWNERS = 5\n\n/** The signed-in person making the change. */\nexport interface Writer {\n user: User\n isAdmin: boolean\n actor: Actor\n}\n\nexport interface ToolPreview {\n host: string\n title: string | null\n screenshotDataUrl: string | null\n duplicate: { slug: string; name: string } | null\n /** An official app on the same host: the tool would duplicate a catalog entry. */\n officialApp: { slug: string; name: string } | null\n captureAllowed: boolean\n}\n\nconst PREVIEW_WIDTH = 640\n/** Previews are someone waiting at the form; past this many queued, ask them to retry. */\nconst MAX_WAITING_CAPTURES = 3\n\nfunction requireHost(url: string): string {\n const host = hostOf(url)\n if (!host) {\n throw new TRPCError({\n code: 'BAD_REQUEST',\n message: 'The address must start with http:// or https://',\n })\n }\n return host\n}\n\nasync function requireTool(slug: string) {\n const tool = await getDbClient().dbCommunityTool.findUnique({\n where: { slug },\n include: { owners: true, namedOwners: true, tags: true },\n })\n if (!tool) {\n throw new TRPCError({ code: 'NOT_FOUND', message: 'Tool not found' })\n }\n return tool\n}\n\nasync function requireEditor(\n tool: { id: string; addedByUserId: string },\n writer: Writer,\n) {\n if (!(await computeCanEdit(tool, writer.user, writer.isAdmin))) {\n throw new TRPCError({\n code: 'FORBIDDEN',\n message: 'Only an owner or an admin can change this tool',\n })\n }\n}\n\n/**\n * The owners as stored: directory people, who must exist, and names of people\n * outside it. One owner at least, of either kind, and five in all.\n */\nasync function requireOwners(input: ToolInput, keptNames: string[] = []) {\n const unique = Array.from(new Set(input.ownerSlugs))\n const ownerNames = input.ownerNames ?? keptNames\n const count = unique.length + ownerNames.length\n if (count < 1 || count > MAX_OWNERS) {\n throw new TRPCError({\n code: 'BAD_REQUEST',\n message: `A tool has 1 to ${MAX_OWNERS} owners`,\n })\n }\n const found = await getDbClient().dbPerson.findMany({\n where: { slug: { in: unique } },\n select: { slug: true },\n })\n if (found.length !== unique.length) {\n throw new TRPCError({\n code: 'BAD_REQUEST',\n message: 'Every owner must be in the person directory',\n })\n }\n return { ownerSlugs: unique, ownerNames }\n}\n\nfunction slugify(name: string): string {\n return (\n name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'tool'\n )\n}\n\nasync function freeSlug(name: string): Promise<string> {\n const base = slugify(name)\n const db = getDbClient()\n for (let n = 1; ; n++) {\n const slug = n === 1 ? base : `${base}-${n}`\n if (!(await db.dbCommunityTool.findUnique({ where: { slug } }))) return slug\n }\n}\n\n/** A review row: what puts the change in the maintainer's existing comment queue. */\nasync function writeMarker(\n toolId: string,\n body: string,\n writer: Writer,\n status: 'applied' | 'acknowledged' | null,\n) {\n await getDbClient().dbFeedback.create({\n data: {\n communityToolId: toolId,\n kind: MARKER_KIND,\n body,\n status,\n authorHash: writer.actor.hash,\n authorAlias: writer.actor.alias,\n authorUserId: writer.user.id,\n authorName: writer.user.name,\n },\n })\n}\n\n/** Capture in the background: publishing must not wait on a 20-second page load. */\nfunction captureLater(\n tool: Parameters<typeof checkTool>[0],\n deps: CaptureDeps,\n now: Date,\n) {\n checkTool(tool, deps, now).catch((error: unknown) => {\n console.error(`[communityTools] capture of ${tool.slug}:`, error)\n })\n}\n\n/** The top-level catalog app whose address is on this host, if any. */\nasync function findOfficialApp(\n host: string,\n): Promise<{ slug: string; name: string } | null> {\n // ponytail: scans every top-level app; a stored host column if the catalog grows past thousands.\n const apps = await getDbClient().dbResource.findMany({\n where: { parentSlug: null, appUrl: { not: null } },\n select: { slug: true, displayName: true, appUrl: true },\n })\n const app = apps.find((a) => a.appUrl && hostOf(a.appUrl) === host)\n return app ? { slug: app.slug, name: app.displayName } : null\n}\n\nexport async function previewTool(\n url: string,\n deps: CaptureDeps,\n): Promise<ToolPreview> {\n const host = requireHost(url)\n const captureAllowed = isCaptureAllowed(url, deps.captureHosts)\n if (\n captureAllowed &&\n (deps.capturer?.waiting?.() ?? 0) > MAX_WAITING_CAPTURES\n ) {\n throw new TRPCError({\n code: 'TOO_MANY_REQUESTS',\n message: 'Too many previews in progress; try again in a minute',\n })\n }\n\n const duplicate = await getDbClient().dbCommunityTool.findFirst({\n where: { host },\n select: { slug: true, name: true },\n })\n const officialApp = await findOfficialApp(host)\n\n let title: string | null = null\n let screenshotDataUrl: string | null = null\n if (captureAllowed && deps.capturer) {\n try {\n const result = await deps.capturer.capture(url, deps.captureHosts)\n if (result.ok) {\n title = result.title\n const jpeg = await resizeImage(\n result.png,\n PREVIEW_WIDTH,\n undefined,\n 'jpeg',\n )\n screenshotDataUrl = `data:image/jpeg;base64,${jpeg.toString('base64')}`\n }\n } catch (error) {\n console.error('[communityTools] preview capture failed:', error)\n }\n }\n\n return {\n host,\n title,\n screenshotDataUrl,\n duplicate,\n officialApp,\n captureAllowed,\n }\n}\n\nexport async function addTool(\n input: ToolInput,\n writer: Writer,\n deps: CaptureDeps,\n now: Date,\n): Promise<CommunityTool> {\n const host = requireHost(input.url)\n const { ownerSlugs, ownerNames } = await requireOwners(input)\n const slug = await freeSlug(input.name)\n\n const created = await getDbClient().dbCommunityTool.create({\n data: {\n slug,\n name: input.name,\n url: input.url,\n host,\n summary: input.summary,\n descriptionMd: input.descriptionMd,\n addedByUserId: writer.user.id,\n addedByName: writer.user.name,\n owners: { create: ownerSlugs.map((personSlug) => ({ personSlug })) },\n namedOwners: { create: ownerNames.map((name) => ({ name })) },\n tags: { create: input.tags },\n },\n })\n await writeMarker(created.id, `New tool added: ${input.name}`, writer, null)\n captureLater(created, deps, now)\n\n return (await getTool(created.slug, writer.user, writer.isAdmin, now, deps))!\n}\n\nexport async function editTool(\n input: ToolInput & { slug: string },\n writer: Writer,\n deps: CaptureDeps,\n now: Date,\n): Promise<CommunityTool> {\n const tool = await requireTool(input.slug)\n await requireEditor(tool, writer)\n const host = requireHost(input.url)\n const { ownerSlugs, ownerNames } = await requireOwners(\n input,\n tool.namedOwners.map((o) => o.name),\n )\n\n const sameSet = (a: string[], b: string[]) =>\n a.length === b.length && a.every((x) => b.includes(x))\n const tagKey = (t: { kind: string; value: string }) => `${t.kind}:${t.value}`\n const changed = [\n tool.name !== input.name && 'name',\n tool.url !== input.url && 'url',\n tool.summary !== input.summary && 'summary',\n tool.descriptionMd !== input.descriptionMd && 'description',\n !(\n sameSet(\n tool.owners.map((o) => o.personSlug),\n ownerSlugs,\n ) &&\n sameSet(\n tool.namedOwners.map((o) => o.name),\n ownerNames,\n )\n ) && 'owners',\n !sameSet(tool.tags.map(tagKey), input.tags.map(tagKey)) && 'tags',\n ].filter((f): f is string => !!f)\n\n if (changed.length > 0) {\n // A different site has no health history yet, and a dead old site's\n // automatic archive says nothing about the new one.\n const newSite = host !== tool.host && {\n failingSince: null,\n lastCheckAt: null,\n ...(tool.archivedBy === 'auto' && {\n archivedAt: null,\n archivedBy: null,\n archivedReason: null,\n }),\n }\n const updated = await getDbClient().dbCommunityTool.update({\n where: { id: tool.id },\n data: {\n // An edit is news to the maintainer, so the tool waits for approval again.\n reviewedAt: null,\n reviewedByName: null,\n ...newSite,\n name: input.name,\n url: input.url,\n host,\n summary: input.summary,\n descriptionMd: input.descriptionMd,\n owners: {\n deleteMany: {},\n create: ownerSlugs.map((personSlug) => ({ personSlug })),\n },\n namedOwners: {\n deleteMany: {},\n create: ownerNames.map((name) => ({ name })),\n },\n tags: { deleteMany: {}, create: input.tags },\n },\n })\n await writeMarker(\n tool.id,\n `Tool edited: ${changed.join(', ')}`,\n writer,\n null,\n )\n if (changed.includes('url')) captureLater(updated, deps, now)\n }\n\n // The slug stays: pins, links and bookmarks point at it.\n return (await getTool(tool.slug, writer.user, writer.isAdmin, now, deps))!\n}\n\nexport async function archiveTool(\n slug: string,\n reason: string,\n writer: Writer,\n now: Date,\n): Promise<void> {\n const tool = await requireTool(slug)\n await requireEditor(tool, writer)\n\n await getDbClient().dbCommunityTool.update({\n where: { id: tool.id },\n data: {\n archivedAt: now,\n archivedBy: writer.user.name,\n archivedReason: reason,\n },\n })\n // An owner's archive is news to the maintainer; an admin's is their own decision.\n await writeMarker(\n tool.id,\n `Tool archived: ${reason}`,\n writer,\n writer.isAdmin ? 'acknowledged' : null,\n )\n}\n\nexport async function approveTool(\n slug: string,\n writer: Writer,\n now: Date,\n): Promise<void> {\n const tool = await requireTool(slug)\n const db = getDbClient()\n\n await db.dbCommunityTool.update({\n where: { id: tool.id },\n data: { reviewedAt: now, reviewedByName: writer.user.name },\n })\n await db.dbFeedback.updateMany({\n where: { communityToolId: tool.id, status: null },\n data: { status: 'applied', reviewedAt: now },\n })\n await writeMarker(tool.id, 'Tool approved', writer, 'applied')\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"writeService.mjs","names":[],"sources":["../../../src/modules/communityTools/writeService.ts"],"sourcesContent":["import { TRPCError } from '@trpc/server'\nimport type { User } from 'better-auth/types'\nimport { z } from 'zod'\nimport type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport {\n TAG_VALUE_MAX,\n hostOf,\n isCaptureAllowed,\n normalizeTags,\n} from '@igstack/app-catalog-shared-core'\nimport { getDbClient } from '../../db/client'\nimport { resizeImage } from '../assets/assetUtils'\nimport { MARKER_KIND } from '../feedback/service'\nimport type { Actor } from '../visitor/visitorIdentity'\nimport { checkTool } from './healthJob'\nimport type { CaptureDeps } from './healthJob'\nimport { computeCanEdit, getTool } from './service'\n\nexport const toolInputSchema = z.object({\n url: z.string().max(2000),\n name: z.string().trim().min(1).max(80),\n summary: z.string().trim().max(160),\n descriptionMd: z.string().max(5000),\n ownerSlugs: z.array(z.string()).max(5),\n /**\n * Owners outside the person directory, by name. Left out of an edit, the\n * tool keeps the ones it has; only a list replaces them.\n */\n ownerNames: z\n .array(z.string().trim().min(1).max(80))\n .max(5)\n .transform((names) => Array.from(new Set(names)))\n .optional(),\n // Topics are stored the way the next person will type them, each tag once;\n // the caps hold for what is stored.\n tags: z\n .array(z.object({ kind: z.enum(['app', 'topic']), value: z.string() }))\n .transform(normalizeTags)\n .pipe(\n z\n .array(\n z.object({\n kind: z.enum(['app', 'topic']),\n value: z.string().min(1).max(TAG_VALUE_MAX),\n }),\n )\n .max(8),\n ),\n})\nexport type ToolInput = z.output<typeof toolInputSchema>\n\nconst MAX_OWNERS = 5\n\n/** The signed-in person making the change. */\nexport interface Writer {\n user: User\n isAdmin: boolean\n actor: Actor\n}\n\nexport interface ToolPreview {\n host: string\n title: string | null\n screenshotDataUrl: string | null\n duplicate: { slug: string; name: string } | null\n /** An official app on the same host: the tool would duplicate a catalog entry. */\n officialApp: { slug: string; name: string } | null\n captureAllowed: boolean\n}\n\nconst PREVIEW_WIDTH = 640\n/** Previews are someone waiting at the form; past this many queued, ask them to retry. */\nconst MAX_WAITING_CAPTURES = 3\n\nfunction requireHost(url: string): string {\n const host = hostOf(url)\n if (!host) {\n throw new TRPCError({\n code: 'BAD_REQUEST',\n message: 'The address must start with http:// or https://',\n })\n }\n return host\n}\n\nasync function requireTool(slug: string) {\n const tool = await getDbClient().dbCommunityTool.findUnique({\n where: { slug },\n include: { owners: true, namedOwners: true, tags: true },\n })\n if (!tool) {\n throw new TRPCError({ code: 'NOT_FOUND', message: 'Tool not found' })\n }\n return tool\n}\n\nasync function requireEditor(\n tool: { id: string; addedByUserId: string },\n writer: Writer,\n) {\n if (!(await computeCanEdit(tool, writer.user, writer.isAdmin))) {\n throw new TRPCError({\n code: 'FORBIDDEN',\n message: 'Only an owner or an admin can change this tool',\n })\n }\n}\n\n/**\n * The owners as stored: directory people, who must exist, and names of people\n * outside it. One owner at least, of either kind, and five in all.\n */\nasync function requireOwners(input: ToolInput, keptNames: string[] = []) {\n const unique = Array.from(new Set(input.ownerSlugs))\n const ownerNames = input.ownerNames ?? keptNames\n const count = unique.length + ownerNames.length\n if (count < 1 || count > MAX_OWNERS) {\n throw new TRPCError({\n code: 'BAD_REQUEST',\n message: `A tool has 1 to ${MAX_OWNERS} owners`,\n })\n }\n const found = await getDbClient().dbPerson.findMany({\n where: { slug: { in: unique } },\n select: { slug: true },\n })\n if (found.length !== unique.length) {\n throw new TRPCError({\n code: 'BAD_REQUEST',\n message: 'Every owner must be in the person directory',\n })\n }\n return { ownerSlugs: unique, ownerNames }\n}\n\nfunction slugify(name: string): string {\n return (\n name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'tool'\n )\n}\n\nasync function freeSlug(name: string): Promise<string> {\n const base = slugify(name)\n const db = getDbClient()\n for (let n = 1; ; n++) {\n const slug = n === 1 ? base : `${base}-${n}`\n if (!(await db.dbCommunityTool.findUnique({ where: { slug } }))) return slug\n }\n}\n\n/** A review row: what puts the change in the maintainer's existing comment queue. */\nasync function writeMarker(\n toolId: string,\n body: string,\n writer: Writer,\n status: 'applied' | 'acknowledged' | null,\n) {\n await getDbClient().dbFeedback.create({\n data: {\n communityToolId: toolId,\n kind: MARKER_KIND,\n body,\n status,\n authorHash: writer.actor.hash,\n authorAlias: writer.actor.alias,\n authorUserId: writer.user.id,\n authorName: writer.user.name,\n },\n })\n}\n\n/** Capture in the background: publishing must not wait on a 20-second page load. */\nfunction captureLater(\n tool: Parameters<typeof checkTool>[0],\n deps: CaptureDeps,\n now: Date,\n) {\n checkTool(tool, deps, now).catch((error: unknown) => {\n console.error(`[communityTools] capture of ${tool.slug}:`, error)\n })\n}\n\n/** The top-level catalog app whose address is on this host, if any. */\nasync function findOfficialApp(\n host: string,\n): Promise<{ slug: string; name: string } | null> {\n // ponytail: scans every top-level app; a stored host column if the catalog grows past thousands.\n const apps = await getDbClient().dbResource.findMany({\n where: { parentSlug: null, appUrl: { not: null } },\n select: { slug: true, displayName: true, appUrl: true },\n })\n const app = apps.find((a) => a.appUrl && hostOf(a.appUrl) === host)\n return app ? { slug: app.slug, name: app.displayName } : null\n}\n\nexport async function previewTool(\n url: string,\n deps: CaptureDeps,\n): Promise<ToolPreview> {\n const host = requireHost(url)\n const captureAllowed = isCaptureAllowed(url, deps.captureHosts)\n if (\n captureAllowed &&\n (deps.capturer?.waiting?.() ?? 0) > MAX_WAITING_CAPTURES\n ) {\n throw new TRPCError({\n code: 'TOO_MANY_REQUESTS',\n message: 'Too many previews in progress; try again in a minute',\n })\n }\n\n const duplicate = await getDbClient().dbCommunityTool.findFirst({\n where: { host },\n select: { slug: true, name: true },\n })\n const officialApp = await findOfficialApp(host)\n\n let title: string | null = null\n let screenshotDataUrl: string | null = null\n if (captureAllowed && deps.capturer) {\n try {\n const result = await deps.capturer.capture(url, deps.captureHosts)\n if (result.ok) {\n title = result.title\n const jpeg = await resizeImage(\n result.png,\n PREVIEW_WIDTH,\n undefined,\n 'jpeg',\n )\n screenshotDataUrl = `data:image/jpeg;base64,${jpeg.toString('base64')}`\n }\n } catch (error) {\n console.error('[communityTools] preview capture failed:', error)\n }\n }\n\n return {\n host,\n title,\n screenshotDataUrl,\n duplicate,\n officialApp,\n captureAllowed,\n }\n}\n\nexport async function addTool(\n input: ToolInput,\n writer: Writer,\n deps: CaptureDeps,\n now: Date,\n): Promise<CommunityTool> {\n const host = requireHost(input.url)\n const { ownerSlugs, ownerNames } = await requireOwners(input)\n const slug = await freeSlug(input.name)\n\n const created = await getDbClient().dbCommunityTool.create({\n data: {\n slug,\n name: input.name,\n url: input.url,\n host,\n summary: input.summary,\n descriptionMd: input.descriptionMd,\n addedByUserId: writer.user.id,\n addedByName: writer.user.name,\n owners: { create: ownerSlugs.map((personSlug) => ({ personSlug })) },\n namedOwners: { create: ownerNames.map((name) => ({ name })) },\n tags: { create: input.tags },\n },\n })\n await writeMarker(created.id, `New tool added: ${input.name}`, writer, null)\n captureLater(created, deps, now)\n\n return (await getTool(created.slug, writer.user, writer.isAdmin, now, deps))!\n}\n\nexport async function editTool(\n input: ToolInput & { slug: string },\n writer: Writer,\n deps: CaptureDeps,\n now: Date,\n): Promise<CommunityTool> {\n const tool = await requireTool(input.slug)\n await requireEditor(tool, writer)\n const host = requireHost(input.url)\n const { ownerSlugs, ownerNames } = await requireOwners(\n input,\n tool.namedOwners.map((o) => o.name),\n )\n\n const sameSet = (a: string[], b: string[]) =>\n a.length === b.length && a.every((x) => b.includes(x))\n const tagKey = (t: { kind: string; value: string }) => `${t.kind}:${t.value}`\n const changed = [\n tool.name !== input.name && 'name',\n tool.url !== input.url && 'url',\n tool.summary !== input.summary && 'summary',\n tool.descriptionMd !== input.descriptionMd && 'description',\n !(\n sameSet(\n tool.owners.map((o) => o.personSlug),\n ownerSlugs,\n ) &&\n sameSet(\n tool.namedOwners.map((o) => o.name),\n ownerNames,\n )\n ) && 'owners',\n !sameSet(tool.tags.map(tagKey), input.tags.map(tagKey)) && 'tags',\n ].filter((f): f is string => !!f)\n\n if (changed.length > 0) {\n // A different site has no health history yet, and a dead old site's\n // automatic archive says nothing about the new one.\n const newSite = host !== tool.host && {\n failingSince: null,\n unreachableSince: null,\n lastCheckAt: null,\n ...(tool.archivedBy === 'auto' && {\n archivedAt: null,\n archivedBy: null,\n archivedReason: null,\n }),\n }\n const updated = await getDbClient().dbCommunityTool.update({\n where: { id: tool.id },\n data: {\n // An edit is news to the maintainer, so the tool waits for approval again.\n reviewedAt: null,\n reviewedByName: null,\n ...newSite,\n name: input.name,\n url: input.url,\n host,\n summary: input.summary,\n descriptionMd: input.descriptionMd,\n owners: {\n deleteMany: {},\n create: ownerSlugs.map((personSlug) => ({ personSlug })),\n },\n namedOwners: {\n deleteMany: {},\n create: ownerNames.map((name) => ({ name })),\n },\n tags: { deleteMany: {}, create: input.tags },\n },\n })\n await writeMarker(\n tool.id,\n `Tool edited: ${changed.join(', ')}`,\n writer,\n null,\n )\n if (changed.includes('url')) captureLater(updated, deps, now)\n }\n\n // The slug stays: pins, links and bookmarks point at it.\n return (await getTool(tool.slug, writer.user, writer.isAdmin, now, deps))!\n}\n\nexport async function archiveTool(\n slug: string,\n reason: string,\n writer: Writer,\n now: Date,\n): Promise<void> {\n const tool = await requireTool(slug)\n await requireEditor(tool, writer)\n\n await getDbClient().dbCommunityTool.update({\n where: { id: tool.id },\n data: {\n archivedAt: now,\n archivedBy: writer.user.name,\n archivedReason: reason,\n },\n })\n // An owner's archive is news to the maintainer; an admin's is their own decision.\n await writeMarker(\n tool.id,\n `Tool archived: ${reason}`,\n writer,\n writer.isAdmin ? 'acknowledged' : null,\n )\n}\n\nexport async function approveTool(\n slug: string,\n writer: Writer,\n now: Date,\n): Promise<void> {\n const tool = await requireTool(slug)\n const db = getDbClient()\n\n await db.dbCommunityTool.update({\n where: { id: tool.id },\n data: { reviewedAt: now, reviewedByName: writer.user.name },\n })\n await db.dbFeedback.updateMany({\n where: { communityToolId: tool.id, status: null },\n data: { status: 'applied', reviewedAt: now },\n })\n await writeMarker(tool.id, 'Tool approved', writer, 'applied')\n}\n"],"mappings":"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"}
@@ -147,6 +147,15 @@ declare function createTrpcRouter(auth?: BetterAuth, options?: {
147
147
  };
148
148
  meta: object;
149
149
  }>;
150
+ recordSiteOpen: _trpc_server0.TRPCMutationProcedure<{
151
+ input: {
152
+ slug: string;
153
+ };
154
+ output: {
155
+ ok: boolean;
156
+ };
157
+ meta: object;
158
+ }>;
150
159
  pins: _trpc_server0.TRPCQueryProcedure<{
151
160
  input: void;
152
161
  output: _igstack_app_catalog_shared_core0.CommunityTool[];
@@ -1 +1 @@
1
- {"version":3,"file":"controller.d.mts","names":[],"sources":["../../src/server/controller.ts"],"mappings":";;;;;;;;;;;;;;;iBAqCgB,gBAAA,CACd,IAAA,GAAO,UAAA,EACP,OAAA;EACE,eAAA;EACA,cAAA,GAAiB,qBAAA;AAAA,kBAClB,eAAA;OADuC,aAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA0C9B,UAAA,GAAa,UAAA,QAAkB,gBAAA"}
1
+ {"version":3,"file":"controller.d.mts","names":[],"sources":["../../src/server/controller.ts"],"mappings":";;;;;;;;;;;;;;;iBAqCgB,gBAAA,CACd,IAAA,GAAO,UAAA,EACP,OAAA;EACE,eAAA;EACA,cAAA,GAAiB,qBAAA;AAAA,kBAClB,eAAA;OADuC,aAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA0C9B,UAAA,GAAa,UAAA,QAAkB,gBAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-backend-core",
3
- "version": "6.12.0",
3
+ "version": "6.12.2",
4
4
  "description": "Backend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -44,8 +44,8 @@
44
44
  "tsyringe": "^4.10.0",
45
45
  "yaml": "^2.8.0",
46
46
  "zod": "^4.3.5",
47
- "@igstack/app-catalog-table-sync": "6.12.0",
48
- "@igstack/app-catalog-shared-core": "6.12.0"
47
+ "@igstack/app-catalog-shared-core": "6.12.2",
48
+ "@igstack/app-catalog-table-sync": "6.12.2"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@tanstack/vite-config": "^0.4.3",
@@ -79,7 +79,7 @@
79
79
  "engines": {
80
80
  "node": ">=24"
81
81
  },
82
- "gitHead": "94ade32ef879b41d8c62f2121a41e4b35f46b2ee",
82
+ "gitHead": "c6c960cbe00392b9c55fd2691c22c07c42aeb882",
83
83
  "scripts": {
84
84
  "build": "tsdown",
85
85
  "build:lenient": "tsdown",
@@ -399,9 +399,12 @@ model DbCommunityTool {
399
399
  /// Categorization tags (app associations and topic labels).
400
400
  tags DbCommunityToolTag[]
401
401
 
402
- /// View tracking for ranking.
402
+ /// Superseded by `opens`; kept so the schema stays additive.
403
403
  views DbCommunityToolView[]
404
404
 
405
+ /// Every card open and click through to the site: what the view counts count.
406
+ opens DbCommunityToolOpen[]
407
+
405
408
  /// User pins for personalized ordering.
406
409
  pins DbCommunityToolPin[]
407
410
 
@@ -421,6 +424,10 @@ model DbCommunityTool {
421
424
  /// When the tool first started failing checks (null when healthy).
422
425
  failingSince DateTime?
423
426
 
427
+ /// Since when the server's checks get no answer at all (timeout, refused):
428
+ /// says nothing about the site, so it is not a failure. Null once one answers.
429
+ unreachableSince DateTime?
430
+
424
431
  /// When archived (null when active).
425
432
  archivedAt DateTime?
426
433
  archivedReason String?
@@ -483,7 +490,23 @@ model DbCommunityToolTag {
483
490
  @@index([kind, value])
484
491
  }
485
492
 
486
- /// View tracking for community tools (one row per viewer per tool per day).
493
+ /// One card open or click through to the site.
494
+ model DbCommunityToolOpen {
495
+ id String @id @default(cuid())
496
+ toolId String
497
+ tool DbCommunityTool @relation(fields: [toolId], references: [id], onDelete: Cascade)
498
+
499
+ /// 'card' or 'site'.
500
+ kind String
501
+ /// Hash of the actor id, for the rate limit.
502
+ actorHash String?
503
+ userId String?
504
+ at DateTime @default(now())
505
+
506
+ @@index([toolId, at])
507
+ }
508
+
509
+ /// Superseded by DbCommunityToolOpen and no longer written; kept so the schema stays additive.
487
510
  model DbCommunityToolView {
488
511
  toolId String
489
512
  tool DbCommunityTool @relation(fields: [toolId], references: [id], onDelete: Cascade)