@igstack/app-catalog-backend-core 6.11.0 → 6.12.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.
@@ -1,5 +1,5 @@
1
1
  import { getDbClient } from "../../db/client.mjs";
2
- import { LIKES, toView } from "./toView.mjs";
2
+ import { COMMENTS, LIKES, toView } from "./toView.mjs";
3
3
  import { TRPCError } from "@trpc/server";
4
4
 
5
5
  //#region src/modules/communityTools/service.ts
@@ -9,6 +9,7 @@ async function listTools(user, isAdmin, now, capture) {
9
9
  owners: true,
10
10
  namedOwners: true,
11
11
  likes: LIKES,
12
+ _count: COMMENTS,
12
13
  tags: true
13
14
  } });
14
15
  const toolIds = tools.map((t) => t.id);
@@ -57,6 +58,7 @@ async function getTool(slug, user, isAdmin, now, capture) {
57
58
  owners: true,
58
59
  namedOwners: true,
59
60
  likes: LIKES,
61
+ _count: COMMENTS,
60
62
  tags: true
61
63
  }
62
64
  });
@@ -121,6 +123,7 @@ async function listPins(userId, user, isAdmin, now, capture) {
121
123
  owners: true,
122
124
  namedOwners: true,
123
125
  likes: LIKES,
126
+ _count: COMMENTS,
124
127
  tags: true
125
128
  } } }
126
129
  });
@@ -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 { 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 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 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 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 // 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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"}
@@ -10,6 +10,8 @@ const LIKES = {
10
10
  userName: true
11
11
  }
12
12
  };
13
+ /** Visitors' comments, counted in the same query: a review line the system writes has a `kind`. */
14
+ const COMMENTS = { select: { feedback: { where: { kind: null } } } };
13
15
  /** How many likers a view names; the rest are a count. */
14
16
  const LIKED_BY_NAMES = 8;
15
17
  async function toView(tool, personMap, viewCounts, user, isAdmin, now, capture) {
@@ -59,6 +61,7 @@ async function toView(tool, personMap, viewCounts, user, isAdmin, now, capture)
59
61
  name: tool.addedByName
60
62
  },
61
63
  createdAt: tool.createdAt.toISOString(),
64
+ comments: tool._count.feedback,
62
65
  likes: tool.likes.length,
63
66
  likedByMe: !!user && tool.likes.some((l) => l.userId === user.id),
64
67
  likedBy: tool.likes.filter((l) => l.userId !== user?.id).slice(0, LIKED_BY_NAMES).map((l) => l.userName),
@@ -77,5 +80,5 @@ async function toView(tool, personMap, viewCounts, user, isAdmin, now, capture)
77
80
  }
78
81
 
79
82
  //#endregion
80
- export { LIKES, toView };
83
+ export { COMMENTS, LIKES, toView };
81
84
  //# sourceMappingURL=toView.mjs.map
@@ -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/** 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 }\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 // 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,MAAM,iBAAiB;AAWvB,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;EAEvC,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 },\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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-backend-core",
3
- "version": "6.11.0",
3
+ "version": "6.12.0",
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-shared-core": "6.11.0",
48
- "@igstack/app-catalog-table-sync": "6.11.0"
47
+ "@igstack/app-catalog-table-sync": "6.12.0",
48
+ "@igstack/app-catalog-shared-core": "6.12.0"
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": "f4c72c2178cd9a3ec6695cfbb243ff3d0737ad1b",
82
+ "gitHead": "94ade32ef879b41d8c62f2121a41e4b35f46b2ee",
83
83
  "scripts": {
84
84
  "build": "tsdown",
85
85
  "build:lenient": "tsdown",
@@ -177,6 +177,7 @@ describe('Community tools service', () => {
177
177
  owners: [],
178
178
  namedOwners: [],
179
179
  likes: [] as { userId: string; userName: string }[],
180
+ _count: { feedback: 0 },
180
181
  tags: [],
181
182
  },
182
183
  },
@@ -206,6 +207,7 @@ describe('Community tools service', () => {
206
207
  owners: [],
207
208
  namedOwners: [],
208
209
  likes: [] as { userId: string; userName: string }[],
210
+ _count: { feedback: 0 },
209
211
  tags: [],
210
212
  },
211
213
  },
@@ -235,6 +237,7 @@ describe('Community tools service', () => {
235
237
  owners: [],
236
238
  namedOwners: [],
237
239
  likes: [] as { userId: string; userName: string }[],
240
+ _count: { feedback: 0 },
238
241
  tags: [],
239
242
  },
240
243
  },
@@ -411,6 +414,7 @@ describe('Community tools service', () => {
411
414
  owners: [],
412
415
  namedOwners: [],
413
416
  likes: [] as { userId: string; userName: string }[],
417
+ _count: { feedback: 0 },
414
418
  tags: [],
415
419
  },
416
420
  {
@@ -436,6 +440,7 @@ describe('Community tools service', () => {
436
440
  owners: [],
437
441
  namedOwners: [],
438
442
  likes: [] as { userId: string; userName: string }[],
443
+ _count: { feedback: 0 },
439
444
  tags: [],
440
445
  },
441
446
  ])
@@ -488,6 +493,7 @@ describe('Community tools service', () => {
488
493
  owners: [],
489
494
  namedOwners: [],
490
495
  likes: [] as { userId: string; userName: string }[],
496
+ _count: { feedback: 0 },
491
497
  tags: [],
492
498
  ...over,
493
499
  }) as Parameters<typeof toView>[0]
@@ -542,6 +548,19 @@ describe('Community tools service', () => {
542
548
  expect(anonymous.likedByMe).toBe(false)
543
549
  })
544
550
 
551
+ it('counts the comments the query counted', async () => {
552
+ const view = await toView(
553
+ row({ _count: { feedback: 3 } }),
554
+ new Map(),
555
+ { views30d: 0, viewsAll: 0 },
556
+ null,
557
+ false,
558
+ new Date('2024-01-15T12:00:00Z'),
559
+ CAPTURES,
560
+ )
561
+ expect(view.comments).toBe(3)
562
+ })
563
+
545
564
  it('lists an owner outside the directory by name, with no slug', async () => {
546
565
  const view = await toView(
547
566
  row({
@@ -57,6 +57,7 @@ const dbTool = {
57
57
  owners: [{ toolId: 'tool-1', personSlug: 'ann@example.com' }],
58
58
  namedOwners: [] as { toolId: string; name: string }[],
59
59
  likes: [] as { userId: string; userName: string }[],
60
+ _count: { feedback: 0 },
60
61
  tags: [],
61
62
  }
62
63
 
@@ -4,7 +4,7 @@ import { getDbClient } from '../../db/client'
4
4
  import type { Actor } from '../visitor/visitorIdentity'
5
5
  import type { CommunityTool } from '@igstack/app-catalog-shared-core'
6
6
  import type { CaptureDeps } from './healthJob'
7
- import { LIKES, toView } from './toView'
7
+ import { COMMENTS, LIKES, toView } from './toView'
8
8
 
9
9
  export async function listTools(
10
10
  user: User | null,
@@ -19,6 +19,7 @@ export async function listTools(
19
19
  owners: true,
20
20
  namedOwners: true,
21
21
  likes: LIKES,
22
+ _count: COMMENTS,
22
23
  tags: true,
23
24
  },
24
25
  })
@@ -97,6 +98,7 @@ export async function getTool(
97
98
  owners: true,
98
99
  namedOwners: true,
99
100
  likes: LIKES,
101
+ _count: COMMENTS,
100
102
  tags: true,
101
103
  },
102
104
  })
@@ -200,6 +202,7 @@ export async function listPins(
200
202
  owners: true,
201
203
  namedOwners: true,
202
204
  likes: LIKES,
205
+ _count: COMMENTS,
203
206
  tags: true,
204
207
  },
205
208
  },
@@ -15,6 +15,11 @@ export const LIKES = {
15
15
  select: { userId: true, userName: true },
16
16
  } as const satisfies Prisma.DbCommunityTool$likesArgs
17
17
 
18
+ /** Visitors' comments, counted in the same query: a review line the system writes has a `kind`. */
19
+ export const COMMENTS = {
20
+ select: { feedback: { where: { kind: null } } },
21
+ } as const satisfies Prisma.DbCommunityToolCountOutputTypeDefaultArgs
22
+
18
23
  /** How many likers a view names; the rest are a count. */
19
24
  const LIKED_BY_NAMES = 8
20
25
 
@@ -24,6 +29,7 @@ type DbToolWithRelations = Prisma.DbCommunityToolGetPayload<{
24
29
  namedOwners: true
25
30
  tags: true
26
31
  likes: typeof LIKES
32
+ _count: typeof COMMENTS
27
33
  }
28
34
  }>
29
35
 
@@ -89,6 +95,7 @@ export async function toView(
89
95
  name: tool.addedByName,
90
96
  },
91
97
  createdAt: tool.createdAt.toISOString(),
98
+ comments: tool._count.feedback,
92
99
  // ponytail: every like row is read per view; a counted column if likes run to thousands.
93
100
  likes: tool.likes.length,
94
101
  likedByMe: !!user && tool.likes.some((l) => l.userId === user.id),