import { z } from "zod"; import { eq, sql as dsql } from "drizzle-orm"; import { db, schema, sql } from "@/db"; import { toolRecordSchema, type ToolRecord } from "@/lib/content/schema"; import { toToolRow, contentHash } from "@/lib/content/import"; import { normalizeUrl } from "@/lib/url"; import { audit } from "@/lib/audit"; import { enqueue } from "@/lib/jobs/queue"; import { humanize } from "@/lib/text"; import type { SessionUser } from "@/lib/auth/session"; export type AdminListFilters = { q?: string; status?: string; visibility?: string; linkState?: string; depth?: string; category?: string; page?: number; sort?: "updated" | "name" | "quality" | "checked" }; export async function listTools(f: AdminListFilters) { const page = Math.max(1, f.page ?? 1); const per = 50; const q = (f.q ?? "").trim(); const where = [dsql`true`]; if (q) where.push(dsql`(t.name ILIKE ${"%" + q + "%"} OR t.slug ILIKE ${"%" + q + "%"} OR t.homepage_url ILIKE ${"%" + q + "%"})`); if (f.status) where.push(dsql`t.editorial_status = ${f.status}`); if (f.visibility) where.push(dsql`t.visibility = ${f.visibility}`); if (f.linkState) where.push(dsql`t.link_state = ${f.linkState}`); if (f.depth) where.push(dsql`t.depth = ${f.depth}`); if (f.category) where.push(dsql`t.id IN (SELECT tool_id FROM tool_categories tc JOIN categories c ON c.id=tc.category_id WHERE c.slug=${f.category})`); const order = f.sort === "name" ? dsql`t.name` : f.sort === "quality" ? dsql`t.quality_score ASC, t.name` : f.sort === "checked" ? dsql`t.checked_at ASC NULLS FIRST` : dsql`t.updated_at DESC`; const rows = await db.execute(dsql`SELECT t.id, t.slug, t.name, t.editorial_status AS "editorialStatus", t.visibility, t.depth, t.quality_score AS "qualityScore", t.link_state AS "linkState", t.checked_at AS "checkedAt", t.updated_at AS "updatedAt", t.product_status AS "productStatus", t.confidence, t.author, t.featured, (SELECT c.name FROM tool_categories tc JOIN categories c ON c.id=tc.category_id WHERE tc.tool_id=t.id AND tc.is_primary LIMIT 1) AS category, (SELECT use_state FROM owner_notes o WHERE o.tool_id=t.id) AS "useState", count(*) OVER()::int AS total FROM tools t WHERE ${dsql.join(where, dsql` AND `)} ORDER BY ${order} LIMIT ${per} OFFSET ${(page - 1) * per}`); const total = rows[0]?.total ?? 0; return { items: rows.map(({ total: _t, ...r }: any) => r), total, page, pages: Math.max(1, Math.ceil(total / per)) }; } /** Full editable record (content-schema shape) reconstructed from relational rows. */ export async function getToolRecord(id: string): Promise<(ToolRecord & { id: string; editorial_status: string; quality_score: number; link_state: string; checked_at: Date | null; published_at: Date | null; updated_at: Date; duplicate_of: string | null }) | null> { const [t] = await sql`SELECT t.*, v.name AS vendor_name, v.slug AS vendor_slug, v.website_url AS vendor_website FROM tools t LEFT JOIN vendors v ON v.id=t.vendor_id WHERE t.id::text = ${id} OR t.slug = ${id}`; if (!t) return null; const [cats, tags, alts, srcs] = await Promise.all([ sql`SELECT c.slug, tc.is_primary, tc.label FROM tool_categories tc JOIN categories c ON c.id=tc.category_id WHERE tc.tool_id=${t.id} ORDER BY tc.is_primary DESC, c.name`, sql`SELECT g.slug, g.kind FROM tool_tags tt JOIN tags g ON g.id=tt.tag_id WHERE tt.tool_id=${t.id}`, sql`SELECT a.slug FROM tool_alternatives ta JOIN tools a ON a.id=ta.alternative_id WHERE ta.tool_id=${t.id} ORDER BY a.name`, sql`SELECT so.url, so.title, so.source_type, ts.supports, ts.checked_at, ts.note FROM tool_sources ts JOIN sources so ON so.id=ts.source_id WHERE ts.tool_id=${t.id}`, ]); const u = (x: any) => x ?? undefined; const d = (x: any) => (x == null ? undefined : x instanceof Date ? x.toISOString().slice(0, 10) : String(x).slice(0, 10)); return { id: t.id, slug: t.slug, name: t.name, aliases: t.aliases, vendor: { name: t.vendor_name ?? "Unknown", slug: u(t.vendor_slug), website: u(t.vendor_website) }, homepage_url: t.homepage_url, docs_url: u(t.docs_url), repo_url: u(t.repo_url), pricing_url: u(t.pricing_url), license_url: u(t.license_url), privacy_url: u(t.privacy_url), security_url: u(t.security_url), logo_url: u(t.logo_url), logo_attribution: u(t.logo_attribution), product_type: t.product_type, product_status: t.product_status, summary: t.summary, description: t.description, capabilities: t.capabilities, workflows: t.workflows, advantages: t.advantages, limitations: t.limitations, prerequisites: t.prerequisites, audiences: t.audiences, alternatives: alts.map((a) => a.slug), comparison_notes: u(t.comparison_notes), categories: cats.map((c) => ({ slug: c.slug, primary: c.is_primary, label: u(c.label) })), tags: tags.filter((g) => g.kind === "tag").map((g) => g.slug), use_cases: tags.filter((g) => g.kind === "use_case").map((g) => g.slug), operating_systems: t.operating_systems, execution_modes: t.execution_modes, interfaces: t.interfaces, deployment_models: t.deployment_models, integrations: t.integrations, data_formats: t.data_formats, languages: t.languages, source_model: t.source_model, license_spdx: u(t.license_spdx), pricing_model: t.pricing_model, pricing_checked_at: d(t.pricing_checked_at), privacy_posture: u(t.privacy_posture), data_residency: u(t.data_residency), self_hostable: t.self_hostable, sources: srcs.map((s) => ({ url: s.url, type: s.source_type, title: u(s.title), supports: s.supports, checked_at: d(s.checked_at)!, note: u(s.note) })), confidence: t.confidence, uncertainty_note: u(t.uncertainty_note), discovery_method: t.discovery_method, depth: t.depth, editorial_status: t.editorial_status, visibility: t.visibility, featured: t.featured, author: t.author, reviewed_at: d(t.reviewed_at), reviewed_by: u(t.reviewed_by), quality_score: t.quality_score, link_state: t.link_state, checked_at: t.checked_at, published_at: t.published_at, updated_at: t.updated_at, duplicate_of: t.duplicate_of, }; } /** Validate + persist a full record edit from the admin editor (same path as import → same invariants), with revision + audit. */ export async function saveToolRecord(input: unknown, actor: SessionUser, ip?: string | null, existingId?: string) { const r = toolRecordSchema.parse(input); const hash = contentHash(r); const vslug = r.vendor.slug ?? r.vendor.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const [v] = await db.insert(schema.vendors).values({ slug: vslug, name: r.vendor.name, websiteUrl: r.vendor.website ?? null }).onConflictDoUpdate({ target: schema.vendors.slug, set: { name: r.vendor.name, websiteUrl: r.vendor.website ?? null, updatedAt: new Date() } }).returning({ id: schema.vendors.id }); const row = toToolRow(r, v.id, hash); let id = existingId; if (id) { const [prev] = await db.select({ editorialStatus: schema.tools.editorialStatus, slug: schema.tools.slug }).from(schema.tools).where(eq(schema.tools.id, id)); if (!prev) throw new Error("tool not found"); if (prev.slug !== r.slug) { const [clash] = await db.select({ id: schema.tools.id }).from(schema.tools).where(eq(schema.tools.slug, r.slug)); if (clash) throw new Error(`slug ${r.slug} already used`); } await db.update(schema.tools).set({ ...row, editorialStatus: r.editorial_status, reviewedAt: r.reviewed_at ?? dsql`current_date`, reviewedBy: actor.email }).where(eq(schema.tools.id, id)); await db.insert(schema.toolRevisions).values({ toolId: id, actor: actor.email, reason: "admin:edit", snapshot: r }); await audit({ id: actor.id, name: actor.email }, "tool.update", { type: "tool", id }, { slug: r.slug, status: r.editorial_status }, ip); } else { const [ins] = await db.insert(schema.tools).values({ ...row, editorialStatus: r.editorial_status, reviewedBy: actor.email }).returning({ id: schema.tools.id }); id = ins.id; await db.insert(schema.toolRevisions).values({ toolId: id, actor: actor.email, reason: "admin:create", snapshot: r }); await audit({ id: actor.id, name: actor.email }, "tool.create", { type: "tool", id }, { slug: r.slug }, ip); } const catIds = new Map((await db.select({ id: schema.categories.id, slug: schema.categories.slug }).from(schema.categories)).map((c) => [c.slug, c.id])); await db.delete(schema.toolCategories).where(eq(schema.toolCategories.toolId, id)); for (const c of r.categories) { const cid = catIds.get(c.slug); if (!cid) throw new Error(`unknown category ${c.slug}`); await db.insert(schema.toolCategories).values({ toolId: id, categoryId: cid, isPrimary: c.primary, label: c.label ?? null }).onConflictDoNothing(); } await db.delete(schema.toolTags).where(eq(schema.toolTags.toolId, id)); for (const [kind, list] of [["tag", r.tags], ["use_case", r.use_cases]] as const) for (const t of list) { const [g] = await db.insert(schema.tags).values({ slug: t, kind, name: humanize(t) }).onConflictDoUpdate({ target: [schema.tags.slug, schema.tags.kind], set: { name: humanize(t) } }).returning({ id: schema.tags.id }); await db.insert(schema.toolTags).values({ toolId: id, tagId: g.id }).onConflictDoNothing(); } await db.delete(schema.toolSources).where(eq(schema.toolSources.toolId, id)); for (const s of r.sources) { const [so] = await db.insert(schema.sources).values({ url: normalizeUrl(s.url), sourceType: s.type, title: s.title ?? null }).onConflictDoUpdate({ target: schema.sources.url, set: { sourceType: s.type, updatedAt: new Date() } }).returning({ id: schema.sources.id }); await db.insert(schema.toolSources).values({ toolId: id, sourceId: so.id, supports: s.supports, checkedAt: s.checked_at, note: s.note ?? null }).onConflictDoNothing(); } const allIds = new Map((await db.select({ id: schema.tools.id, slug: schema.tools.slug }).from(schema.tools)).map((t) => [t.slug, t.id])); await db.delete(schema.toolAlternatives).where(eq(schema.toolAlternatives.toolId, id)); for (const a of r.alternatives) { const aid = allIds.get(a); if (aid && aid !== id) await db.insert(schema.toolAlternatives).values({ toolId: id, alternativeId: aid }).onConflictDoNothing(); } if (r.editorial_status === "published") await db.update(schema.tools).set({ publishedAt: dsql`coalesce(published_at, now())` }).where(eq(schema.tools.id, id)); await enqueue("link_check", { toolId: id }, { dedupeKey: `link_check:${id}`, priority: 4 }).catch(() => {}); await enqueue("embed_tool", { toolId: id }, { dedupeKey: `embed:${id}`, priority: 8 }).catch(() => {}); return id; } export const actionSchema = z.object({ action: z.enum(["publish", "unpublish", "deprecate", "recheck", "rescan", "embed", "merge", "feature", "unfeature", "set_visibility"]), into: z.string().optional(), visibility: z.enum(["public", "private"]).optional() }); export async function toolAction(id: string, a: z.infer, actor: SessionUser, ip?: string | null) { const [t] = await db.select({ id: schema.tools.id, slug: schema.tools.slug, status: schema.tools.editorialStatus, visibility: schema.tools.visibility, qualityScore: schema.tools.qualityScore, linkState: schema.tools.linkState }).from(schema.tools).where(eq(schema.tools.id, id)); if (!t) throw new Error("tool not found"); const log = (action: string, details: Record = {}) => audit({ id: actor.id, name: actor.email }, action, { type: "tool", id }, { slug: t.slug, ...details }, ip); switch (a.action) { case "publish": { // Policy gate: published records must have quality ≥ 40 and no broken homepage. if (t.qualityScore < 40) throw new Error(`quality score ${t.qualityScore} below the publishing threshold (40)`); if (t.linkState === "broken") throw new Error("links are broken — fix or re-check before publishing"); await db.update(schema.tools).set({ editorialStatus: "published", publishedAt: dsql`coalesce(published_at, now())`, reviewedAt: dsql`current_date`, reviewedBy: actor.email, updatedAt: new Date() }).where(eq(schema.tools.id, id)); await log("tool.publish"); break; } case "unpublish": await db.update(schema.tools).set({ editorialStatus: "reviewed", updatedAt: new Date() }).where(eq(schema.tools.id, id)); await log("tool.unpublish"); break; case "deprecate": await db.update(schema.tools).set({ editorialStatus: "deprecated", updatedAt: new Date() }).where(eq(schema.tools.id, id)); await log("tool.deprecate"); break; case "feature": await db.update(schema.tools).set({ featured: true }).where(eq(schema.tools.id, id)); await log("tool.feature"); break; case "unfeature": await db.update(schema.tools).set({ featured: false }).where(eq(schema.tools.id, id)); await log("tool.unfeature"); break; case "set_visibility": await db.update(schema.tools).set({ visibility: a.visibility ?? "private", updatedAt: new Date() }).where(eq(schema.tools.id, id)); await log("tool.visibility", { visibility: a.visibility }); break; case "recheck": await enqueue("link_check", { toolId: id }, { dedupeKey: `link_check:${id}`, priority: 2 }); await log("tool.recheck_queued"); break; case "rescan": await enqueue("source_rescan", { toolId: id }, { dedupeKey: `rescan:${id}`, priority: 2 }); await log("tool.rescan_queued"); break; case "embed": await enqueue("embed_tool", { toolId: id }, { dedupeKey: `embed:${id}`, priority: 2 }); await log("tool.embed_queued"); break; case "merge": { if (!a.into || a.into === id) throw new Error("target required"); const [target] = await db.select({ id: schema.tools.id, slug: schema.tools.slug, aliases: schema.tools.aliases, name: schema.tools.name }).from(schema.tools).where(eq(schema.tools.id, a.into)); if (!target) throw new Error("target not found"); await db.transaction(async (tx) => { await tx.update(schema.tools).set({ duplicateOf: target.id, editorialStatus: "deprecated", visibility: "private", updatedAt: new Date() }).where(eq(schema.tools.id, id)); const [src] = await tx.select({ name: schema.tools.name, aliases: schema.tools.aliases }).from(schema.tools).where(eq(schema.tools.id, id)); await tx.update(schema.tools).set({ aliases: Array.from(new Set([...target.aliases, src.name, ...src.aliases].filter((x) => x !== target.name))), updatedAt: new Date() }).where(eq(schema.tools.id, target.id)); await tx.execute(dsql`INSERT INTO tool_alternatives (tool_id, alternative_id) SELECT ${target.id}, alternative_id FROM tool_alternatives WHERE tool_id=${id} AND alternative_id<>${target.id} ON CONFLICT DO NOTHING`); await tx.execute(dsql`UPDATE collection_items SET tool_id=${target.id} WHERE tool_id=${id} AND NOT EXISTS (SELECT 1 FROM collection_items ci WHERE ci.collection_id=collection_items.collection_id AND ci.tool_id=${target.id})`); await tx.insert(schema.toolRevisions).values({ toolId: id, actor: actor.email, reason: `merged into ${target.slug}`, snapshot: { mergedInto: target.id } }); }); await log("tool.merge", { into: target.slug }); break; } } } export async function duplicateCandidates() { return sql<{ aId: string; aSlug: string; aName: string; bId: string; bSlug: string; bName: string; reason: string; score: number }[]>` SELECT a.id AS "aId", a.slug AS "aSlug", a.name AS "aName", b.id AS "bId", b.slug AS "bSlug", b.name AS "bName", CASE WHEN regexp_replace(lower(a.homepage_url), '^https?://(www\.)?', '') = regexp_replace(lower(b.homepage_url), '^https?://(www\.)?', '') THEN 'same homepage' WHEN lower(a.name) = ANY(SELECT lower(x) FROM unnest(b.aliases) x) OR lower(b.name) = ANY(SELECT lower(x) FROM unnest(a.aliases) x) THEN 'name is an alias of the other' ELSE 'similar name' END AS reason, similarity(lower(a.name), lower(b.name)) AS score FROM tools a JOIN tools b ON a.id < b.id AND a.duplicate_of IS NULL AND b.duplicate_of IS NULL WHERE similarity(lower(a.name), lower(b.name)) > 0.6 OR regexp_replace(lower(a.homepage_url), '^https?://(www\.)?', '') = regexp_replace(lower(b.homepage_url), '^https?://(www\.)?', '') OR lower(a.name) = ANY(SELECT lower(x) FROM unnest(b.aliases) x) OR lower(b.name) = ANY(SELECT lower(x) FROM unnest(a.aliases) x) ORDER BY score DESC LIMIT 100`; } export async function adminStats() { const [s] = await sql`SELECT (SELECT count(*)::int FROM tools) AS total, (SELECT count(*)::int FROM tools WHERE editorial_status='published' AND visibility='public') AS published, (SELECT count(*)::int FROM tools WHERE editorial_status='reviewed') AS reviewed, (SELECT count(*)::int FROM tools WHERE editorial_status='draft') AS draft, (SELECT count(*)::int FROM tools WHERE visibility='private') AS private, (SELECT count(*)::int FROM tools WHERE depth='deep' AND editorial_status='published') AS deep, (SELECT count(*)::int FROM tools WHERE link_state='broken') AS broken, (SELECT count(*)::int FROM tools WHERE link_state='unchecked') AS unchecked, (SELECT count(*)::int FROM jobs WHERE status='queued') AS queued, (SELECT count(*)::int FROM jobs WHERE status='running') AS running, (SELECT count(*)::int FROM jobs WHERE status='dead') AS dead, (SELECT count(*)::int FROM error_reports WHERE status='open') AS reports, (SELECT count(*)::int FROM settings WHERE key LIKE 'review:%' OR key LIKE 'stale:%') AS flags, (SELECT max(created_at) FROM audit_log) AS "lastAudit", (SELECT value FROM settings WHERE key = ${"ai_usage:" + new Date().toISOString().slice(0, 7)}) AS "aiUsage", (SELECT count(*)::int FROM owner_notes WHERE use_state='active') AS active, (SELECT count(*)::int FROM owner_notes WHERE use_state='testing') AS testing, (SELECT count(*)::int FROM owner_notes WHERE use_state='candidate') AS candidate`; const byCat = await sql<{ name: string; n: number; deep: number }[]>`SELECT p.name, count(DISTINCT t.id)::int AS n, count(DISTINCT t.id) FILTER (WHERE t.depth='deep')::int AS deep FROM categories p JOIN categories c ON c.parent_id=p.id JOIN tool_categories tc ON tc.category_id=c.id JOIN tools t ON t.id=tc.tool_id AND t.editorial_status='published' AND t.visibility='public' GROUP BY p.name, p.sort_order ORDER BY p.sort_order`; const byConf = await sql<{ confidence: string; n: number }[]>`SELECT confidence, count(*)::int AS n FROM tools WHERE editorial_status='published' GROUP BY 1 ORDER BY 1`; return { ...s, byCat, byConf }; }