diff --git a/app/(app)/admin/_client.tsx b/app/(admin)/admin/_client.tsx similarity index 99% rename from app/(app)/admin/_client.tsx rename to app/(admin)/admin/_client.tsx index 0c9166e3f..35c33d90a 100644 --- a/app/(app)/admin/_client.tsx +++ b/app/(admin)/admin/_client.tsx @@ -77,7 +77,7 @@ const AdminDashboard = () => { const { data: reportCounts } = api.report.getCounts.useQuery(); return ( -
+

{"// "}admin diff --git a/app/(app)/admin/moderation/_client.tsx b/app/(admin)/admin/moderation/_client.tsx similarity index 100% rename from app/(app)/admin/moderation/_client.tsx rename to app/(admin)/admin/moderation/_client.tsx diff --git a/app/(app)/admin/moderation/page.tsx b/app/(admin)/admin/moderation/page.tsx similarity index 50% rename from app/(app)/admin/moderation/page.tsx rename to app/(admin)/admin/moderation/page.tsx index 66132c76c..86157e85c 100644 --- a/app/(app)/admin/moderation/page.tsx +++ b/app/(admin)/admin/moderation/page.tsx @@ -1,5 +1,3 @@ -import { getServerAuthSession } from "@/server/auth"; -import { redirect } from "next/navigation"; import { Suspense } from "react"; import ModerationQueue from "./_client"; @@ -8,13 +6,8 @@ export const metadata = { description: "Review and manage reported content", }; -export default async function Page() { - const session = await getServerAuthSession(); - - if (!session?.user || session.user.role !== "ADMIN") { - redirect("/"); - } - +// Admin-role gate is enforced in app/(admin)/layout.tsx. +export default function Page() { return ( diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx new file mode 100644 index 000000000..a7c03d915 --- /dev/null +++ b/app/(admin)/admin/page.tsx @@ -0,0 +1,11 @@ +import AdminDashboard from "./_client"; + +export const metadata = { + title: "Admin Dashboard - Codú", + description: "Admin dashboard for managing Codú platform", +}; + +// Admin-role gate is enforced in app/(admin)/layout.tsx. +export default function Page() { + return ; +} diff --git a/app/(app)/admin/sources/_client.tsx b/app/(admin)/admin/sources/_client.tsx similarity index 100% rename from app/(app)/admin/sources/_client.tsx rename to app/(admin)/admin/sources/_client.tsx diff --git a/app/(admin)/admin/sources/page.tsx b/app/(admin)/admin/sources/page.tsx new file mode 100644 index 000000000..84d4117a5 --- /dev/null +++ b/app/(admin)/admin/sources/page.tsx @@ -0,0 +1,11 @@ +import Content from "./_client"; + +export const metadata = { + title: "Feed Sources - Admin", + description: "Manage RSS feed sources for the content aggregator", +}; + +// Admin-role gate is enforced in app/(admin)/layout.tsx. +export default function Page() { + return ; +} diff --git a/app/(app)/admin/tags/_client.tsx b/app/(admin)/admin/tags/_client.tsx similarity index 100% rename from app/(app)/admin/tags/_client.tsx rename to app/(admin)/admin/tags/_client.tsx diff --git a/app/(admin)/admin/tags/page.tsx b/app/(admin)/admin/tags/page.tsx new file mode 100644 index 000000000..b94261af9 --- /dev/null +++ b/app/(admin)/admin/tags/page.tsx @@ -0,0 +1,11 @@ +import TagsAdmin from "./_client"; + +export const metadata = { + title: "Tag Management - Codú Admin", + description: "Manage tags and topics on the Codú platform", +}; + +// Admin-role gate is enforced in app/(admin)/layout.tsx. +export default function Page() { + return ; +} diff --git a/app/(app)/admin/users/_client.tsx b/app/(admin)/admin/users/_client.tsx similarity index 100% rename from app/(app)/admin/users/_client.tsx rename to app/(admin)/admin/users/_client.tsx diff --git a/app/(admin)/admin/users/page.tsx b/app/(admin)/admin/users/page.tsx new file mode 100644 index 000000000..9e2c9290b --- /dev/null +++ b/app/(admin)/admin/users/page.tsx @@ -0,0 +1,11 @@ +import UserManagement from "./_client"; + +export const metadata = { + title: "User Management - Codú Admin", + description: "Search and manage users", +}; + +// Admin-role gate is enforced in app/(admin)/layout.tsx. +export default function Page() { + return ; +} diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx new file mode 100644 index 000000000..447bfe1ab --- /dev/null +++ b/app/(admin)/layout.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { redirect } from "next/navigation"; +import { getServerAuthSession } from "@/server/auth"; +import { AdminShell } from "@/components/Admin/AdminShell"; + +export const metadata = { + title: "Admin - Codú", + description: "Private admin dashboard for managing the Codú platform", + robots: { index: false, follow: false }, +}; + +/** + * Layout for the private `(admin)` route group. The admin-role gate is enforced + * ONCE here for the whole section, so individual pages don't repeat it. This + * group is a sibling of `(app)`, so it is fully outside the public AppShell + * rails — admin gets its own full-width cockpit. + */ +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getServerAuthSession(); + + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ( + + {children} + + ); +} diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx deleted file mode 100644 index 4e26b90fb..000000000 --- a/app/(app)/admin/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { getServerAuthSession } from "@/server/auth"; -import { redirect } from "next/navigation"; -import AdminDashboard from "./_client"; - -export const metadata = { - title: "Admin Dashboard - Codú", - description: "Admin dashboard for managing Codú platform", -}; - -export default async function Page() { - const session = await getServerAuthSession(); - - if (!session?.user || session.user.role !== "ADMIN") { - redirect("/"); - } - - return ; -} diff --git a/app/(app)/admin/sources/page.tsx b/app/(app)/admin/sources/page.tsx deleted file mode 100644 index c16ee4ddc..000000000 --- a/app/(app)/admin/sources/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Content from "./_client"; -import { getServerAuthSession } from "@/server/auth"; -import { redirect } from "next/navigation"; - -export const metadata = { - title: "Feed Sources - Admin", - description: "Manage RSS feed sources for the content aggregator", -}; - -export default async function Page() { - const session = await getServerAuthSession(); - - // Redirect non-admin users - if (!session?.user || session.user.role !== "ADMIN") { - redirect("/"); - } - - return ; -} diff --git a/app/(app)/admin/tags/page.tsx b/app/(app)/admin/tags/page.tsx deleted file mode 100644 index 8ebd258db..000000000 --- a/app/(app)/admin/tags/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { getServerAuthSession } from "@/server/auth"; -import { redirect } from "next/navigation"; -import TagsAdmin from "./_client"; - -export const metadata = { - title: "Tag Management - Codú Admin", - description: "Manage tags and topics on the Codú platform", -}; - -export default async function Page() { - const session = await getServerAuthSession(); - - if (!session?.user || session.user.role !== "ADMIN") { - redirect("/"); - } - - return ; -} diff --git a/app/(app)/admin/users/page.tsx b/app/(app)/admin/users/page.tsx deleted file mode 100644 index 37d62e58d..000000000 --- a/app/(app)/admin/users/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { getServerAuthSession } from "@/server/auth"; -import { redirect } from "next/navigation"; -import UserManagement from "./_client"; - -export const metadata = { - title: "User Management - Codú Admin", - description: "Search and manage users", -}; - -export default async function Page() { - const session = await getServerAuthSession(); - - if (!session?.user || session.user.role !== "ADMIN") { - redirect("/"); - } - - return ; -} diff --git a/app/api/cron/daily-review/route.ts b/app/api/cron/daily-review/route.ts new file mode 100644 index 000000000..ded8ff7fe --- /dev/null +++ b/app/api/cron/daily-review/route.ts @@ -0,0 +1,446 @@ +import { NextResponse } from "next/server"; +import { + and, + count, + desc, + eq, + gt, + isNotNull, + isNull, + lt, + or, +} from "drizzle-orm"; +import * as Sentry from "@sentry/nextjs"; + +import { env } from "@/config/env"; +import { db } from "@/server/db"; +import { + comments, + post_metadata, + post_topic, + posts, + reports, + topic, + user, +} from "@/server/db/schema"; +import { isBedrockEnabled } from "@/server/lib/bedrock"; +import { + analyzePost, + ANALYSIS_SCHEMA_VERSION, + type TopicVocabEntry, +} from "@/server/lib/contentAnalysis"; +import { autoReview } from "@/server/lib/autoReview"; +import sendEmail from "@/utils/sendEmail"; + +// Nightly review cron. Auth via Bearer CRON_SECRET (a headless scheduler can't +// use admin-session auth); unset secret refuses to run (500), wrong/missing +// token 401. Wired via AWS Lambda + EventBridge (cdk/lib/cron-stack.ts). +// +// Four incremental passes (see docs/plans/2026-06-14-admin-shell-and-ai-content-design.md): +// 1. topic + sentiment tagging (posts) +// 2. quality / spam scoring (posts) — passes 1+2 share one Bedrock call +// 3. re-screen moderation (posts + comments) -> reports queue (source=system) +// 4. daily digest -> email the founder only when something needs attention +// +// Everything is incremental (per-row analyzedAt / moderatedAt watermark) and +// capped per run, so an empty worklist is a near-zero-cost no-op and a backfill +// can't blow the Lambda timeout. Each item is isolated (try/catch + Sentry) so +// one bad row never kills the batch. + +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +const POST_CAP = 100; +const COMMENT_CAP = 200; +// Sentinel modelId for rows scored by the cheap heuristic (Bedrock disabled), so +// they're distinguishable from human-curated rows (modelId IS NULL) and can be +// upgraded once Bedrock is enabled. +const HEURISTIC_MODEL = "heuristic"; + +function isAuthorized(request: Request): boolean { + const secret = env.CRON_SECRET; + if (!secret) return false; + return request.headers.get("authorization") === `Bearer ${secret}`; +} + +/** Map the model's free-text moderation category to a reports.reason enum. */ +function mapReason( + category: string, +): "spam" | "nsfw" | "off_topic" | "misinformation" | "other" { + const c = category.toLowerCase(); + if (c.includes("nsfw") || c.includes("porn") || c.includes("sexual")) + return "nsfw"; + if (c.includes("spam") || c.includes("crypto") || c.includes("shill")) + return "spam"; + if (c.includes("off") || c.includes("topic") || c.includes("theme")) + return "off_topic"; + if (c.includes("misinfo") || c.includes("scam") || c.includes("fraud")) + return "misinformation"; + return "other"; +} + +function titleCase(slug: string): string { + return slug + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +/** Raise a system (auto-flagged) report unless one is already pending. */ +async function raiseSystemReport(opts: { + postId?: string; + commentId?: string; + category: string; + reason: string; +}): Promise { + const target = opts.postId + ? eq(reports.postId, opts.postId) + : eq(reports.commentId, opts.commentId as string); + const existing = await db + .select({ id: reports.id }) + .from(reports) + .where( + and(target, eq(reports.source, "system"), eq(reports.status, "pending")), + ) + .limit(1); + if (existing.length > 0) return false; + + await db.insert(reports).values({ + postId: opts.postId ?? null, + commentId: opts.commentId ?? null, + reporterId: null, + source: "system", + reason: mapReason(opts.category), + details: opts.reason || "Auto-flagged by nightly review", + status: "pending", + }); + return true; +} + +async function reviewPosts( + vocab: TopicVocabEntry[], + slugToId: Map, +): Promise<{ analyzed: number; flagged: number; proposed: number }> { + const bedrock = isBedrockEnabled(); + const now = new Date().toISOString(); + + // Incremental worklist: published posts that have never been analysed, whose + // AI metadata is stale (post edited / schema bumped), or that only have a + // heuristic placeholder now that Bedrock is available. Rows with modelId IS + // NULL are human-curated and deliberately skipped. + const staleBranches = [ + gt(posts.updatedAt, post_metadata.analyzedAt), + lt(post_metadata.schemaVersion, ANALYSIS_SCHEMA_VERSION), + ]; + if (bedrock) staleBranches.push(eq(post_metadata.modelId, HEURISTIC_MODEL)); + + const worklist = await db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + body: posts.body, + externalUrl: posts.externalUrl, + }) + .from(posts) + .leftJoin(post_metadata, eq(post_metadata.postId, posts.id)) + .where( + and( + eq(posts.status, "published"), + or( + isNull(post_metadata.postId), + and(isNotNull(post_metadata.modelId), or(...staleBranches)), + ), + ), + ) + .orderBy(desc(posts.updatedAt)) + .limit(POST_CAP); + + let analyzed = 0; + let flagged = 0; + let proposed = 0; + + for (const post of worklist) { + try { + const analysis = await analyzePost( + { + type: post.type, + title: post.title, + body: post.body, + externalUrl: post.externalUrl, + }, + vocab, + ); + + // Pass 3 (moderation) runs whether or not Bedrock is enabled. + if (analysis.moderation.verdict === "review") { + const raised = await raiseSystemReport({ + postId: post.id, + category: analysis.moderation.category, + reason: analysis.moderation.reason, + }); + if (raised) flagged += 1; + } + + // Passes 1+2 (tagging / sentiment / quality) need Bedrock. When it's off + // we still advance the watermark with a heuristic placeholder so the post + // doesn't re-enter the worklist every run. + await db + .insert(post_metadata) + .values({ + postId: post.id, + sentiment: analysis.sentiment, + sentimentScore: analysis.sentimentScore, + qualityScore: analysis.qualityScore, + qualityReason: analysis.qualityReason, + modelId: bedrock + ? (process.env.BEDROCK_MODEL_ID as string) + : HEURISTIC_MODEL, + analyzedAt: now, + schemaVersion: ANALYSIS_SCHEMA_VERSION, + }) + .onConflictDoUpdate({ + target: post_metadata.postId, + set: { + sentiment: analysis.sentiment, + sentimentScore: analysis.sentimentScore, + qualityScore: analysis.qualityScore, + qualityReason: analysis.qualityReason, + modelId: bedrock + ? (process.env.BEDROCK_MODEL_ID as string) + : HEURISTIC_MODEL, + analyzedAt: now, + schemaVersion: ANALYSIS_SCHEMA_VERSION, + }, + }); + + if (bedrock) { + // Rewrite only our own AI edges; manual edges are never touched. + await db + .delete(post_topic) + .where( + and(eq(post_topic.postId, post.id), eq(post_topic.source, "ai")), + ); + + const edges = analysis.topics + .map((t) => ({ + topicId: slugToId.get(t.slug), + confidence: t.confidence, + })) + .filter((e): e is { topicId: number; confidence: number } => + Number.isInteger(e.topicId), + ); + if (edges.length > 0) { + await db + .insert(post_topic) + .values( + edges.map((e) => ({ + postId: post.id, + topicId: e.topicId, + confidence: e.confidence, + source: "ai" as const, + })), + ) + // A manual edge for the same topic wins — keep it. + .onConflictDoNothing(); + } + + // Record model-proposed topics as pending for admin approval. + if (analysis.proposedTopics.length > 0) { + const inserted = await db + .insert(topic) + .values( + analysis.proposedTopics.map((slug) => ({ + slug, + label: titleCase(slug), + status: "pending" as const, + })), + ) + .onConflictDoNothing() + .returning({ id: topic.id }); + proposed += inserted.length; + } + } + + analyzed += 1; + } catch (err) { + Sentry.captureException(err); + } + } + + return { analyzed, flagged, proposed }; +} + +async function reviewComments(): Promise<{ + moderated: number; + flagged: number; +}> { + const now = new Date().toISOString(); + + const worklist = await db + .select({ id: comments.id, body: comments.body }) + .from(comments) + .where( + and( + isNull(comments.deletedAt), + or( + isNull(comments.moderatedAt), + gt(comments.updatedAt, comments.moderatedAt), + ), + ), + ) + .orderBy(desc(comments.updatedAt)) + .limit(COMMENT_CAP); + + let moderated = 0; + let flagged = 0; + + for (const comment of worklist) { + try { + const verdict = await autoReview({ type: "comment", body: comment.body }); + if (verdict.verdict === "review") { + const raised = await raiseSystemReport({ + commentId: comment.id, + category: verdict.category, + reason: verdict.reason, + }); + if (raised) flagged += 1; + } + await db + .update(comments) + .set({ moderatedAt: now }) + .where(eq(comments.id, comment.id)); + moderated += 1; + } catch (err) { + Sentry.captureException(err); + } + } + + return { moderated, flagged }; +} + +async function sendDigest(summary: { + postsAnalyzed: number; + postsFlagged: number; + commentsModerated: number; + commentsFlagged: number; + proposedTopics: number; +}): Promise { + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + const [usersRow, postsRow, commentsRow, pendingRow] = await Promise.all([ + db.select({ n: count() }).from(user).where(gt(user.createdAt, since)), + db.select({ n: count() }).from(posts).where(gt(posts.createdAt, since)), + db + .select({ n: count() }) + .from(comments) + .where(gt(comments.createdAt, since)), + db + .select({ n: count() }) + .from(reports) + .where(eq(reports.status, "pending")), + ]); + const newUsers = usersRow[0]?.n ?? 0; + const newPosts = postsRow[0]?.n ?? 0; + const newComments = commentsRow[0]?.n ?? 0; + const pendingReports = pendingRow[0]?.n ?? 0; + + const flagsThisRun = summary.postsFlagged + summary.commentsFlagged; + const needsAttention = flagsThisRun > 0 || pendingReports > 0; + + // No noise: only email when there's something to act on. + if (!needsAttention || !env.ADMIN_EMAIL) return false; + + const rows: Array<[string, number]> = [ + ["New users (24h)", newUsers], + ["New posts (24h)", newPosts], + ["New comments (24h)", newComments], + ["Posts analyzed", summary.postsAnalyzed], + ["Comments moderated", summary.commentsModerated], + ["Auto-flagged this run", flagsThisRun], + ["Pending in moderation queue", pendingReports], + ["New proposed topics", summary.proposedTopics], + ]; + + const htmlMessage = ` +

Codú daily review

+

${flagsThisRun} new auto-flag(s); ${pendingReports} item(s) waiting in the moderation queue.

+ + ${rows + .map( + ([label, value]) => + ``, + ) + .join("")} +
${label}${value}
+

Open the moderation queue →

+ `; + + await sendEmail({ + recipient: env.ADMIN_EMAIL, + subject: `Codú daily review — ${flagsThisRun} new flag(s), ${pendingReports} pending`, + htmlMessage, + }); + return true; +} + +async function loadVocab(): Promise<{ + vocab: TopicVocabEntry[]; + slugToId: Map; +}> { + const rows = await db + .select({ id: topic.id, slug: topic.slug, label: topic.label }) + .from(topic) + .where(eq(topic.status, "active")); + const slugToId = new Map(rows.map((r) => [r.slug, r.id])); + return { + vocab: rows.map((r) => ({ slug: r.slug, label: r.label })), + slugToId, + }; +} + +async function handle(request: Request) { + if (!env.CRON_SECRET) { + return NextResponse.json( + { error: "CRON_SECRET not configured" }, + { status: 500 }, + ); + } + if (!isAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { vocab, slugToId } = await loadVocab(); + const postResult = await reviewPosts(vocab, slugToId); + const commentResult = await reviewComments(); + + const summary = { + postsAnalyzed: postResult.analyzed, + postsFlagged: postResult.flagged, + proposedTopics: postResult.proposed, + commentsModerated: commentResult.moderated, + commentsFlagged: commentResult.flagged, + }; + + const digestSent = await sendDigest(summary); + + return NextResponse.json({ + ok: true, + bedrock: isBedrockEnabled(), + ...summary, + digestSent, + }); + } catch (error) { + Sentry.captureException(error); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} + +export async function GET(request: Request) { + return handle(request); +} + +export async function POST(request: Request) { + return handle(request); +} diff --git a/cdk/lambdas/dailyReview/index.ts b/cdk/lambdas/dailyReview/index.ts new file mode 100644 index 000000000..85bb01d07 --- /dev/null +++ b/cdk/lambdas/dailyReview/index.ts @@ -0,0 +1,69 @@ +import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; + +// Thin invoker: on a daily schedule, POST the app's /api/cron/daily-review route +// (which does the actual topic tagging / sentiment / quality scoring / moderation +// re-screen / digest). This Lambda only authenticates with CRON_SECRET and +// reports back. Mirrors promoteScheduled/index.ts. + +const ssmClient = new SSMClient({ region: "eu-west-1" }); + +// Read a decrypted SecureString from SSM (matches rssFetcher's `/env/...` convention). +async function getSsmValue(secretName: string): Promise { + const params = { + Name: secretName, + WithDecryption: true, + }; + + try { + const command = new GetParameterCommand(params); + const response = await ssmClient.send(command); + if (!response.Parameter || !response.Parameter.Value) { + throw new Error(`Parameter not found: ${secretName}`); + } + return response.Parameter.Value; + } catch (error) { + console.error(`Error retrieving secret: ${error}`); + throw error; + } +} + +// Site base URL from the required `/env/siteUrl` param. FAIL CLOSED: a missing +// or unreadable param throws (visible Lambda failure) rather than falling back +// to production — otherwise a non-prod deploy would silently POST prod. +async function getBaseUrl(): Promise { + const value = await getSsmValue("/env/siteUrl"); + return value.replace(/\/+$/, ""); +} + +// Main Lambda handler +exports.handler = async function () { + console.log("Daily Review invoker Lambda running"); + + const secret = await getSsmValue("/env/cronSecret"); + const base = await getBaseUrl(); + const url = `${base}/api/cron/daily-review`; + + console.log(`Invoking ${url}`); + + // Node 20 runtime ships a global fetch — no node-fetch dependency needed. + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${secret}`, + }, + }); + + const body = await response.text(); + console.log(`Response ${response.status}: ${body}`); + + // Surface failures in CloudWatch so a broken route / bad secret is visible. + if (!response.ok) { + throw new Error(`daily-review returned ${response.status}: ${body}`); + } + + return { + statusCode: 200, + headers: { "Content-Type": "application/json" }, + body, + }; +}; diff --git a/cdk/lambdas/dailyReview/package-lock.json b/cdk/lambdas/dailyReview/package-lock.json new file mode 100644 index 000000000..22657670c --- /dev/null +++ b/cdk/lambdas/dailyReview/package-lock.json @@ -0,0 +1,605 @@ +{ + "name": "promote-scheduled-lambda", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "promote-scheduled-lambda", + "version": "1.0.0", + "dependencies": { + "@aws-sdk/client-ssm": "^3.509.0" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-ssm": { + "version": "3.1066.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1066.0.tgz", + "integrity": "sha512-hnhsB2v0eYqt9A7QBTyTQR6O6hGQkDEpeuUKsk4rru7bgiDfKgMJ53WRqKwxEPgPORXBQrItl3jWRW30+CkjGg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.55", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.20.tgz", + "integrity": "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.12", + "@aws-sdk/xml-builder": "^3.972.29", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.46.tgz", + "integrity": "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.48.tgz", + "integrity": "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.53.tgz", + "integrity": "sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-env": "^3.972.46", + "@aws-sdk/credential-provider-http": "^3.972.48", + "@aws-sdk/credential-provider-login": "^3.972.52", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@aws-sdk/credential-provider-sso": "^3.972.52", + "@aws-sdk/credential-provider-web-identity": "^3.972.52", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.52.tgz", + "integrity": "sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.55.tgz", + "integrity": "sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.46", + "@aws-sdk/credential-provider-http": "^3.972.48", + "@aws-sdk/credential-provider-ini": "^3.972.53", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@aws-sdk/credential-provider-sso": "^3.972.52", + "@aws-sdk/credential-provider-web-identity": "^3.972.52", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.46.tgz", + "integrity": "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.52.tgz", + "integrity": "sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/token-providers": "3.1066.0", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.52.tgz", + "integrity": "sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.20.tgz", + "integrity": "sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/signature-v4-multi-region": "^3.996.34", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.34.tgz", + "integrity": "sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.12", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1066.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1066.0.tgz", + "integrity": "sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.12.tgz", + "integrity": "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.7.tgz", + "integrity": "sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.29.tgz", + "integrity": "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@smithy/core": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz", + "integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.8.tgz", + "integrity": "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.6.tgz", + "integrity": "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.7.tgz", + "integrity": "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.6.tgz", + "integrity": "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz", + "integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/anynum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", + "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/strnum": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", + "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + } + } +} diff --git a/cdk/lambdas/dailyReview/package.json b/cdk/lambdas/dailyReview/package.json new file mode 100644 index 000000000..7779e19a1 --- /dev/null +++ b/cdk/lambdas/dailyReview/package.json @@ -0,0 +1,8 @@ +{ + "name": "daily-review-lambda", + "version": "1.0.0", + "description": "Thin invoker Lambda to trigger the nightly content-review cron route", + "dependencies": { + "@aws-sdk/client-ssm": "^3.509.0" + } +} diff --git a/cdk/lib/cron-stack.ts b/cdk/lib/cron-stack.ts index 808e94c21..8983d83c3 100644 --- a/cdk/lib/cron-stack.ts +++ b/cdk/lib/cron-stack.ts @@ -112,5 +112,30 @@ export class CronStack extends cdk.Stack { promoteScheduledRule.addTarget( new targets.LambdaFunction(promoteScheduledFn), ); + + // Nightly Content-Review Invoker Lambda — reads CRON_SECRET from SSM and + // POSTs the app's /api/cron/daily-review route (topic tagging, sentiment, + // quality scoring, moderation re-screen, digest). Same thin-invoker pattern + // as PromoteScheduled. + const dailyReviewFn = new NodejsFunction(this, "DailyReviewLambda", { + timeout: cdk.Duration.seconds(60), + runtime: lambda.Runtime.NODEJS_20_X, + entry: path.join(__dirname, "/../lambdas/dailyReview/index.ts"), + depsLockFilePath: path.join( + __dirname, + "/../lambdas/dailyReview/package-lock.json", + ), + role: lambdaRole, + bundling: { + nodeModules: ["@aws-sdk/client-ssm"], + }, + }); + + // Run daily at 6:00 AM UTC (after the 5:00 AM vote reconciliation). + const dailyReviewRule = new events.Rule(this, "DailyReviewRule", { + schedule: events.Schedule.expression("cron(0 6 * * ? *)"), + }); + + dailyReviewRule.addTarget(new targets.LambdaFunction(dailyReviewFn)); } } diff --git a/components/Admin/AdminShell.tsx b/components/Admin/AdminShell.tsx new file mode 100644 index 000000000..de52a608f --- /dev/null +++ b/components/Admin/AdminShell.tsx @@ -0,0 +1,218 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Squares2X2Icon, + FlagIcon, + UsersIcon, + RssIcon, + TagIcon, + RectangleStackIcon, + ChartBarIcon, + ArrowLeftIcon, +} from "@heroicons/react/24/outline"; + +interface AdminUser { + name?: string | null; + image?: string | null; +} + +interface NavItem { + name: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + /** Planned surface that doesn't exist yet — rendered disabled with a badge. */ + soon?: boolean; +} + +// Sidebar sections. `soon` items are Phase 2/3 surfaces (see +// docs/plans/2026-06-14-admin-shell-and-ai-content-design.md) — shown as the +// roadmap but not linked until their routes exist. +const NAV: NavItem[] = [ + { name: "Overview", href: "/admin", icon: Squares2X2Icon }, + { name: "Moderation", href: "/admin/moderation", icon: FlagIcon }, + { name: "Users", href: "/admin/users", icon: UsersIcon }, + { name: "Sources", href: "/admin/sources", icon: RssIcon }, + { name: "Tags", href: "/admin/tags", icon: TagIcon }, + { + name: "Content", + href: "/admin/content", + icon: RectangleStackIcon, + soon: true, + }, + { name: "Insights", href: "/admin/insights", icon: ChartBarIcon, soon: true }, +]; + +/** + * The private admin cockpit shell: a persistent left sidebar + slim top bar + + * full-width fluid content area. Lives in the `(admin)` route group so it is + * fully outside the public `AppShell` rails. Reuses the Codú design tokens. + */ +export function AdminShell({ + children, + user, +}: { + children: React.ReactNode; + user: AdminUser; +}) { + const pathname = usePathname(); + + const isActive = (href: string) => + href === "/admin" ? pathname === "/admin" : pathname?.startsWith(href); + + return ( +
+ {/* Desktop sidebar */} + + +
+ {/* Top bar */} +
+
+ + Codú + + + Admin + +
+

+ {"// "}admin +

+
+ + Back to site + + +
+
+ + {/* Mobile horizontal nav (sidebar is hidden < md) */} + + +
+ {children} +
+
+
+ ); +} + +function NavLink({ item, active }: { item: NavItem; active: boolean }) { + const Icon = item.icon; + const base = + "flex items-center gap-2.5 rounded-md px-3 py-2 text-sm transition-colors duration-base ease-out"; + + if (item.soon) { + return ( + + + {item.name} + + Soon + + + ); + } + + return ( + + + {item.name} + + ); +} + +function MobileNavLink({ item, active }: { item: NavItem; active: boolean }) { + if (item.soon) { + return ( + + {item.name} + + ); + } + return ( + + {item.name} + + ); +} + +function Avatar({ + name, + image, +}: { + name?: string | null; + image?: string | null; +}) { + if (image) { + return ( + {`${name + ); + } + return ( + + {name?.[0]?.toUpperCase() || "C"} + + ); +} diff --git a/docs/plans/2026-06-14-admin-shell-and-ai-content-design.md b/docs/plans/2026-06-14-admin-shell-and-ai-content-design.md new file mode 100644 index 000000000..e27bcc6f7 --- /dev/null +++ b/docs/plans/2026-06-14-admin-shell-and-ai-content-design.md @@ -0,0 +1,284 @@ +# Admin shell + AI content pipeline — design + +Date: 2026-06-14 +Status: design approved (brainstormed with founder); admin shell to be built first. + +## Why + +Two problems, one foundation: + +1. **The admin dashboard is crushed.** Admin pages live at `app/(app)/admin/*`, so + they inherit `(app)/layout.tsx` → `AppShell`, which wraps every child in the + public 3-column rail grid (`LeftRail` / narrow center / `RightRail`). Management + tables get the ~600px center column the feed uses. Admin is private — it should + not share the public member chrome at all. +2. **Managing the platform is manual.** As a solo founder, auditing content, + moderation, users, and quality needs to be a glance, not a dig. We also want + AI-derived topic/sentiment/quality signals so the feed can be personalized. + +These connect: the admin shell becomes the cockpit for the AI content pipeline. + +## Scope decision + +- Write this one design doc covering all three efforts (admin shell, AI review + cron, personalization). +- **Build the admin shell now.** The cron, AI metadata, and personalized ranking + are designed here and built as later phases. + +--- + +## 1. Admin shell — route-group as layout boundary + +### The mental model + +Next.js route groups are already the "back out of a layout" mechanism. The top +level already has siblings, each its own layout world: + +``` +app/ + (app)/ ← the rail shell (LeftRail / center / RightRail). The social surface. + (auth)/ ← auth chrome + (editor)/ ← editor chrome + (marketing)/ ← marketing chrome + (admin)/ ← NEW: AdminShell. Private management. Full width, own nav. +``` + +`AppShell` is **not global** — it is scoped to `(app)`. A sibling group escapes it +structurally, with no runtime flag. The only reason it _feels_ global is that +nearly everything was dropped into `(app)`, and the two pages that wanted no rails +(`/speakers`, `/volunteer`) used a runtime hack: the `BARE_ROUTES` array inside +`AppShell` that conditionally drops the rails. That hack is the smell — a page in +the shell group that doesn't want the shell. + +**Rule going forward:** a page that does not want the rail shell does not live in +`(app)`. Pick the sibling group whose chrome fits, or add a new group. No runtime +flags, no fighting a parent layout. + +Future non-shell pages ("other things I'll want up") are deferred — but the pattern +is documented so adding a `(bare)` public group or more admin tools later is a +structural 2-minute move with no rework. Optional cleanup (not in this work): +retire `BARE_ROUTES` by moving `/speakers` + `/volunteer` into a `(bare)` group. + +### The structure to build + +``` +app/(admin)/ + layout.tsx ← AdminShell + admin-gate (role check) enforced ONCE here + admin/ + page.tsx ← Overview dashboard (moved) + users/ page.tsx + _client.tsx (moved) + sources/ page.tsx + _client.tsx (moved) + tags/ page.tsx + _client.tsx (moved) + moderation/ page.tsx + _client.tsx (moved) +``` + +Route groups don't change URLs — every `/admin/*` link, bookmark, and redirect +keeps working. The page files move from `(app)/admin/*` to `(admin)/admin/*`. + +### AdminShell + +- **Left sidebar** (persistent): Overview, Moderation, Users, Sources, Tags, + and placeholders for the new surfaces — Content, Insights, Settings. Active-state + styling mirrors `LeftRail`. +- **Slim top bar**: page title / breadcrumb, "← Back to site" link, founder avatar. +- **Full-width fluid content** (`max-w-screen-2xl`, real padding) so tables breathe. +- Reuses existing design tokens (`bg-canvas`, `border-hairline`, `font-display`, + the `eyebrow` / `slash` motifs) so it reads as Codú, not a bolted-on admin theme. + +### Auth + +The `session.user.role !== "ADMIN"` → `redirect("/")` gate moves into +`(admin)/layout.tsx`, enforced once for the whole section. Each `page.tsx` drops +its own gate. No engagement side-effects (`recordDailyActivity`, `ensureReferral`) +or public rails run here — it's private. + +--- + +## 2. AI metadata data model (provenance-aware) + +Metadata is a general layer that BOTH the founder (manual) and the cron (AI) write +to. Provenance is tracked via a `source` field so manual tags are authoritative and +the nightly job never overwrites them. + +### `post_metadata` (1:1 with posts) — per-post signal envelope + +``` +postId uuid PK/FK → posts.id (cascade) +sentiment varchar -- nullable; "positive" | "neutral" | "negative" +sentimentScore real -- -1..1 +qualityScore real -- 0..1 (spam / low-effort signal) +qualityReason text -- short model rationale +modelId text -- Bedrock model that produced it; null if human-set +analyzedAt timestamptz -- last AI pass; THIS IS THE INCREMENTAL WATERMARK +schemaVersion integer -- bump to force re-analysis of everything +``` + +A separate 1:1 table (not columns on `posts`) keeps the hot `posts` row lean and +lets the cron write without bumping `posts.updatedAt`. + +### Controlled topic vocabulary + +LLM free-text drifts ("RAG" / "rag" / "retrieval-augmented"). Topics resolve +against a curated list so manual + AI tags share one clean namespace. + +``` +topic id, slug, label, status(active|pending), createdAt + -- seeded: rag, agents, prompting, evals, nextjs, indie-hacking, + -- fundraising, ... ; model picks from the list, may propose + -- new ones into `pending` for founder approval in admin. + +post_topic postId uuid FK, topicId int FK, confidence real (nullable for manual), + source varchar ("ai" | "manual"), createdAt timestamptz, + PRIMARY KEY (postId, topicId) +``` + +`post_metadata` = per-post AI verdict; `post_topic` = normalized, queryable topic +edges that power ranking. Human `Tag` / `post_tags` stays untouched. + +> Note: a `profile.myInterests` query + `openTopics` action already exist in the +> rail ("Your topics"). The personalization layer should reconcile with / build on +> that existing interest concept rather than introduce a parallel one. + +### The coexistence rule (manual + AI) + +- The cron only ever **deletes and rewrites `source = 'ai'`** rows in `post_topic`. + `source = 'manual'` edges are never touched. +- For `post_metadata`, a manually-set field leaves a marker (e.g. `modelId = null`) + that tells the cron to skip overwriting it. +- So the founder can hand-tag a post; the nightly job fills the blanks around it. + +--- + +## 3. Nightly review cron — incremental, comment-aware + +One route, **`/api/cron/daily-review`** (Bearer `CRON_SECRET`, same auth as +`promote-scheduled`), invoked by an EventBridge rule + Lambda invoker — the +established pattern in `cdk/lib/cron-stack.ts`. + +### Incremental worklist ("never re-tag unchanged content") + +`analyzedAt` per row IS the watermark — no fragile global cursor. + +- **Posts to analyze:** `posts LEFT JOIN post_metadata` where + `analyzedAt IS NULL` **OR** `posts.updatedAt > analyzedAt` **OR** + `schemaVersion < N`. Unchanged-since-last-analysis posts are not in the worklist. + Editing a post re-enters it; bumping `schemaVersion` re-enters everything. +- **Comments to moderate:** same idea via a `moderatedAt` column on comments — + only new/edited comments are screened. +- **Empty worklist → no-op.** Nothing new, nothing runs, near-zero cost. + +### Robustness + +- **Capped per run** (e.g. 100 posts / 200 comments), like `promote-scheduled`'s + `limit(100)`. Leftovers roll to the next run — a backfill can't blow the Lambda + timeout. +- **Per-item try/catch + Sentry** — one bad row never kills the batch (fail-open, + matching `autoReview`). +- **Bedrock gate** — `isBedrockEnabled()` false → moderation falls back to the + `screenContent` heuristic; tagging/quality passes are skipped gracefully. + +### The four passes + +1. **Topic + sentiment tagging** (posts) → writes `post_metadata` + `source='ai'` + edges in `post_topic`, never touching manual edges. Reuses the Bedrock + `InvokeModel` plumbing from `autoReview.ts`. +2. **Re-screen moderation** (posts AND comments) → anything flagged becomes a row + in the existing `reports` queue (see below), surfacing in the moderation UI. +3. **Quality / spam scoring** (posts) → fills `qualityScore` / `qualityReason`. +4. **Daily digest** → counts the day (new users / posts / comments, flags raised, + pending queue) and **emails the founder only when something needs attention**; + otherwise just updates a dashboard widget. No daily noise. + +### AI flags → existing moderation queue (schema change) + +Extend `reports` so AI flags share the one queue the founder already checks: + +``` +reports.source varchar default "user" -- "user" | "system" +reports.reporterId -> make NULLABLE -- system flags have no human reporter +``` + +AI-raised reports render in the existing moderation UI tagged "auto-flagged." +One queue, one place to look. + +--- + +## 4. Personalized feed ranking (last phase) + +Built on transparent, explicit signals first — not an opaque model — so it stays +debuggable for a solo founder. + +### Interest profile — two sources + +1. **Explicit (ship first):** users follow / mute topics. + `user_topic_pref (userId, topicId, pref: follow|mute)`. Reconcile with the + existing `myInterests` / "Your topics" UI. Followed topics boost; muted are + filtered out. Predictable, user-controlled. +2. **Implicit affinity (layer after):** + `user_topic_affinity (userId, topicId, score, updatedAt)`, computed by an + incremental job from existing interactions — `post_votes` (strong), + `bookmarks` (strong), `comments` (medium), views (weak) — mapped through each + post's `post_topic` edges, with time decay so interests stay current. + +### Ranking = transparent weighted blend (server-side) + +``` +score = w1·recency + + w2·baseQuality (votes / existing signals) + + w3·topicAffinity (explicit follows + implicit score) + − muteFilter +``` + +Weights in config so they're tunable. A sum of named terms means "why did this +rank here?" is always answerable. + +### Cold start & safety + +No profile (logged-out / new user) → today's chronological/trending feed, +unchanged. Personalization is purely additive. Everything keys off the +`post_topic` edges from §2 — no new content analysis needed. Per-user ranked feed +cached with a short TTL; affinity recompute is incremental (active users only). + +**Recommendation:** build explicit follow/mute first (most of the value, a +fraction of the complexity), add implicit affinity once topics are flowing. + +--- + +## 5. Phasing + "robust to manage as a solo founder" + +### Phases + +- **Phase 1 (this work): admin shell.** `(admin)` route group, AdminShell, move + pages, centralize auth. Unblocks everything else by giving the cockpit room. +- **Phase 2: AI metadata + nightly cron.** Schema (`post_metadata`, `topic`, + `post_topic`, `comments.moderatedAt`, `reports.source`/nullable reporter), + `/api/cron/daily-review`, EventBridge + Lambda, admin **Content** + **Insights** + views (review AI tags, approve pending topics, see flags/quality). +- **Phase 3: personalization.** Explicit follow/mute → ranked feed; then implicit + affinity. + +### Robustness ideas to fold into the admin cockpit + +- **One moderation queue** — user reports + AI flags together (§3). +- **Daily digest** — the day on one screen; email only when action is needed. +- **Audit log** — record admin actions (bans, deletes, topic approvals) so a solo + founder has a paper trail. +- **Quality/spam surfacing** — sort the Content view by `qualityScore` to find + low-effort content fast; down-rank rather than delete where possible. +- **Pending-topic approval** — keep the topic vocabulary clean with one click. +- **Everything incremental + fail-open** — jobs skip when there's nothing to do and + never block the platform on an AI/infra failure. + +--- + +## Implementation notes (Phase 1) + +- Create `app/(admin)/layout.tsx` (server component): fetch session, gate on + `role === "ADMIN"`, render `AdminShell`. +- Create `components/Admin/AdminShell.tsx` (+ sidebar nav, top bar) reusing tokens. +- `git mv` the five page directories from `app/(app)/admin/` to `app/(admin)/admin/`. +- Strip the per-page `getServerAuthSession` + redirect gate from the moved + `page.tsx` files (now handled by the layout); keep any page-specific data + fetching. +- Verify: `next build` / typecheck, and that `/admin` + each subroute renders + full-width without the public rails. diff --git a/drizzle/0038_ai_content_metadata.sql b/drizzle/0038_ai_content_metadata.sql new file mode 100644 index 000000000..0d0b172e9 --- /dev/null +++ b/drizzle/0038_ai_content_metadata.sql @@ -0,0 +1,85 @@ +CREATE TYPE "public"."report_source" AS ENUM('user', 'system');--> statement-breakpoint +CREATE TYPE "public"."sentiment" AS ENUM('positive', 'neutral', 'negative');--> statement-breakpoint +CREATE TYPE "public"."tag_source" AS ENUM('ai', 'manual');--> statement-breakpoint +CREATE TYPE "public"."topic_status" AS ENUM('active', 'pending');--> statement-breakpoint +CREATE TABLE "post_metadata" ( + "post_id" uuid PRIMARY KEY NOT NULL, + "sentiment" "sentiment", + "sentiment_score" real, + "quality_score" real, + "quality_reason" text, + "model_id" text, + "analyzed_at" timestamp(3) with time zone, + "schema_version" integer DEFAULT 1 NOT NULL +); +--> statement-breakpoint +CREATE TABLE "post_topic" ( + "post_id" uuid NOT NULL, + "topic_id" integer NOT NULL, + "confidence" real, + "source" "tag_source" DEFAULT 'ai' NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "post_topic_post_id_topic_id_pk" PRIMARY KEY("post_id","topic_id") +); +--> statement-breakpoint +CREATE TABLE "topic" ( + "id" serial PRIMARY KEY NOT NULL, + "slug" varchar(60) NOT NULL, + "label" varchar(80) NOT NULL, + "status" "topic_status" DEFAULT 'active' NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "reports" ALTER COLUMN "reporter_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "comments" ADD COLUMN "moderated_at" timestamp(3) with time zone;--> statement-breakpoint +ALTER TABLE "reports" ADD COLUMN "source" "report_source" DEFAULT 'user' NOT NULL;--> statement-breakpoint +ALTER TABLE "post_metadata" ADD CONSTRAINT "post_metadata_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_topic" ADD CONSTRAINT "post_topic_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_topic" ADD CONSTRAINT "post_topic_topic_id_topic_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topic"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "post_metadata_analyzed_at_idx" ON "post_metadata" USING btree ("analyzed_at");--> statement-breakpoint +CREATE INDEX "post_topic_topic_id_idx" ON "post_topic" USING btree ("topic_id");--> statement-breakpoint +CREATE INDEX "post_topic_source_idx" ON "post_topic" USING btree ("source");--> statement-breakpoint +CREATE UNIQUE INDEX "topic_slug_key" ON "topic" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "topic_status_idx" ON "topic" USING btree ("status");--> statement-breakpoint +-- Seed the controlled topic vocabulary (idempotent). The nightly review cron +-- maps AI-suggested topics to these slugs; the model may propose new ones into +-- `status = 'pending'` for admin approval. +INSERT INTO "topic" ("slug", "label", "status") VALUES + ('ai-agents', 'AI Agents', 'active'), + ('rag', 'RAG', 'active'), + ('prompt-engineering', 'Prompt Engineering', 'active'), + ('evals', 'Evals', 'active'), + ('llm-apps', 'LLM Apps', 'active'), + ('fine-tuning', 'Fine-tuning', 'active'), + ('vector-databases', 'Vector Databases', 'active'), + ('ai-coding-tools', 'AI Coding Tools', 'active'), + ('mcp', 'MCP', 'active'), + ('open-models', 'Open Models', 'active'), + ('computer-vision', 'Computer Vision', 'active'), + ('voice-ai', 'Voice AI', 'active'), + ('nextjs', 'Next.js', 'active'), + ('react', 'React', 'active'), + ('typescript', 'TypeScript', 'active'), + ('python', 'Python', 'active'), + ('frontend', 'Frontend', 'active'), + ('backend', 'Backend', 'active'), + ('databases', 'Databases', 'active'), + ('devops', 'DevOps', 'active'), + ('web-performance', 'Web Performance', 'active'), + ('css', 'CSS', 'active'), + ('security', 'Security', 'active'), + ('data-engineering', 'Data Engineering', 'active'), + ('mobile', 'Mobile', 'active'), + ('open-source', 'Open Source', 'active'), + ('indie-hacking', 'Indie Hacking', 'active'), + ('build-in-public', 'Build in Public', 'active'), + ('saas', 'SaaS', 'active'), + ('bootstrapping', 'Bootstrapping', 'active'), + ('fundraising', 'Fundraising', 'active'), + ('growth-marketing', 'Growth & Marketing', 'active'), + ('product', 'Product', 'active'), + ('design', 'Design', 'active'), + ('career', 'Career', 'active'), + ('startups', 'Startups', 'active'), + ('no-code', 'No-code', 'active') +ON CONFLICT ("slug") DO NOTHING; \ No newline at end of file diff --git a/drizzle/meta/0038_snapshot.json b/drizzle/meta/0038_snapshot.json new file mode 100644 index 000000000..e42812c05 --- /dev/null +++ b/drizzle/meta/0038_snapshot.json @@ -0,0 +1,6155 @@ +{ + "id": "4e6b6c33-b5ca-494b-9e68-7b370d344ffc", + "prevId": "53405aa0-451c-400b-b4a2-96d9e08c5833", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": ["provider", "providerAccountId"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.AggregatedArticle": { + "name": "AggregatedArticle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sourceId": { + "name": "sourceId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shortId": { + "name": "shortId", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(350)", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalUrl": { + "name": "externalUrl", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": true + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ogImageUrl": { + "name": "ogImageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sourceAuthor": { + "name": "sourceAuthor", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fetchedAt": { + "name": "fetchedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upvotes": { + "name": "upvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "downvotes": { + "name": "downvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "clickCount": { + "name": "clickCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "aggregated_article_source_idx": { + "name": "aggregated_article_source_idx", + "columns": [ + { + "expression": "sourceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "aggregated_article_slug_idx": { + "name": "aggregated_article_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "aggregated_article_published_idx": { + "name": "aggregated_article_published_idx", + "columns": [ + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "aggregated_article_url_idx": { + "name": "aggregated_article_url_idx", + "columns": [ + { + "expression": "externalUrl", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "AggregatedArticle_sourceId_FeedSource_id_fk": { + "name": "AggregatedArticle_sourceId_FeedSource_id_fk", + "tableFrom": "AggregatedArticle", + "tableTo": "FeedSource", + "columnsFrom": ["sourceId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.AggregatedArticleBookmark": { + "name": "AggregatedArticleBookmark", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "articleId": { + "name": "articleId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "article_bookmark_unique": { + "name": "article_bookmark_unique", + "columns": [ + { + "expression": "articleId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "article_bookmark_user_idx": { + "name": "article_bookmark_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "AggregatedArticleBookmark_articleId_AggregatedArticle_id_fk": { + "name": "AggregatedArticleBookmark_articleId_AggregatedArticle_id_fk", + "tableFrom": "AggregatedArticleBookmark", + "tableTo": "AggregatedArticle", + "columnsFrom": ["articleId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "AggregatedArticleBookmark_userId_user_id_fk": { + "name": "AggregatedArticleBookmark_userId_user_id_fk", + "tableFrom": "AggregatedArticleBookmark", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.AggregatedArticleTag": { + "name": "AggregatedArticleTag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "articleId": { + "name": "articleId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagId": { + "name": "tagId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "article_tag_unique": { + "name": "article_tag_unique", + "columns": [ + { + "expression": "articleId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tagId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "article_tag_article_idx": { + "name": "article_tag_article_idx", + "columns": [ + { + "expression": "articleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "AggregatedArticleTag_articleId_AggregatedArticle_id_fk": { + "name": "AggregatedArticleTag_articleId_AggregatedArticle_id_fk", + "tableFrom": "AggregatedArticleTag", + "tableTo": "AggregatedArticle", + "columnsFrom": ["articleId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "AggregatedArticleTag_tagId_Tag_id_fk": { + "name": "AggregatedArticleTag_tagId_Tag_id_fk", + "tableFrom": "AggregatedArticleTag", + "tableTo": "Tag", + "columnsFrom": ["tagId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.AggregatedArticleVote": { + "name": "AggregatedArticleVote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "articleId": { + "name": "articleId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voteType": { + "name": "voteType", + "type": "VoteType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "article_vote_unique": { + "name": "article_vote_unique", + "columns": [ + { + "expression": "articleId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "article_vote_article_idx": { + "name": "article_vote_article_idx", + "columns": [ + { + "expression": "articleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "article_vote_user_idx": { + "name": "article_vote_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "AggregatedArticleVote_articleId_AggregatedArticle_id_fk": { + "name": "AggregatedArticleVote_articleId_AggregatedArticle_id_fk", + "tableFrom": "AggregatedArticleVote", + "tableTo": "AggregatedArticle", + "columnsFrom": ["articleId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "AggregatedArticleVote_userId_user_id_fk": { + "name": "AggregatedArticleVote_userId_user_id_fk", + "tableFrom": "AggregatedArticleVote", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.badge": { + "name": "badge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "varchar(8)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "badge_key_unique": { + "name": "badge_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.BannedUsers": { + "name": "BannedUsers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bannedById": { + "name": "bannedById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "BannedUsers_userId_key": { + "name": "BannedUsers_userId_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "BannedUsers_userId_user_id_fk": { + "name": "BannedUsers_userId_user_id_fk", + "tableFrom": "BannedUsers", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "BannedUsers_bannedById_user_id_fk": { + "name": "BannedUsers_bannedById_user_id_fk", + "tableFrom": "BannedUsers", + "tableTo": "user", + "columnsFrom": ["bannedById"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "BannedUsers_id_unique": { + "name": "BannedUsers_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Bookmark": { + "name": "Bookmark", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "Bookmark_userId_postId_key": { + "name": "Bookmark_userId_postId_key", + "columns": [ + { + "expression": "postId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Bookmark_postId_Post_id_fk": { + "name": "Bookmark_postId_Post_id_fk", + "tableFrom": "Bookmark", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Bookmark_userId_user_id_fk": { + "name": "Bookmark_userId_user_id_fk", + "tableFrom": "Bookmark", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Bookmark_id_unique": { + "name": "Bookmark_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "bookmarks_user_id_idx": { + "name": "bookmarks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bookmarks_post_id_idx": { + "name": "bookmarks_post_id_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmarks_post_id_posts_id_fk": { + "name": "bookmarks_post_id_posts_id_fk", + "tableFrom": "bookmarks", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarks_user_id_user_id_fk": { + "name": "bookmarks_user_id_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bookmarks_post_id_user_id_key": { + "name": "bookmarks_post_id_user_id_key", + "nullsNotDistinct": false, + "columns": ["post_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Comment": { + "name": "Comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parentId": { + "name": "parentId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "Comment_postId_index": { + "name": "Comment_postId_index", + "columns": [ + { + "expression": "postId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Comment_postId_Post_id_fk": { + "name": "Comment_postId_Post_id_fk", + "tableFrom": "Comment", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Comment_userId_user_id_fk": { + "name": "Comment_userId_user_id_fk", + "tableFrom": "Comment", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Comment_parentId_fkey": { + "name": "Comment_parentId_fkey", + "tableFrom": "Comment", + "tableTo": "Comment", + "columnsFrom": ["parentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Comment_id_unique": { + "name": "Comment_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_votes": { + "name": "comment_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote_type": { + "name": "vote_type", + "type": "vote_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "comment_votes_comment_id_idx": { + "name": "comment_votes_comment_id_idx", + "columns": [ + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_votes_comment_id_comments_id_fk": { + "name": "comment_votes_comment_id_comments_id_fk", + "tableFrom": "comment_votes", + "tableTo": "comments", + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_votes_user_id_user_id_fk": { + "name": "comment_votes_user_id_user_id_fk", + "tableFrom": "comment_votes", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "comment_votes_comment_id_user_id_key": { + "name": "comment_votes_comment_id_user_id_key", + "nullsNotDistinct": false, + "columns": ["comment_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upvotes_count": { + "name": "upvotes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "downvotes_count": { + "name": "downvotes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "moderated_at": { + "name": "moderated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "legacy_comment_id": { + "name": "legacy_comment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "comments_post_id_idx": { + "name": "comments_post_id_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_author_id_idx": { + "name": "comments_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_id_idx": { + "name": "comments_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_created_at_idx": { + "name": "comments_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_legacy_comment_id_idx": { + "name": "comments_legacy_comment_id_idx", + "columns": [ + { + "expression": "legacy_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_post_id_posts_id_fk": { + "name": "comments_post_id_posts_id_fk", + "tableFrom": "comments", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_author_id_user_id_fk": { + "name": "comments_author_id_user_id_fk", + "tableFrom": "comments", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_parent_id_fkey": { + "name": "comments_parent_id_fkey", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Content": { + "name": "Content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "ContentType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "externalUrl": { + "name": "externalUrl", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ogImageUrl": { + "name": "ogImageUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sourceId": { + "name": "sourceId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sourceAuthor": { + "name": "sourceAuthor", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "publishedAt": { + "name": "publishedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "upvotes": { + "name": "upvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "downvotes": { + "name": "downvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "readTimeMins": { + "name": "readTimeMins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "clickCount": { + "name": "clickCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "slug": { + "name": "slug", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false + }, + "canonicalUrl": { + "name": "canonicalUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coverImage": { + "name": "coverImage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "showComments": { + "name": "showComments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "Content_slug_key": { + "name": "Content_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Content_type_index": { + "name": "Content_type_index", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Content_userId_index": { + "name": "Content_userId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Content_sourceId_index": { + "name": "Content_sourceId_index", + "columns": [ + { + "expression": "sourceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Content_publishedAt_index": { + "name": "Content_publishedAt_index", + "columns": [ + { + "expression": "publishedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Content_published_index": { + "name": "Content_published_index", + "columns": [ + { + "expression": "published", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Content_userId_user_id_fk": { + "name": "Content_userId_user_id_fk", + "tableFrom": "Content", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Content_sourceId_FeedSource_id_fk": { + "name": "Content_sourceId_FeedSource_id_fk", + "tableFrom": "Content", + "tableTo": "FeedSource", + "columnsFrom": ["sourceId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ContentBookmark": { + "name": "ContentBookmark", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contentId": { + "name": "contentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ContentBookmark_contentId_Content_id_fk": { + "name": "ContentBookmark_contentId_Content_id_fk", + "tableFrom": "ContentBookmark", + "tableTo": "Content", + "columnsFrom": ["contentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ContentBookmark_userId_user_id_fk": { + "name": "ContentBookmark_userId_user_id_fk", + "tableFrom": "ContentBookmark", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ContentBookmark_contentId_userId_key": { + "name": "ContentBookmark_contentId_userId_key", + "nullsNotDistinct": false, + "columns": ["contentId", "userId"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ContentReport": { + "name": "ContentReport", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contentId": { + "name": "contentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discussionId": { + "name": "discussionId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "postId": { + "name": "postId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reporterId": { + "name": "reporterId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "ReportReason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "ReportStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "reviewedById": { + "name": "reviewedById", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewedAt": { + "name": "reviewedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "actionTaken": { + "name": "actionTaken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ContentReport_status_index": { + "name": "ContentReport_status_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ContentReport_reporterId_index": { + "name": "ContentReport_reporterId_index", + "columns": [ + { + "expression": "reporterId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ContentReport_contentId_index": { + "name": "ContentReport_contentId_index", + "columns": [ + { + "expression": "contentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ContentReport_discussionId_index": { + "name": "ContentReport_discussionId_index", + "columns": [ + { + "expression": "discussionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ContentReport_postId_index": { + "name": "ContentReport_postId_index", + "columns": [ + { + "expression": "postId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ContentReport_contentId_Content_id_fk": { + "name": "ContentReport_contentId_Content_id_fk", + "tableFrom": "ContentReport", + "tableTo": "Content", + "columnsFrom": ["contentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ContentReport_discussionId_Discussion_id_fk": { + "name": "ContentReport_discussionId_Discussion_id_fk", + "tableFrom": "ContentReport", + "tableTo": "Discussion", + "columnsFrom": ["discussionId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ContentReport_postId_posts_id_fk": { + "name": "ContentReport_postId_posts_id_fk", + "tableFrom": "ContentReport", + "tableTo": "posts", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ContentReport_reporterId_user_id_fk": { + "name": "ContentReport_reporterId_user_id_fk", + "tableFrom": "ContentReport", + "tableTo": "user", + "columnsFrom": ["reporterId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ContentReport_reviewedById_user_id_fk": { + "name": "ContentReport_reviewedById_user_id_fk", + "tableFrom": "ContentReport", + "tableTo": "user", + "columnsFrom": ["reviewedById"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ContentTag": { + "name": "ContentTag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contentId": { + "name": "contentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagId": { + "name": "tagId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ContentTag_contentId_Content_id_fk": { + "name": "ContentTag_contentId_Content_id_fk", + "tableFrom": "ContentTag", + "tableTo": "Content", + "columnsFrom": ["contentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ContentTag_tagId_Tag_id_fk": { + "name": "ContentTag_tagId_Tag_id_fk", + "tableFrom": "ContentTag", + "tableTo": "Tag", + "columnsFrom": ["tagId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ContentTag_contentId_tagId_key": { + "name": "ContentTag_contentId_tagId_key", + "nullsNotDistinct": false, + "columns": ["contentId", "tagId"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ContentVote": { + "name": "ContentVote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contentId": { + "name": "contentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voteType": { + "name": "voteType", + "type": "VoteType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ContentVote_contentId_index": { + "name": "ContentVote_contentId_index", + "columns": [ + { + "expression": "contentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ContentVote_contentId_Content_id_fk": { + "name": "ContentVote_contentId_Content_id_fk", + "tableFrom": "ContentVote", + "tableTo": "Content", + "columnsFrom": ["contentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ContentVote_userId_user_id_fk": { + "name": "ContentVote_userId_user_id_fk", + "tableFrom": "ContentVote", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ContentVote_contentId_userId_key": { + "name": "ContentVote_contentId_userId_key", + "nullsNotDistinct": false, + "columns": ["contentId", "userId"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Discussion": { + "name": "Discussion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "contentId": { + "name": "contentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parentId": { + "name": "parentId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "upvotes": { + "name": "upvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "downvotes": { + "name": "downvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "Discussion_contentId_index": { + "name": "Discussion_contentId_index", + "columns": [ + { + "expression": "contentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Discussion_userId_index": { + "name": "Discussion_userId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Discussion_contentId_Content_id_fk": { + "name": "Discussion_contentId_Content_id_fk", + "tableFrom": "Discussion", + "tableTo": "Content", + "columnsFrom": ["contentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Discussion_userId_user_id_fk": { + "name": "Discussion_userId_user_id_fk", + "tableFrom": "Discussion", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Discussion_parentId_fkey": { + "name": "Discussion_parentId_fkey", + "tableFrom": "Discussion", + "tableTo": "Discussion", + "columnsFrom": ["parentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Discussion_id_unique": { + "name": "Discussion_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.DiscussionVote": { + "name": "DiscussionVote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "discussionId": { + "name": "discussionId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voteType": { + "name": "voteType", + "type": "VoteType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "DiscussionVote_discussionId_index": { + "name": "DiscussionVote_discussionId_index", + "columns": [ + { + "expression": "discussionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "DiscussionVote_discussionId_Discussion_id_fk": { + "name": "DiscussionVote_discussionId_Discussion_id_fk", + "tableFrom": "DiscussionVote", + "tableTo": "Discussion", + "columnsFrom": ["discussionId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "DiscussionVote_userId_user_id_fk": { + "name": "DiscussionVote_userId_user_id_fk", + "tableFrom": "DiscussionVote", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "DiscussionVote_discussionId_userId_key": { + "name": "DiscussionVote_discussionId_userId_key", + "nullsNotDistinct": false, + "columns": ["discussionId", "userId"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.EmailChangeHistory": { + "name": "EmailChangeHistory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oldEmail": { + "name": "oldEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "newEmail": { + "name": "newEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changedAt": { + "name": "changedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "EmailChangeHistory_userId_user_id_fk": { + "name": "EmailChangeHistory_userId_user_id_fk", + "tableFrom": "EmailChangeHistory", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.EmailChangeRequest": { + "name": "EmailChangeRequest", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "newEmail": { + "name": "newEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "EmailChangeRequest_userId_user_id_fk": { + "name": "EmailChangeRequest_userId_user_id_fk", + "tableFrom": "EmailChangeRequest", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "EmailChangeRequest_token_unique": { + "name": "EmailChangeRequest_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_sources": { + "name": "feed_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "feed_source_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "error_count": { + "name": "error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "feed_sources_url_key": { + "name": "feed_sources_url_key", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_sources_slug_key": { + "name": "feed_sources_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feed_sources_user_id_idx": { + "name": "feed_sources_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_sources_user_id_user_id_fk": { + "name": "feed_sources_user_id_user_id_fk", + "tableFrom": "feed_sources", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.FeedSource": { + "name": "FeedSource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "websiteUrl": { + "name": "websiteUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "FeedSourceStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "lastSuccessAt": { + "name": "lastSuccessAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "errorCount": { + "name": "errorCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "FeedSource_url_key": { + "name": "FeedSource_url_key", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "FeedSource_slug_key": { + "name": "FeedSource_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "FeedSource_status_index": { + "name": "FeedSource_status_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "FeedSource_id_unique": { + "name": "FeedSource_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Flagged": { + "name": "Flagged", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notifierId": { + "name": "notifierId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commentId": { + "name": "commentId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Flagged_userId_user_id_fk": { + "name": "Flagged_userId_user_id_fk", + "tableFrom": "Flagged", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Flagged_notifierId_user_id_fk": { + "name": "Flagged_notifierId_user_id_fk", + "tableFrom": "Flagged", + "tableTo": "user", + "columnsFrom": ["notifierId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Flagged_postId_Post_id_fk": { + "name": "Flagged_postId_Post_id_fk", + "tableFrom": "Flagged", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Flagged_commentId_Comment_id_fk": { + "name": "Flagged_commentId_Comment_id_fk", + "tableFrom": "Flagged", + "tableTo": "Comment", + "columnsFrom": ["commentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Flagged_id_unique": { + "name": "Flagged_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follow": { + "name": "follow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "follow_pair_idx": { + "name": "follow_pair_idx", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "follow_follower_idx": { + "name": "follow_follower_idx", + "columns": [ + { + "expression": "follower_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "follow_following_idx": { + "name": "follow_following_idx", + "columns": [ + { + "expression": "following_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "follow_follower_id_user_id_fk": { + "name": "follow_follower_id_user_id_fk", + "tableFrom": "follow", + "tableTo": "user", + "columnsFrom": ["follower_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follow_following_id_user_id_fk": { + "name": "follow_following_id_user_id_fk", + "tableFrom": "follow", + "tableTo": "user", + "columnsFrom": ["following_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job": { + "name": "job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "company_logo": { + "name": "company_logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true + }, + "job_description": { + "name": "job_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_location": { + "name": "job_location", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "application_url": { + "name": "application_url", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "remote": { + "name": "remote", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "relocation": { + "name": "relocation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "visa_sponsorship": { + "name": "visa_sponsorship", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "ai_native": { + "name": "ai_native", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'EUR'" + }, + "payment_provider": { + "name": "payment_provider", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false + }, + "payment_ref": { + "name": "payment_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by_id": { + "name": "approved_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "job_slug_idx": { + "name": "job_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_status_idx": { + "name": "job_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_featured_idx": { + "name": "job_featured_idx", + "columns": [ + { + "expression": "featured", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_type_idx": { + "name": "job_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_user_id_idx": { + "name": "job_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_published_at_idx": { + "name": "job_published_at_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_expires_at_idx": { + "name": "job_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_user_id_user_id_fk": { + "name": "job_user_id_user_id_fk", + "tableFrom": "job", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_approved_by_id_user_id_fk": { + "name": "job_approved_by_id_user_id_fk", + "tableFrom": "job", + "tableTo": "user", + "columnsFrom": ["approved_by_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Like": { + "name": "Like", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commentId": { + "name": "commentId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "Like_userId_commentId_key": { + "name": "Like_userId_commentId_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "commentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Like_userId_postId_key": { + "name": "Like_userId_postId_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "postId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Like_userId_user_id_fk": { + "name": "Like_userId_user_id_fk", + "tableFrom": "Like", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Like_postId_Post_id_fk": { + "name": "Like_postId_Post_id_fk", + "tableFrom": "Like", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Like_commentId_Comment_id_fk": { + "name": "Like_commentId_Comment_id_fk", + "tableFrom": "Like", + "tableTo": "Comment", + "columnsFrom": ["commentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Like_id_unique": { + "name": "Like_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Notification": { + "name": "Notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "type": { + "name": "type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "postId": { + "name": "postId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "commentId": { + "name": "commentId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notifierId": { + "name": "notifierId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "Notification_userId_index": { + "name": "Notification_userId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Notification_userId_user_id_fk": { + "name": "Notification_userId_user_id_fk", + "tableFrom": "Notification", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notification_postId_posts_id_fk": { + "name": "Notification_postId_posts_id_fk", + "tableFrom": "Notification", + "tableTo": "posts", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notification_commentId_comments_id_fk": { + "name": "Notification_commentId_comments_id_fk", + "tableFrom": "Notification", + "tableTo": "comments", + "columnsFrom": ["commentId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notification_notifierId_user_id_fk": { + "name": "Notification_notifierId_user_id_fk", + "tableFrom": "Notification", + "tableTo": "user", + "columnsFrom": ["notifierId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Notification_id_unique": { + "name": "Notification_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.point_event": { + "name": "point_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "point_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "point_event_user_idx": { + "name": "point_event_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "point_event_user_created_idx": { + "name": "point_event_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "point_event_created_idx": { + "name": "point_event_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_event_user_id_user_id_fk": { + "name": "point_event_user_id_user_id_fk", + "tableFrom": "point_event", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "point_event_actor_id_user_id_fk": { + "name": "point_event_actor_id_user_id_fk", + "tableFrom": "point_event", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "point_event_dedupe_idx": { + "name": "point_event_dedupe_idx", + "nullsNotDistinct": true, + "columns": ["user_id", "action", "source_id", "actor_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Post": { + "name": "Post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonicalUrl": { + "name": "canonicalUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coverImage": { + "name": "coverImage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "varchar(156)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "readTimeMins": { + "name": "readTimeMins", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "published": { + "name": "published", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "showComments": { + "name": "showComments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "likes": { + "name": "likes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "upvotes": { + "name": "upvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "downvotes": { + "name": "downvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "Post_id_key": { + "name": "Post_id_key", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Post_slug_key": { + "name": "Post_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Post_slug_index": { + "name": "Post_slug_index", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Post_userId_index": { + "name": "Post_userId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Post_userId_user_id_fk": { + "name": "Post_userId_user_id_fk", + "tableFrom": "Post", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Post_id_unique": { + "name": "Post_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tags": { + "name": "post_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "post_tags_post_id_idx": { + "name": "post_tags_post_id_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_tags_tag_id_idx": { + "name": "post_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_tags_post_id_posts_id_fk": { + "name": "post_tags_post_id_posts_id_fk", + "tableFrom": "post_tags", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tags_tag_id_Tag_id_fk": { + "name": "post_tags_tag_id_Tag_id_fk", + "tableFrom": "post_tags", + "tableTo": "Tag", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_tags_post_id_tag_id_key": { + "name": "post_tags_post_id_tag_id_key", + "nullsNotDistinct": false, + "columns": ["post_id", "tag_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_votes": { + "name": "post_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote_type": { + "name": "vote_type", + "type": "vote_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "post_votes_post_id_idx": { + "name": "post_votes_post_id_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_votes_post_id_posts_id_fk": { + "name": "post_votes_post_id_posts_id_fk", + "tableFrom": "post_votes", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_votes_user_id_user_id_fk": { + "name": "post_votes_user_id_user_id_fk", + "tableFrom": "post_votes", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_votes_post_id_user_id_key": { + "name": "post_votes_post_id_user_id_key", + "nullsNotDistinct": false, + "columns": ["post_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_follow": { + "name": "post_follow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "post_follow_pair_idx": { + "name": "post_follow_pair_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_follow_post_idx": { + "name": "post_follow_post_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_follow_user_id_user_id_fk": { + "name": "post_follow_user_id_user_id_fk", + "tableFrom": "post_follow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_follow_post_id_posts_id_fk": { + "name": "post_follow_post_id_posts_id_fk", + "tableFrom": "post_follow", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_metadata": { + "name": "post_metadata", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "sentiment": { + "name": "sentiment", + "type": "sentiment", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "sentiment_score": { + "name": "sentiment_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "quality_score": { + "name": "quality_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "quality_reason": { + "name": "quality_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analyzed_at": { + "name": "analyzed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": { + "post_metadata_analyzed_at_idx": { + "name": "post_metadata_analyzed_at_idx", + "columns": [ + { + "expression": "analyzed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_metadata_post_id_posts_id_fk": { + "name": "post_metadata_post_id_posts_id_fk", + "tableFrom": "post_metadata", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.PostTag": { + "name": "PostTag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "tagId": { + "name": "tagId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "PostTag_tagId_postId_key": { + "name": "PostTag_tagId_postId_key", + "columns": [ + { + "expression": "tagId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "postId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "PostTag_tagId_Tag_id_fk": { + "name": "PostTag_tagId_Tag_id_fk", + "tableFrom": "PostTag", + "tableTo": "Tag", + "columnsFrom": ["tagId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "PostTag_postId_Post_id_fk": { + "name": "PostTag_postId_Post_id_fk", + "tableFrom": "PostTag", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_topic": { + "name": "post_topic", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "tag_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ai'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "post_topic_topic_id_idx": { + "name": "post_topic_topic_id_idx", + "columns": [ + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_topic_source_idx": { + "name": "post_topic_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_topic_post_id_posts_id_fk": { + "name": "post_topic_post_id_posts_id_fk", + "tableFrom": "post_topic", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_topic_topic_id_topic_id_fk": { + "name": "post_topic_topic_id_topic_id_fk", + "tableFrom": "post_topic", + "tableTo": "topic", + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_topic_post_id_topic_id_pk": { + "name": "post_topic_post_id_topic_id_pk", + "columns": ["post_id", "topic_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.PostVote": { + "name": "PostVote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "postId": { + "name": "postId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voteType": { + "name": "voteType", + "type": "VoteType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "PostVote_postId_index": { + "name": "PostVote_postId_index", + "columns": [ + { + "expression": "postId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "PostVote_postId_Post_id_fk": { + "name": "PostVote_postId_Post_id_fk", + "tableFrom": "PostVote", + "tableTo": "Post", + "columnsFrom": ["postId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "PostVote_userId_user_id_fk": { + "name": "PostVote_userId_user_id_fk", + "tableFrom": "PostVote", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "PostVote_postId_userId_key": { + "name": "PostVote_postId_userId_key", + "nullsNotDistinct": false, + "columns": ["postId", "userId"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true + }, + "url_id": { + "name": "url_id", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "externalUrlNormalized": { + "name": "externalUrlNormalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderationNote": { + "name": "moderationNote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_id": { + "name": "source_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source_author": { + "name": "source_author", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "reading_time": { + "name": "reading_time", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "upvotes_count": { + "name": "upvotes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "downvotes_count": { + "name": "downvotes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "comments_count": { + "name": "comments_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "views_count": { + "name": "views_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pinned_until": { + "name": "pinned_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "show_comments": { + "name": "show_comments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "legacy_post_id": { + "name": "legacy_post_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "posts_author_id_idx": { + "name": "posts_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_slug_idx": { + "name": "posts_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_url_id_key": { + "name": "posts_url_id_key", + "columns": [ + { + "expression": "url_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_legacy_post_id_idx": { + "name": "posts_legacy_post_id_idx", + "columns": [ + { + "expression": "legacy_post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_status_idx": { + "name": "posts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_published_at_idx": { + "name": "posts_published_at_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_type_idx": { + "name": "posts_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_source_id_idx": { + "name": "posts_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_featured_idx": { + "name": "posts_featured_idx", + "columns": [ + { + "expression": "featured", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_external_url_normalized_idx": { + "name": "posts_external_url_normalized_idx", + "columns": [ + { + "expression": "externalUrlNormalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_author_id_user_id_fk": { + "name": "posts_author_id_user_id_fk", + "tableFrom": "posts", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_source_id_feed_sources_id_fk": { + "name": "posts_source_id_feed_sources_id_fk", + "tableFrom": "posts", + "tableTo": "feed_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publication_follow": { + "name": "publication_follow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "publication_follow_pair_idx": { + "name": "publication_follow_pair_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publication_follow_source_idx": { + "name": "publication_follow_source_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publication_follow_user_id_user_id_fk": { + "name": "publication_follow_user_id_user_id_fk", + "tableFrom": "publication_follow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publication_follow_source_id_feed_sources_id_fk": { + "name": "publication_follow_source_id_feed_sources_id_fk", + "tableFrom": "publication_follow", + "tableTo": "feed_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "comment_id": { + "name": "comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "report_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "reason": { + "name": "reason", + "type": "report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by_id": { + "name": "reviewed_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "action_taken": { + "name": "action_taken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_reporter_id_idx": { + "name": "reports_reporter_id_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_post_id_idx": { + "name": "reports_post_id_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_comment_id_idx": { + "name": "reports_comment_id_idx", + "columns": [ + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_post_id_posts_id_fk": { + "name": "reports_post_id_posts_id_fk", + "tableFrom": "reports", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_comment_id_comments_id_fk": { + "name": "reports_comment_id_comments_id_fk", + "tableFrom": "reports", + "tableTo": "comments", + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_reporter_id_user_id_fk": { + "name": "reports_reporter_id_user_id_fk", + "tableFrom": "reports", + "tableTo": "user", + "columnsFrom": ["reporter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_reviewed_by_id_user_id_fk": { + "name": "reports_reviewed_by_id_user_id_fk", + "tableFrom": "reports", + "tableTo": "user", + "columnsFrom": ["reviewed_by_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.SponsorInquiry": { + "name": "SponsorInquiry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "company": { + "name": "company", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "interests": { + "name": "interests", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "budgetRange": { + "name": "budgetRange", + "type": "SponsorBudgetRange", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'EXPLORING'" + }, + "goals": { + "name": "goals", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "SponsorInquiryStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "SponsorInquiry_status_index": { + "name": "SponsorInquiry_status_index", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "SponsorInquiry_email_index": { + "name": "SponsorInquiry_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Tag": { + "name": "Tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "post_count": { + "name": "post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "Tag_title_key": { + "name": "Tag_title_key", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Tag_slug_key": { + "name": "Tag_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Tag_id_unique": { + "name": "Tag_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag_merge_suggestions": { + "name": "tag_merge_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_tag_id": { + "name": "source_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_tag_id": { + "name": "target_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "similarity_score": { + "name": "similarity_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "tag_merge_suggestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by_id": { + "name": "reviewed_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "tag_merge_suggestions_source_tag_idx": { + "name": "tag_merge_suggestions_source_tag_idx", + "columns": [ + { + "expression": "source_tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_merge_suggestions_target_tag_idx": { + "name": "tag_merge_suggestions_target_tag_idx", + "columns": [ + { + "expression": "target_tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_merge_suggestions_status_idx": { + "name": "tag_merge_suggestions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tag_merge_suggestions_source_tag_id_Tag_id_fk": { + "name": "tag_merge_suggestions_source_tag_id_Tag_id_fk", + "tableFrom": "tag_merge_suggestions", + "tableTo": "Tag", + "columnsFrom": ["source_tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tag_merge_suggestions_target_tag_id_Tag_id_fk": { + "name": "tag_merge_suggestions_target_tag_id_Tag_id_fk", + "tableFrom": "tag_merge_suggestions", + "tableTo": "Tag", + "columnsFrom": ["target_tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tag_merge_suggestions_reviewed_by_id_user_id_fk": { + "name": "tag_merge_suggestions_reviewed_by_id_user_id_fk", + "tableFrom": "tag_merge_suggestions", + "tableTo": "user", + "columnsFrom": ["reviewed_by_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tag_merge_suggestions_source_target_key": { + "name": "tag_merge_suggestions_source_target_key", + "nullsNotDistinct": false, + "columns": ["source_tag_id", "target_tag_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topic": { + "name": "topic", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "topic_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "topic_slug_key": { + "name": "topic_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topic_status_idx": { + "name": "topic_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'/images/person.png'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "bio": { + "name": "bio", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "websiteUrl": { + "name": "websiteUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "emailNotifications": { + "name": "emailNotifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "newsletter": { + "name": "newsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dateOfBirth": { + "name": "dateOfBirth", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "professionalOrStudent": { + "name": "professionalOrStudent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workplace": { + "name": "workplace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jobTitle": { + "name": "jobTitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "levelOfStudy": { + "name": "levelOfStudy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "course": { + "name": "course", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "Role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "referral_code": { + "name": "referral_code", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "topics": { + "name": "topics", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "experience_level": { + "name": "experience_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "User_username_key": { + "name": "User_username_key", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_username_lower_key": { + "name": "user_username_lower_key", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "User_email_key": { + "name": "User_email_key", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "User_username_id_idx": { + "name": "User_username_id_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "User_username_index": { + "name": "User_username_index", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "User_referral_code_key": { + "name": "User_referral_code_key", + "columns": [ + { + "expression": "referral_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "User_invited_by_idx": { + "name": "User_invited_by_idx", + "columns": [ + { + "expression": "invited_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_badge": { + "name": "user_badge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "badge_id": { + "name": "badge_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "awarded_at": { + "name": "awarded_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "celebrated_at": { + "name": "celebrated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_badge_user_badge_idx": { + "name": "user_badge_user_badge_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "badge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_badge_user_idx": { + "name": "user_badge_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_badge_user_id_user_id_fk": { + "name": "user_badge_user_id_user_id_fk", + "tableFrom": "user_badge", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_badge_badge_id_badge_id_fk": { + "name": "user_badge_badge_id_badge_id_fk", + "tableFrom": "user_badge", + "tableTo": "badge", + "columnsFrom": ["badge_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_streak": { + "name": "user_streak", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "current_streak": { + "name": "current_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "longest_streak": { + "name": "longest_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active_on": { + "name": "last_active_on", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "freezes_available": { + "name": "freezes_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_streak_user_id_user_id_fk": { + "name": "user_streak_user_id_user_id_fk", + "tableFrom": "user_streak", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.feed_source_status": { + "name": "feed_source_status", + "schema": "public", + "values": ["active", "paused", "error"] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "draft", + "pending_payment", + "pending", + "active", + "expired", + "rejected" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": ["full-time", "part-time", "freelancer", "other"] + }, + "public.ContentType": { + "name": "ContentType", + "schema": "public", + "values": ["POST", "LINK", "QUESTION", "VIDEO", "DISCUSSION"] + }, + "public.FeedSourceStatus": { + "name": "FeedSourceStatus", + "schema": "public", + "values": ["ACTIVE", "PAUSED", "ERROR"] + }, + "public.ReportReason": { + "name": "ReportReason", + "schema": "public", + "values": [ + "SPAM", + "HARASSMENT", + "HATE_SPEECH", + "MISINFORMATION", + "COPYRIGHT", + "NSFW", + "OFF_TOPIC", + "OTHER" + ] + }, + "public.ReportStatus": { + "name": "ReportStatus", + "schema": "public", + "values": ["PENDING", "REVIEWED", "DISMISSED", "ACTIONED"] + }, + "public.VoteType": { + "name": "VoteType", + "schema": "public", + "values": ["UP", "DOWN"] + }, + "public.point_action": { + "name": "point_action", + "schema": "public", + "values": [ + "post_published", + "comment_created", + "upvote_received", + "daily_active", + "shipped", + "referral" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "published", + "scheduled", + "unlisted", + "in_review", + "rejected" + ] + }, + "public.post_type": { + "name": "post_type", + "schema": "public", + "values": ["article", "discussion", "link", "resource", "til", "question"] + }, + "public.report_reason": { + "name": "report_reason", + "schema": "public", + "values": [ + "spam", + "harassment", + "hate_speech", + "misinformation", + "copyright", + "nsfw", + "off_topic", + "other" + ] + }, + "public.report_source": { + "name": "report_source", + "schema": "public", + "values": ["user", "system"] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": ["pending", "reviewed", "dismissed", "actioned"] + }, + "public.Role": { + "name": "Role", + "schema": "public", + "values": ["MODERATOR", "ADMIN", "USER"] + }, + "public.sentiment": { + "name": "sentiment", + "schema": "public", + "values": ["positive", "neutral", "negative"] + }, + "public.SponsorBudgetRange": { + "name": "SponsorBudgetRange", + "schema": "public", + "values": [ + "EXPLORING", + "UNDER_500", + "BETWEEN_500_2000", + "BETWEEN_2000_5000", + "OVER_5000" + ] + }, + "public.SponsorInquiryStatus": { + "name": "SponsorInquiryStatus", + "schema": "public", + "values": ["PENDING", "CONTACTED", "CONVERTED", "CLOSED"] + }, + "public.tag_merge_suggestion_status": { + "name": "tag_merge_suggestion_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.tag_source": { + "name": "tag_source", + "schema": "public", + "values": ["ai", "manual"] + }, + "public.topic_status": { + "name": "topic_status", + "schema": "public", + "values": ["active", "pending"] + }, + "public.vote_type": { + "name": "vote_type", + "schema": "public", + "values": ["up", "down"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4941ec145..00730e29a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -267,6 +267,13 @@ "when": 1781296891023, "tag": "0037_backfill_onboarding_badge", "breakpoints": true + }, + { + "idx": 38, + "version": "7", + "when": 1781464677918, + "tag": "0038_ai_content_metadata", + "breakpoints": true } ] } diff --git a/server/db/schema.ts b/server/db/schema.ts index eeecc36e2..18d35c308 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -14,6 +14,7 @@ import { varchar, unique, uuid, + real, } from "drizzle-orm/pg-core"; import { relations, sql } from "drizzle-orm"; @@ -67,6 +68,21 @@ export const reportStatus = pgEnum("report_status", [ "dismissed", "actioned", ]); +// Who raised a report: a human ("user") or the automated review cron ("system"). +export const reportSource = pgEnum("report_source", ["user", "system"]); + +// AI content pipeline (see docs/plans/2026-06-14-admin-shell-and-ai-content-design.md) +export const sentiment = pgEnum("sentiment", [ + "positive", + "neutral", + "negative", +]); +// Curated topic vocabulary lifecycle: active (usable) or pending (model-proposed, +// awaiting admin approval). +export const topicStatus = pgEnum("topic_status", ["active", "pending"]); +// Provenance of a post<->topic edge. The nightly cron only ever rewrites its own +// `ai` edges; `manual` edges (set by an admin) are never touched. +export const tagSource = pgEnum("tag_source", ["ai", "manual"]); // Job board export const jobType = pgEnum("job_type", [ @@ -430,6 +446,8 @@ export const postsRelations = relations(posts, ({ one, many }) => ({ bookmarks: many(bookmarks), tags: many(post_tags), reports: many(reports), + metadata: one(post_metadata), + aiTopics: many(post_topic), })); // COMMENTS TABLE @@ -479,6 +497,14 @@ export const comments = pgTable( withTimezone: true, }), // Soft delete for "[deleted]" placeholders + // Nightly auto-moderation watermark: last time the review cron screened this + // comment. NULL or < updatedAt => the comment re-enters the moderation pass. + moderatedAt: timestamp("moderated_at", { + precision: 3, + mode: "string", + withTimezone: true, + }), + // Migration tracking: references legacy Comment.id legacyCommentId: integer("legacy_comment_id"), }, @@ -659,9 +685,12 @@ export const reports = pgTable( commentId: uuid("comment_id").references(() => comments.id, { onDelete: "cascade", }), - reporterId: text("reporter_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), + // Nullable: system (auto-flagged) reports have no human reporter. + reporterId: text("reporter_id").references(() => user.id, { + onDelete: "cascade", + }), + // "user" = human report, "system" = raised by the nightly review cron. + source: reportSource("source").default("user").notNull(), reason: reportReason("reason").notNull(), details: text("details"), status: reportStatus("status").default("pending").notNull(), @@ -708,6 +737,106 @@ export const reportsRelations = relations(reports, ({ one }) => ({ }), })); +// AI CONTENT METADATA (nightly review cron — Phase 2) +// See docs/plans/2026-06-14-admin-shell-and-ai-content-design.md + +// Per-post signal envelope, 1:1 with posts. Separate table (not columns on +// posts) keeps the hot posts row lean and lets the cron write without bumping +// posts.updatedAt. `analyzedAt` IS the incremental watermark. +export const post_metadata = pgTable( + "post_metadata", + { + postId: uuid("post_id") + .primaryKey() + .references(() => posts.id, { onDelete: "cascade" }), + sentiment: sentiment("sentiment"), + sentimentScore: real("sentiment_score"), + qualityScore: real("quality_score"), + qualityReason: text("quality_reason"), + // The Bedrock model that produced this row; NULL means a human set it (so + // the cron skips overwriting manually-curated values). + modelId: text("model_id"), + analyzedAt: timestamp("analyzed_at", { + precision: 3, + mode: "string", + withTimezone: true, + }), + // Bump in code to force re-analysis of every post on the next run. + schemaVersion: integer("schema_version").default(1).notNull(), + }, + (table) => ({ + analyzedAtIdx: index("post_metadata_analyzed_at_idx").on(table.analyzedAt), + }), +); + +export const postMetadataRelations = relations(post_metadata, ({ one }) => ({ + post: one(posts, { + fields: [post_metadata.postId], + references: [posts.id], + }), +})); + +// Controlled topic vocabulary so AI + manual tags share one clean namespace and +// LLM free-text ("RAG"/"rag"/"retrieval-augmented") can't drift. +export const topic = pgTable( + "topic", + { + id: serial("id").primaryKey().notNull(), + slug: varchar("slug", { length: 60 }).notNull(), + label: varchar("label", { length: 80 }).notNull(), + status: topicStatus("status").default("active").notNull(), + createdAt: timestamp("created_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + slugKey: uniqueIndex("topic_slug_key").on(table.slug), + statusIdx: index("topic_status_idx").on(table.status), + }), +); + +export const topicRelations = relations(topic, ({ many }) => ({ + posts: many(post_topic), +})); + +// Normalized post<->topic edges that power personalized ranking. `source` +// distinguishes AI suggestions from admin-curated tags; the cron only rewrites +// `ai` edges. +export const post_topic = pgTable( + "post_topic", + { + postId: uuid("post_id") + .notNull() + .references(() => posts.id, { onDelete: "cascade" }), + topicId: integer("topic_id") + .notNull() + .references(() => topic.id, { onDelete: "cascade" }), + confidence: real("confidence"), // nullable: manual tags have none + source: tagSource("source").default("ai").notNull(), + createdAt: timestamp("created_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.postId, table.topicId] }), + topicIdIdx: index("post_topic_topic_id_idx").on(table.topicId), + sourceIdx: index("post_topic_source_idx").on(table.source), + }), +); + +export const postTopicRelations = relations(post_topic, ({ one }) => ({ + post: one(posts, { fields: [post_topic.postId], references: [posts.id] }), + topic: one(topic, { fields: [post_topic.topicId], references: [topic.id] }), +})); + // TAGS (shared between legacy and new system) export const tag = pgTable( diff --git a/server/lib/contentAnalysis.test.ts b/server/lib/contentAnalysis.test.ts new file mode 100644 index 000000000..991589b3b --- /dev/null +++ b/server/lib/contentAnalysis.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { + parseAnalysis, + analyzePost, + type TopicVocabEntry, +} from "./contentAnalysis"; + +const VOCAB: TopicVocabEntry[] = [ + { slug: "rag", label: "RAG" }, + { slug: "ai-agents", label: "AI Agents" }, + { slug: "nextjs", label: "Next.js" }, +]; + +const input = { title: "t", body: "b" }; + +describe("parseAnalysis", () => { + it("parses a full valid analysis and keeps only vocab topics", () => { + const a = parseAnalysis( + JSON.stringify({ + topics: [ + { slug: "rag", confidence: 0.9 }, + { slug: "not-a-real-topic", confidence: 0.8 }, + ], + proposedTopics: ["llmops"], + sentiment: "positive", + sentimentScore: 0.7, + qualityScore: 0.85, + qualityReason: "substantial", + moderation: { verdict: "allow", category: "none", reason: "" }, + }), + VOCAB, + input, + ); + expect(a.topics).toEqual([{ slug: "rag", confidence: 0.9 }]); + expect(a.proposedTopics).toEqual(["llmops"]); + expect(a.sentiment).toBe("positive"); + expect(a.qualityScore).toBe(0.85); + expect(a.moderation.verdict).toBe("allow"); + }); + + it("drops a proposed topic that already exists in the vocab", () => { + const a = parseAnalysis( + JSON.stringify({ topics: [], proposedTopics: ["rag", "newthing"] }), + VOCAB, + input, + ); + expect(a.proposedTopics).toEqual(["newthing"]); + }); + + it("clamps out-of-range scores", () => { + const a = parseAnalysis( + JSON.stringify({ sentimentScore: 5, qualityScore: -2 }), + VOCAB, + input, + ); + expect(a.sentimentScore).toBe(1); + expect(a.qualityScore).toBe(0); + }); + + it("extracts JSON embedded in surrounding prose", () => { + const a = parseAnalysis( + 'Here: {"moderation":{"verdict":"review","category":"nsfw","reason":"x"}} ok', + VOCAB, + input, + ); + expect(a.moderation.verdict).toBe("review"); + expect(a.moderation.category).toBe("nsfw"); + }); + + it("falls back to the heuristic on unparseable garbage (fail-open allow on clean text)", () => { + const a = parseAnalysis("not json at all", VOCAB, { + title: "Shipping my AI side project", + body: "A clean writeup about what I built and learned.", + }); + expect(a.topics).toEqual([]); + expect(a.moderation.verdict).toBe("allow"); + }); + + it("treats an unknown moderation verdict as review (fail-safe)", () => { + const a = parseAnalysis( + JSON.stringify({ moderation: { verdict: "banana" } }), + VOCAB, + input, + ); + expect(a.moderation.verdict).toBe("review"); + }); +}); + +describe("analyzePost (disabled path)", () => { + it("returns heuristic-only analysis with no AI signals when Bedrock is disabled", async () => { + // Under Vitest isBedrockEnabled() is forced false (NODE_ENV==="test"). + delete process.env.BEDROCK_MODEL_ID; + delete process.env.ACCESS_KEY; + + const a = await analyzePost( + { + type: "article", + title: "Shipping my first AI side project", + body: "I built a small tool this weekend and shared what I learned.", + }, + VOCAB, + ); + + expect(a.topics).toEqual([]); + expect(a.sentiment).toBeNull(); + expect(a.qualityScore).toBeNull(); + expect(a.moderation.verdict).toBe("allow"); + }); +}); diff --git a/server/lib/contentAnalysis.ts b/server/lib/contentAnalysis.ts new file mode 100644 index 000000000..72f86a80a --- /dev/null +++ b/server/lib/contentAnalysis.ts @@ -0,0 +1,231 @@ +import { InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"; +import * as Sentry from "@sentry/nextjs"; +import { bedrockClient, isBedrockEnabled } from "@/server/lib/bedrock"; +import { screenContent } from "@/server/lib/moderation"; +import { fetchPageText } from "@/server/lib/fetchPage"; + +// Bump to force the nightly cron to re-analyse every post (compared against +// post_metadata.schemaVersion). Increment when this prompt or the output shape +// changes in a way that should invalidate existing rows. +export const ANALYSIS_SCHEMA_VERSION = 1; + +export type Sentiment = "positive" | "neutral" | "negative"; + +export interface TopicSuggestion { + /** A slug from the supplied controlled vocabulary. */ + slug: string; + /** 0..1 model confidence. */ + confidence: number; +} + +export interface ContentAnalysis { + /** Topics resolved against the controlled vocabulary (slugs only). */ + topics: TopicSuggestion[]; + /** New topic slugs the model proposes that weren't in the vocabulary. */ + proposedTopics: string[]; + sentiment: Sentiment | null; + /** -1..1; negative .. positive. */ + sentimentScore: number | null; + /** 0..1; higher = higher quality / lower spam risk. */ + qualityScore: number | null; + qualityReason: string; + /** Moderation verdict, mirroring autoReview's shape. */ + moderation: { verdict: "allow" | "review"; category: string; reason: string }; +} + +export interface AnalyzePostInput { + type?: string | null; + title?: string | null; + body?: string | null; + externalUrl?: string | null; +} + +export interface TopicVocabEntry { + slug: string; + label: string; +} + +const MAX_BODY_CHARS = 6000; +const MAX_TOPICS = 4; + +function buildSystemPrompt(vocab: TopicVocabEntry[]): string { + const list = vocab.map((t) => `${t.slug} (${t.label})`).join(", "); + return `You analyse posts for Codú, a community for AI builders and indie hackers. Return a single JSON object describing the post. No prose, JSON only. + +Do FOUR things: +1. TOPICS: pick up to ${MAX_TOPICS} of the most relevant topics. You MUST choose slugs from this controlled list: [${list}]. Only include a topic if it genuinely fits. If the post is clearly about an important topic that is missing from the list, add its kebab-case slug to "proposedTopics" (do NOT put proposed topics in "topics"). +2. SENTIMENT: the overall tone toward its subject — "positive", "neutral" or "negative" — and a sentimentScore from -1 (very negative) to 1 (very positive). +3. QUALITY: a qualityScore from 0 (spam / zero-effort / link-only with no substance) to 1 (substantial, useful, well-formed), with a one-sentence qualityReason. +4. MODERATION: be LOOSE and FAIR — allow by default, including people sharing their OWN projects/launches. Only "review" the clearly bad: pornographic/NSFW, crypto/token shilling, scams/phishing, malicious or fabricated links, or content plainly off-theme for a developer/AI-builder community. When in doubt, allow. + +Reply with ONLY this JSON shape: +{"topics":[{"slug":string,"confidence":number}],"proposedTopics":[string],"sentiment":"positive"|"neutral"|"negative","sentimentScore":number,"qualityScore":number,"qualityReason":string,"moderation":{"verdict":"allow"|"review","category":string,"reason":string}} +Use moderation category "none" and reason "" for an allow.`; +} + +/** + * Heuristic-only fallback when Bedrock isn't configured (local/dev/test). Mirrors + * autoReview's fail-open contract: we still produce a moderation verdict from the + * cheap screenContent heuristic, but skip the AI-only signals (topics/sentiment/ + * quality) by leaving them empty/null so the cron writes nothing misleading. + */ +function heuristicAnalysis(input: AnalyzePostInput): ContentAnalysis { + const result = screenContent({ title: input.title, body: input.body }); + return { + topics: [], + proposedTopics: [], + sentiment: null, + sentimentScore: null, + qualityScore: null, + qualityReason: "", + moderation: result.ok + ? { verdict: "allow", category: "none", reason: "" } + : { + verdict: "review", + category: "heuristic", + reason: result.reasons.join(", "), + }, + }; +} + +/** + * Analyse a single post with Bedrock: topic tagging, sentiment, quality scoring + * and a moderation verdict in ONE model call. Gated and FAIL-OPEN, exactly like + * autoReview(): + * - Bedrock not configured -> heuristic moderation only, no AI signals. + * - On ANY thrown error -> capture to Sentry and return the heuristic result so + * a model/infra failure never blocks or corrupts the pipeline. + */ +export async function analyzePost( + input: AnalyzePostInput, + vocab: TopicVocabEntry[], +): Promise { + if (!isBedrockEnabled()) { + return heuristicAnalysis(input); + } + + try { + const modelId = process.env.BEDROCK_MODEL_ID as string; + const { type, title, body, externalUrl } = input; + const pageText = externalUrl ? await fetchPageText(externalUrl) : ""; + + const userMessage = [ + `Type: ${type ?? "post"}`, + `Title: ${title ?? ""}`, + `Body: ${(body ?? "").slice(0, MAX_BODY_CHARS)}`, + externalUrl ? `External URL: ${externalUrl}` : "", + pageText ? `Linked page text:\n${pageText}` : "", + ] + .filter(Boolean) + .join("\n\n"); + + const res = await bedrockClient.send( + new InvokeModelCommand({ + modelId, + contentType: "application/json", + accept: "application/json", + body: JSON.stringify({ + anthropic_version: "bedrock-2023-05-31", + max_tokens: 512, + system: buildSystemPrompt(vocab), + messages: [{ role: "user", content: userMessage }], + }), + }), + ); + + const decoded = JSON.parse(new TextDecoder().decode(res.body)) as { + content?: Array<{ type?: string; text?: string }>; + }; + const text = decoded.content?.[0]?.text ?? ""; + return parseAnalysis(text, vocab, input); + } catch (err) { + Sentry.captureException(err); + // FAIL OPEN: fall back to the heuristic rather than corrupt/skip the row. + return heuristicAnalysis(input); + } +} + +/** Pull the first {...} object out of text that may be wrapped in prose. */ +function extractJson(raw: string): string { + const start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + return start >= 0 && end > start ? raw.slice(start, end + 1) : raw; +} + +function clamp(n: unknown, min: number, max: number): number | null { + if (typeof n !== "number" || Number.isNaN(n)) return null; + return Math.min(max, Math.max(min, n)); +} + +/** + * Parse the model's JSON. Defensive: unknown/garbage topics are dropped (only + * slugs present in the vocab survive), and an unparseable response falls back to + * the heuristic (fail-open). A parseable response with an unexpected moderation + * verdict routes to review (fail-safe), matching autoReview's asymmetry. + */ +export function parseAnalysis( + raw: string, + vocab: TopicVocabEntry[], + input: AnalyzePostInput, +): ContentAnalysis { + let obj: unknown; + try { + obj = JSON.parse(extractJson(raw)); + } catch { + return heuristicAnalysis(input); + } + + const o = (obj ?? {}) as Record; + const vocabSlugs = new Set(vocab.map((t) => t.slug)); + + const topics: TopicSuggestion[] = Array.isArray(o.topics) + ? (o.topics as unknown[]) + .map((t) => { + const tt = (t ?? {}) as Record; + const slug = typeof tt.slug === "string" ? tt.slug : ""; + const confidence = clamp(tt.confidence, 0, 1) ?? 0.5; + return { slug, confidence }; + }) + .filter((t) => vocabSlugs.has(t.slug)) + .slice(0, MAX_TOPICS) + : []; + + const proposedTopics: string[] = Array.isArray(o.proposedTopics) + ? (o.proposedTopics as unknown[]) + .filter((s): s is string => typeof s === "string" && s.length > 0) + .map((s) => s.toLowerCase().trim()) + .filter((s) => !vocabSlugs.has(s)) + .slice(0, MAX_TOPICS) + : []; + + const sentiment: Sentiment | null = + o.sentiment === "positive" || + o.sentiment === "neutral" || + o.sentiment === "negative" + ? o.sentiment + : null; + + const mod = (o.moderation ?? {}) as Record; + const moderation = + mod.verdict === "allow" + ? { + verdict: "allow" as const, + category: typeof mod.category === "string" ? mod.category : "none", + reason: typeof mod.reason === "string" ? mod.reason : "", + } + : { + verdict: "review" as const, + category: typeof mod.category === "string" ? mod.category : "unknown", + reason: typeof mod.reason === "string" ? mod.reason : "", + }; + + return { + topics, + proposedTopics, + sentiment, + sentimentScore: clamp(o.sentimentScore, -1, 1), + qualityScore: clamp(o.qualityScore, 0, 1), + qualityReason: typeof o.qualityReason === "string" ? o.qualityReason : "", + moderation, + }; +}