From 92d0d1d45089f3281720551218119f16b0f19641 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 14 Jun 2026 21:32:51 +0100 Subject: [PATCH 1/5] chore: prettier-format OG card files --- .../s/[sourceSlug]/_sourceProfileClient.tsx | 2 +- app/og/route.tsx | 13 +- lib/og/fonts.ts | 41 +- lib/og/templates.tsx | 722 +++++++++++++++--- lib/og/tokens.ts | 42 +- lib/og/url.ts | 7 +- 6 files changed, 686 insertions(+), 141 deletions(-) diff --git a/app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx b/app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx index 0f985419..8363b518 100644 --- a/app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx +++ b/app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx @@ -65,7 +65,7 @@ const SourceProfileContent = ({ sourceSlug, initialProfile }: Props) => { // first render (including SSR) is the real profile rather than a skeleton. if (status === "error" || !pub) { return ( -
+

Publication Not Found diff --git a/app/og/route.tsx b/app/og/route.tsx index 04d7042e..09402159 100644 --- a/app/og/route.tsx +++ b/app/og/route.tsx @@ -31,13 +31,20 @@ async function logo(origin: string) { const res = await fetch(`${origin}/og/wordmark-white.png`); const bytes = new Uint8Array(await res.arrayBuffer()); let binary = ""; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + for (let i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); return (_logo = `data:image/png;base64,${btoa(binary)}`); } const list = (v: string | null) => - v ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined; -const num = (v: string | null, d = 0) => (v != null && v !== "" ? Number(v) : d); + v + ? v + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : undefined; +const num = (v: string | null, d = 0) => + v != null && v !== "" ? Number(v) : d; export async function GET(req: Request) { try { diff --git a/lib/og/fonts.ts b/lib/og/fonts.ts index 9f36b031..0afa8818 100644 --- a/lib/og/fonts.ts +++ b/lib/og/fonts.ts @@ -7,7 +7,7 @@ type FontSpec = { name: string; data: ArrayBuffer; weight: 400 | 600 | 700 | 800; - style: 'normal'; + style: "normal"; }; // The css2 endpoint serves woff2 to modern UAs; spoofing an old UA makes it @@ -18,12 +18,14 @@ export async function loadGoogleFont( text?: string, ): Promise { const params = new URLSearchParams({ family: `${family}:wght@${weight}` }); - if (text) params.set('text', text); + if (text) params.set("text", text); const cssUrl = `https://fonts.googleapis.com/css2?${params.toString()}`; const css = await fetch(cssUrl, { - headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 5.1)' }, // old UA → ttf + headers: { "User-Agent": "Mozilla/5.0 (Windows NT 5.1)" }, // old UA → ttf }).then((r) => r.text()); - const url = css.match(/src:\s*url\((.+?)\)\s*format\('(?:truetype|opentype)'\)/)?.[1]; + const url = css.match( + /src:\s*url\((.+?)\)\s*format\('(?:truetype|opentype)'\)/, + )?.[1]; if (!url) throw new Error(`Could not resolve a TTF for ${family} ${weight}`); return fetch(url).then((r) => r.arrayBuffer()); } @@ -31,18 +33,25 @@ export async function loadGoogleFont( // Load everything Codú OG cards need. Pass it the text you're about to // render to subset aggressively (smaller payloads); omit for full sets. export async function coduFonts(text?: string): Promise { - const [bricolage, hanken400, hanken600, mono400, mono600] = await Promise.all([ - loadGoogleFont('Bricolage Grotesque', 800, text), - loadGoogleFont('Hanken Grotesk', 400, text), - loadGoogleFont('Hanken Grotesk', 600, text), - loadGoogleFont('JetBrains Mono', 400, text), - loadGoogleFont('JetBrains Mono', 600, text), - ]); + const [bricolage, hanken400, hanken600, mono400, mono600] = await Promise.all( + [ + loadGoogleFont("Bricolage Grotesque", 800, text), + loadGoogleFont("Hanken Grotesk", 400, text), + loadGoogleFont("Hanken Grotesk", 600, text), + loadGoogleFont("JetBrains Mono", 400, text), + loadGoogleFont("JetBrains Mono", 600, text), + ], + ); return [ - { name: 'Bricolage Grotesque', data: bricolage, weight: 800, style: 'normal' }, - { name: 'Hanken Grotesk', data: hanken400, weight: 400, style: 'normal' }, - { name: 'Hanken Grotesk', data: hanken600, weight: 600, style: 'normal' }, - { name: 'JetBrains Mono', data: mono400, weight: 400, style: 'normal' }, - { name: 'JetBrains Mono', data: mono600, weight: 600, style: 'normal' }, + { + name: "Bricolage Grotesque", + data: bricolage, + weight: 800, + style: "normal", + }, + { name: "Hanken Grotesk", data: hanken400, weight: 400, style: "normal" }, + { name: "Hanken Grotesk", data: hanken600, weight: 600, style: "normal" }, + { name: "JetBrains Mono", data: mono400, weight: 400, style: "normal" }, + { name: "JetBrains Mono", data: mono600, weight: 600, style: "normal" }, ]; } diff --git a/lib/og/templates.tsx b/lib/og/templates.tsx index fd775b93..4bed2682 100644 --- a/lib/og/templates.tsx +++ b/lib/og/templates.tsx @@ -2,43 +2,84 @@ // has an explicit display/flexDirection, all colours are literal (see // tokens.ts), no mask-image, and covers are real . OgImage(params) // dispatches by `type`; each builder returns a 1200×630 element. -import React from 'react'; -import { T, FONT, avatarBg, pubBg, initials, fmtK } from './tokens'; +import React from "react"; +import { T, FONT, avatarBg, pubBg, initials, fmtK } from "./tokens"; const W = 1200; const H = 630; // ---- shared style atoms -------------------------------------------- const mono = (size: number, color: string = T.muted): React.CSSProperties => ({ - fontFamily: FONT.mono, fontSize: size, color, letterSpacing: '0.02em', + fontFamily: FONT.mono, + fontSize: size, + color, + letterSpacing: "0.02em", }); -const root = (pad = '72px 76px'): React.CSSProperties => ({ - width: W, height: H, position: 'relative', display: 'flex', flexDirection: 'column', - padding: pad, background: T.canvas, color: T.primary, fontFamily: FONT.sans, overflow: 'hidden', +const root = (pad = "72px 76px"): React.CSSProperties => ({ + width: W, + height: H, + position: "relative", + display: "flex", + flexDirection: "column", + padding: pad, + background: T.canvas, + color: T.primary, + fontFamily: FONT.sans, + overflow: "hidden", }); const spine: React.CSSProperties = { - position: 'absolute', left: 0, top: 0, bottom: 0, width: 6, background: T.accent, + position: "absolute", + left: 0, + top: 0, + bottom: 0, + width: 6, + background: T.accent, }; // faint radial glow stands in for the masked dot-grid (Satori-safe) const glow: React.CSSProperties = { - position: 'absolute', top: -260, right: -200, width: 720, height: 720, borderRadius: 720, - background: 'radial-gradient(circle, rgba(45,212,191,0.10) 0%, rgba(45,212,191,0) 60%)', - display: 'flex', + position: "absolute", + top: -260, + right: -200, + width: 720, + height: 720, + borderRadius: 720, + background: + "radial-gradient(circle, rgba(45,212,191,0.10) 0%, rgba(45,212,191,0) 60%)", + display: "flex", }; const topbar: React.CSSProperties = { - position: 'relative', display: 'flex', flexDirection: 'row', - alignItems: 'center', justifyContent: 'space-between', + position: "relative", + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", }; const url: React.CSSProperties = { ...mono(16, T.faint) }; function Wordmark({ src, h = 38 }: { src: string; h?: number }) { // Explicit width (Satori ignores width:auto); ratio matches wordmark-white.png (1600×519). const w = (h * 1600) / 519; - return Codú; + return ( + Codú + ); } function Eyebrow({ label }: { label: string }) { return ( -
+
{"//"} {label}
@@ -46,7 +87,15 @@ function Eyebrow({ label }: { label: string }) { } function Tag({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); @@ -55,14 +104,45 @@ function Tag({ children }: { children: React.ReactNode }) { // ==================================================================== // 1) MAIN-PAGE cards (static surfaces). hed is an array of lines. // ==================================================================== -export const MAIN: Record = { - home: { eyebrow: 'The community for AI builders', hed: ['Learn to build with AI.', 'Ship what you make.'], sub: 'A knowledge-first community for web devs and indie hackers.' }, - about: { eyebrow: 'About Codú', hed: ['Less theory.', 'More shipping.'], sub: 'Why Codú exists, and who it’s for.' }, - articles: { eyebrow: 'Articles', hed: ['Guides and field notes', 'from people who ship.'], sub: 'Long-form from across the community.' }, - discussions:{ eyebrow: 'Discussions', hed: ['Ask, answer,', 'and figure it out together.'], sub: 'Questions, TILs and working notes from builders.' }, - jobs: { eyebrow: 'Jobs', hed: ['Roles for people', 'building with AI.'], sub: 'Hand-picked teams hiring right now.' }, - advertise: { eyebrow: 'Advertise', hed: ['Reach developers', 'who actually ship.'], sub: 'Sponsor the feed, the newsletter, the jobs board.' }, - weekly: { eyebrow: 'Codú Weekly', hed: ['The best of what', 'builders shipped.'], sub: 'One email a week. No fluff, no filler.' }, +export const MAIN: Record< + string, + { eyebrow: string; hed: string[]; sub: string } +> = { + home: { + eyebrow: "The community for AI builders", + hed: ["Learn to build with AI.", "Ship what you make."], + sub: "A knowledge-first community for web devs and indie hackers.", + }, + about: { + eyebrow: "About Codú", + hed: ["Less theory.", "More shipping."], + sub: "Why Codú exists, and who it’s for.", + }, + articles: { + eyebrow: "Articles", + hed: ["Guides and field notes", "from people who ship."], + sub: "Long-form from across the community.", + }, + discussions: { + eyebrow: "Discussions", + hed: ["Ask, answer,", "and figure it out together."], + sub: "Questions, TILs and working notes from builders.", + }, + jobs: { + eyebrow: "Jobs", + hed: ["Roles for people", "building with AI."], + sub: "Hand-picked teams hiring right now.", + }, + advertise: { + eyebrow: "Advertise", + hed: ["Reach developers", "who actually ship."], + sub: "Sponsor the feed, the newsletter, the jobs board.", + }, + weekly: { + eyebrow: "Codú Weekly", + hed: ["The best of what", "builders shipped."], + sub: "One email a week. No fluff, no filler.", + }, }; export function MainCard({ id, logo }: { id: string; logo: string }) { @@ -75,14 +155,46 @@ export function MainCard({ id, logo }: { id: string; logo: string }) { codu.co
-
-
-
+
+
+ +
+
{c.hed.map((line, i) => ( -
{line}
+
+ {line} +
))}
-
{c.sub}
+
+ {c.sub} +
); @@ -91,15 +203,18 @@ export function MainCard({ id, logo }: { id: string; logo: string }) { // ==================================================================== // 2) POST card — adaptive (Article / Discussion / Link), cover or branded // ==================================================================== -const KIND: Record = { - article: { label: 'Article', tone: 'neutral' }, - discussion: { label: 'Discussion', tone: 'info' }, - link: { label: 'Link', tone: 'faint' }, +const KIND: Record< + string, + { label: string; tone: "neutral" | "info" | "faint" } +> = { + article: { label: "Article", tone: "neutral" }, + discussion: { label: "Discussion", tone: "info" }, + link: { label: "Link", tone: "faint" }, }; export type PostParams = { - type: 'post'; - kind: 'article' | 'discussion' | 'link'; + type: "post"; + kind: "article" | "discussion" | "link"; title: string; author: { name: string; role: string; hue: number }; tags?: string[]; @@ -110,14 +225,36 @@ export type PostParams = { logo: string; }; -function KindBadge({ kind }: { kind: PostParams['kind'] }) { +function KindBadge({ kind }: { kind: PostParams["kind"] }) { const k = KIND[kind] || KIND.article; const tone = - k.tone === 'info' ? { color: T.info, background: T.infoWash, border: 'none' } - : k.tone === 'neutral' ? { color: T.faint, background: 'transparent', border: `1px solid ${T.hairlineStrong}` } - : { color: T.faint, background: 'transparent', border: `1px solid ${T.hairline}` }; + k.tone === "info" + ? { color: T.info, background: T.infoWash, border: "none" } + : k.tone === "neutral" + ? { + color: T.faint, + background: "transparent", + border: `1px solid ${T.hairlineStrong}`, + } + : { + color: T.faint, + background: "transparent", + border: `1px solid ${T.hairline}`, + }; return ( -
+
{k.label}
); @@ -125,18 +262,53 @@ function KindBadge({ kind }: { kind: PostParams['kind'] }) { function Chips({ p }: { p: PostParams }) { return ( -
+
{p.publication && ( -
-
+
+
{initials(p.publication.name)}
{p.publication.name}
)} {p.source && ( -
+
via {p.source}
@@ -147,27 +319,95 @@ function Chips({ p }: { p: PostParams }) { function Byline({ p }: { p: PostParams }) { return ( -
-
-
+
+
+
{initials(p.author.name)}
-
- {p.author.name} - {p.author.role} +
+ + {p.author.name} + + + {p.author.role} +
-
- {(p.tags || []).slice(0, 2).map((t) => {t})} - {p.kind === 'article' && p.read && {p.read} read} +
+ {(p.tags || []).slice(0, 2).map((t) => ( + {t} + ))} + {p.kind === "article" && p.read && ( + {p.read} read + )}
); } -function Title({ text, size, lines }: { text: string; size: number; lines: number }) { +function Title({ + text, + size, + lines, +}: { + text: string; + size: number; + lines: number; +}) { return ( -
+
{text}
); @@ -176,16 +416,31 @@ function Title({ text, size, lines }: { text: string; size: number; lines: numbe export function PostCard(p: PostParams) { const hasCover = !!p.cover; return ( -
+
-
+
{hasCover ? ( -
-
+
+
</div> <img @@ -193,11 +448,24 @@ export function PostCard(p: PostParams) { width={384} height={384} alt="" - style={{ width: 384, height: '100%', objectFit: 'cover', borderRadius: 16, border: `1px solid ${T.hairlineStrong}` }} + style={{ + width: 384, + height: "100%", + objectFit: "cover", + borderRadius: 16, + border: `1px solid ${T.hairlineStrong}`, + }} /> </div> ) : ( - <div style={{ position: 'relative', display: 'flex', flex: 1, alignItems: 'center' }}> + <div + style={{ + position: "relative", + display: "flex", + flex: 1, + alignItems: "center", + }} + > <Title text={p.title} size={60} lines={3} /> </div> )} @@ -209,18 +477,48 @@ export function PostCard(p: PostParams) { // ==================================================================== // 3) IDENTITY cards — profile · publication · job // ==================================================================== -function IdentityShell({ kicker, children, footLeft, logo }: { - kicker: React.ReactNode; children: React.ReactNode; footLeft: React.ReactNode; logo: string; +function IdentityShell({ + kicker, + children, + footLeft, + logo, +}: { + kicker: React.ReactNode; + children: React.ReactNode; + footLeft: React.ReactNode; + logo: string; }) { return ( <div style={root()}> <div style={glow} /> <div style={spine} /> - <div style={topbar}>{kicker}<span style={url}>codu.co</span></div> - <div style={{ position: 'relative', display: 'flex', flexDirection: 'column', marginTop: 'auto', gap: 30 }}> + <div style={topbar}> + {kicker} + <span style={url}>codu.co</span> + </div> + <div + style={{ + position: "relative", + display: "flex", + flexDirection: "column", + marginTop: "auto", + gap: 30, + }} + > {children} </div> - <div style={{ position: 'relative', display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: 26, borderTop: `1px solid ${T.hairline}` }}> + <div + style={{ + position: "relative", + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginTop: "auto", + paddingTop: 26, + borderTop: `1px solid ${T.hairline}`, + }} + > {footLeft} <Wordmark src={logo} h={30} /> </div> @@ -229,91 +527,309 @@ function IdentityShell({ kicker, children, footLeft, logo }: { } function MintBadge({ children }: { children: React.ReactNode }) { return ( - <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 6, fontFamily: FONT.mono, fontSize: 14, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: T.onAccent, background: T.accent, padding: '6px 13px', borderRadius: 999 }}> + <div + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 6, + fontFamily: FONT.mono, + fontSize: 14, + fontWeight: 600, + letterSpacing: "0.08em", + textTransform: "uppercase", + color: T.onAccent, + background: T.accent, + padding: "6px 13px", + borderRadius: 999, + }} + > {children} </div> ); } function MetaLine({ children }: { children: React.ReactNode }) { - return <div style={{ display: 'flex', flexDirection: 'row', ...mono(19, T.muted) }}>{children}</div>; + return ( + <div + style={{ display: "flex", flexDirection: "row", ...mono(19, T.muted) }} + > + {children} + </div> + ); } const metaB: React.CSSProperties = { color: T.accentSoft, fontWeight: 500 }; export type ProfileParams = { - type: 'profile'; name: string; role: string; hue: number; location: string; bio: string; - topHelper?: boolean; followers: number; joined: string; interests?: string[]; logo: string; + type: "profile"; + name: string; + role: string; + hue: number; + location: string; + bio: string; + topHelper?: boolean; + followers: number; + joined: string; + interests?: string[]; + logo: string; }; export function ProfileCard(u: ProfileParams) { return ( <IdentityShell logo={u.logo} kicker={<Eyebrow label="PROFILE" />} - footLeft={<MetaLine><span style={metaB}>{fmtK(u.followers)}</span><span> followers · {u.joined}</span></MetaLine>} + footLeft={ + <MetaLine> + <span style={metaB}>{fmtK(u.followers)}</span> + <span> followers · {u.joined}</span> + </MetaLine> + } > - <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 26 }}> - <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 100, height: 100, borderRadius: 100, background: avatarBg(u.hue), fontFamily: FONT.display, fontWeight: 700, fontSize: 40, color: T.primary }}> + <div + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 26, + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 100, + height: 100, + borderRadius: 100, + background: avatarBg(u.hue), + fontFamily: FONT.display, + fontWeight: 700, + fontSize: 40, + color: T.primary, + }} + > {initials(u.name)} </div> - <div style={{ display: 'flex', flexDirection: 'column' }}> - <div style={{ fontFamily: FONT.display, fontWeight: 800, fontSize: 60, letterSpacing: '-0.03em', lineHeight: 1 }}>{u.name}</div> - <div style={{ ...mono(19, T.muted), marginTop: 12 }}>{[u.role, u.location].filter(Boolean).join(' · ')}</div> + <div style={{ display: "flex", flexDirection: "column" }}> + <div + style={{ + fontFamily: FONT.display, + fontWeight: 800, + fontSize: 60, + letterSpacing: "-0.03em", + lineHeight: 1, + }} + > + {u.name} + </div> + <div style={{ ...mono(19, T.muted), marginTop: 12 }}> + {[u.role, u.location].filter(Boolean).join(" · ")} + </div> </div> </div> - <div style={{ color: T.muted, fontSize: 25, lineHeight: 1.45, maxWidth: 760, display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, overflow: 'hidden' }}>{u.bio}</div> - <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 10 }}> + <div + style={{ + color: T.muted, + fontSize: 25, + lineHeight: 1.45, + maxWidth: 760, + display: "-webkit-box", + WebkitBoxOrient: "vertical", + WebkitLineClamp: 2, + overflow: "hidden", + }} + > + {u.bio} + </div> + <div + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 10, + }} + > {u.topHelper && <MintBadge>★ Top helper</MintBadge>} - {(u.interests || []).slice(0, 3).map((t) => <Tag key={t}>{t}</Tag>)} + {(u.interests || []).slice(0, 3).map((t) => ( + <Tag key={t}>{t}</Tag> + ))} </div> </IdentityShell> ); } export type PublicationParams = { - type: 'publication'; name: string; hue: number; tagline: string; articleCount: number; followers: number; logo: string; + type: "publication"; + name: string; + hue: number; + tagline: string; + articleCount: number; + followers: number; + logo: string; }; export function PublicationCard(p: PublicationParams) { return ( <IdentityShell logo={p.logo} kicker={<Eyebrow label="PUBLICATION" />} - footLeft={<MetaLine><span style={metaB}>{p.articleCount}</span><span> articles · </span><span style={metaB}>{fmtK(p.followers)}</span><span> followers</span></MetaLine>} + footLeft={ + <MetaLine> + <span style={metaB}>{p.articleCount}</span> + <span> articles · </span> + <span style={metaB}>{fmtK(p.followers)}</span> + <span> followers</span> + </MetaLine> + } > - <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 26 }}> - <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 100, height: 100, borderRadius: 12, background: pubBg(p.hue), fontFamily: FONT.display, fontWeight: 800, fontSize: 42, color: '#fff' }}> + <div + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 26, + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 100, + height: 100, + borderRadius: 12, + background: pubBg(p.hue), + fontFamily: FONT.display, + fontWeight: 800, + fontSize: 42, + color: "#fff", + }} + > {initials(p.name)} </div> - <div style={{ fontFamily: FONT.display, fontWeight: 800, fontSize: 60, letterSpacing: '-0.03em', lineHeight: 1 }}>{p.name}</div> + <div + style={{ + fontFamily: FONT.display, + fontWeight: 800, + fontSize: 60, + letterSpacing: "-0.03em", + lineHeight: 1, + }} + > + {p.name} + </div> + </div> + <div + style={{ + color: T.muted, + fontSize: 25, + lineHeight: 1.45, + maxWidth: 760, + display: "-webkit-box", + WebkitBoxOrient: "vertical", + WebkitLineClamp: 2, + overflow: "hidden", + }} + > + {p.tagline} </div> - <div style={{ color: T.muted, fontSize: 25, lineHeight: 1.45, maxWidth: 760, display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, overflow: 'hidden' }}>{p.tagline}</div> </IdentityShell> ); } export type JobParams = { - type: 'job'; company: string; logo: string; role: string; location: string; - jobType: string; salary?: string; tags?: string[]; featured?: boolean; wordmark: string; + type: "job"; + company: string; + logo: string; + role: string; + location: string; + jobType: string; + salary?: string; + tags?: string[]; + featured?: boolean; + wordmark: string; }; export function JobCard(j: JobParams) { return ( <IdentityShell logo={j.wordmark} kicker={ - <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 10 }}> + <div + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 10, + }} + > {j.featured && <MintBadge>Featured</MintBadge>} <Eyebrow label="JOB" /> </div> } - footLeft={<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 10 }}>{(j.tags || []).slice(0, 3).map((t) => <Tag key={t}>{t}</Tag>)}</div>} + footLeft={ + <div + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 10, + }} + > + {(j.tags || []).slice(0, 3).map((t) => ( + <Tag key={t}>{t}</Tag> + ))} + </div> + } > - <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 18 }}> - <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 64, height: 64, borderRadius: 8, background: T.elevated, border: `1px solid ${T.hairline}`, fontFamily: FONT.display, fontWeight: 800, fontSize: 26, color: T.primary }}> + <div + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 18, + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 64, + height: 64, + borderRadius: 8, + background: T.elevated, + border: `1px solid ${T.hairline}`, + fontFamily: FONT.display, + fontWeight: 800, + fontSize: 26, + color: T.primary, + }} + > {j.logo} </div> - <div style={{ display: 'flex', ...mono(22, T.primary) }}>{j.company}</div> + <div style={{ display: "flex", ...mono(22, T.primary) }}> + {j.company} + </div> + </div> + <div + style={{ + fontFamily: FONT.display, + fontWeight: 800, + fontSize: 60, + letterSpacing: "-0.03em", + lineHeight: 1.02, + maxWidth: 900, + display: "-webkit-box", + WebkitBoxOrient: "vertical", + WebkitLineClamp: 2, + overflow: "hidden", + }} + > + {j.role} </div> - <div style={{ fontFamily: FONT.display, fontWeight: 800, fontSize: 60, letterSpacing: '-0.03em', lineHeight: 1.02, maxWidth: 900, display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, overflow: 'hidden' }}>{j.role}</div> <MetaLine> - <span>{j.location} · {j.jobType}{j.salary ? ' · ' : ''}</span> + <span> + {j.location} · {j.jobType} + {j.salary ? " · " : ""} + </span> {j.salary && <span style={metaB}>{j.salary}</span>} </MetaLine> </IdentityShell> @@ -324,7 +840,7 @@ export function JobCard(j: JobParams) { // DISPATCH — one entry point the route calls // ==================================================================== export type OgParams = - | ({ type: 'main'; id: string; logo: string }) + | { type: "main"; id: string; logo: string } | PostParams | ProfileParams | PublicationParams @@ -332,11 +848,17 @@ export type OgParams = export function OgImage(params: OgParams): React.ReactElement { switch (params.type) { - case 'main': return <MainCard id={params.id} logo={params.logo} />; - case 'post': return <PostCard {...params} />; - case 'profile': return <ProfileCard {...params} />; - case 'publication': return <PublicationCard {...params} />; - case 'job': return <JobCard {...params} />; - default: return <MainCard id="home" logo={(params as any).logo} />; + case "main": + return <MainCard id={params.id} logo={params.logo} />; + case "post": + return <PostCard {...params} />; + case "profile": + return <ProfileCard {...params} />; + case "publication": + return <PublicationCard {...params} />; + case "job": + return <JobCard {...params} />; + default: + return <MainCard id="home" logo={(params as any).logo} />; } } diff --git a/lib/og/tokens.ts b/lib/og/tokens.ts index 9638ec17..035bd288 100644 --- a/lib/og/tokens.ts +++ b/lib/og/tokens.ts @@ -4,30 +4,30 @@ export const T = { // canvas ladder - canvas: '#0a0b0e', - surface: '#121419', - elevated: '#181b22', - inset: '#08090c', + canvas: "#0a0b0e", + surface: "#121419", + elevated: "#181b22", + inset: "#08090c", // borders - hairline: '#242832', - hairlineStrong: '#2f3440', + hairline: "#242832", + hairlineStrong: "#2f3440", // text - primary: '#f4f6f8', - muted: '#9aa3b0', - faint: '#868f9b', + primary: "#f4f6f8", + muted: "#9aa3b0", + faint: "#868f9b", // accent (Mint) - accent: '#2dd4bf', - accentSoft: '#6ee7d6', - onAccent: '#04221d', + accent: "#2dd4bf", + accentSoft: "#6ee7d6", + onAccent: "#04221d", // status - info: '#5fa8f5', - infoWash: 'rgba(95,168,245,0.12)', + info: "#5fa8f5", + infoWash: "rgba(95,168,245,0.12)", } as const; export const FONT = { - display: 'Bricolage Grotesque', - sans: 'Hanken Grotesk', - mono: 'JetBrains Mono', + display: "Bricolage Grotesque", + sans: "Hanken Grotesk", + mono: "JetBrains Mono", } as const; // Avatar / publication-mark tints. The app uses oklch(0.5 0.08 H), which @@ -37,7 +37,11 @@ export const avatarBg = (hue: number) => `hsl(${hue}, 22%, 42%)`; export const pubBg = (hue: number) => `hsl(${hue}, 34%, 46%)`; export const initials = (name: string) => - name.split(' ').map((w) => w[0]).slice(0, 2).join(''); + name + .split(" ") + .map((w) => w[0]) + .slice(0, 2) + .join(""); export const fmtK = (n: number) => - n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'k' : '' + n; + n >= 1000 ? (n / 1000).toFixed(1).replace(".0", "") + "k" : "" + n; diff --git a/lib/og/url.ts b/lib/og/url.ts index 418565fe..f967b6d8 100644 --- a/lib/og/url.ts +++ b/lib/og/url.ts @@ -28,7 +28,8 @@ export type OgMainId = | "jobs" | "weekly"; -export const ogMainImage = (id: OgMainId): string => ogUrl({ type: "main", id }); +export const ogMainImage = (id: OgMainId): string => + ogUrl({ type: "main", id }); export type OgPostImageInput = { kind: "article" | "discussion" | "link"; @@ -56,7 +57,9 @@ export const ogPostImage = (post: OgPostImageInput): string => hue: hueFromString(post.authorKey), tags: post.tags?.slice(0, 2).join(","), pub: post.publicationName, - pubHue: post.publicationKey ? hueFromString(post.publicationKey) : undefined, + pubHue: post.publicationKey + ? hueFromString(post.publicationKey) + : undefined, source: post.source, read: post.readMins ? `${post.readMins} min` : undefined, cover: post.cover, From 413650577072b1a2167f1329d3f79355ad787fae Mon Sep 17 00:00:00 2001 From: NiallJoeMaher <nialljoemaher@gmail.com> Date: Sun, 14 Jun 2026 21:32:51 +0100 Subject: [PATCH 2/5] chore: add CLAUDE.md, remove internal planning docs, gitignore AI config - Add CLAUDE.md orienting contributors; flags the repo as public and sets a high bar for code that must pass open-source review. - Remove docs/plans/* internal design/planning notes (not appropriate for a public repo) and drop their references from code comments. - gitignore .claude/ and other local AI assistant config. --- .gitignore | 5 + CLAUDE.md | 59 ++ app/api/cron/daily-review/route.ts | 2 +- components/Admin/AdminShell.tsx | 3 +- .../2026-06-09-moderation-overhaul-design.md | 155 --- .../2026-06-09-moderation-overhaul-plan.md | 883 ------------------ ...6-06-10-content-url-restructure-seo-aeo.md | 619 ------------ ...06-14-admin-shell-and-ai-content-design.md | 284 ------ server/db/schema.ts | 5 +- 9 files changed, 68 insertions(+), 1947 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 docs/plans/2026-06-09-moderation-overhaul-design.md delete mode 100644 docs/plans/2026-06-09-moderation-overhaul-plan.md delete mode 100644 docs/plans/2026-06-10-content-url-restructure-seo-aeo.md delete mode 100644 docs/plans/2026-06-14-admin-shell-and-ai-content-design.md diff --git a/.gitignore b/.gitignore index 104a24b5..3bb96bd2 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,8 @@ ssmSetup.zsh # Local-only deploy notes (never commit) local.md logs/ + +# AI assistant local config / worktrees / scratch (never commit) +.claude/ +.cursor/ +.aider* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..13f3cfbd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# Codú — contributor & assistant guide + +Codú is the community platform for AI builders and indie hackers: a curated feed +of articles, tips, questions, and links, with profiles, discussions, moderation, +and a build-board. This file orients anyone (human or AI assistant) working in the +repo. + +## ⚠️ This is a public, open-source repository + +Everything committed here is public and permanent in git history. Before you write +or commit anything: + +- **No private or internal content.** No secrets, API keys, access tokens, + customer data, internal planning docs, design docs, scratch notes, or + personal/operational details. Local-only notes belong in gitignored files + (`local.md`, `.claude/`). +- **No assistant attribution or scratch artifacts.** Do not add AI co-author + trailers, "generated by" notes, planning/design markdown, or tool config to + commits, commit messages, or PR descriptions. +- **Code must clear public code review.** Assume every line will be read by + external contributors and maintainers. Hold a high bar: clear naming, no dead + code, no debug logging, tests for new logic, and changes scoped to one concern. + +## Stack + +- **Next.js (App Router)** + React + TypeScript. +- **tRPC** for the API (`server/api/router/*`), **Drizzle ORM** over **Postgres** + (`server/db/schema.ts`, migrations in `drizzle/`). +- **NextAuth** for auth; **Tailwind CSS** for styling (design tokens in + `styles/globals.css`). +- **AWS**: S3 (uploads), Bedrock (content moderation/analysis), CDK-managed cron + Lambdas + EventBridge (`cdk/`). Deployed on **Vercel** (the `develop` branch is + production; `db:migrate` runs on the production build). +- **Testing**: Vitest unit tests (`*.test.ts`), Playwright e2e (`e2e/`). + +## Layout (route groups = layout boundaries) + +`app/` is split into route groups, each its own layout "world": + +- `(app)` — the public 3-column rail shell (`AppShell`): feed (home), profiles, + posts, discussions. The feed is the homepage. +- `(admin)` — private, full-width admin cockpit (`AdminShell`); ADMIN-role gate in + its `layout.tsx`. Not part of the public shell. +- `(auth)`, `(editor)`, `(marketing)` — their own chrome. + +A page that should not use the public rail shell does **not** live in `(app)` — it +gets a sibling route group. Don't reach for runtime flags to opt out of a layout. + +## Working in this repo + +- **Before claiming done, run and pass locally:** `npm run lint`, + `npm run prettier`, `npm run test:unit`, and `npm run build`. Migrations: + `npm run db:generate` after schema changes (review the generated SQL). +- **Schema changes** are additive where possible; migrations apply on the prod + deploy, so never write a migration that can fail destructively. +- **Match the surrounding code**: comment density, naming, and idioms. New tRPC + procedures go in the relevant `server/api/router/*` file; keep DB access there. +- **Keep PRs focused** — one concern per PR, with a clear description of what and + why. diff --git a/app/api/cron/daily-review/route.ts b/app/api/cron/daily-review/route.ts index ded8ff7f..f6896775 100644 --- a/app/api/cron/daily-review/route.ts +++ b/app/api/cron/daily-review/route.ts @@ -36,7 +36,7 @@ import sendEmail from "@/utils/sendEmail"; // 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): +// Four incremental passes: // 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) diff --git a/components/Admin/AdminShell.tsx b/components/Admin/AdminShell.tsx index de52a608..ccd61050 100644 --- a/components/Admin/AdminShell.tsx +++ b/components/Admin/AdminShell.tsx @@ -26,8 +26,7 @@ interface NavItem { 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 +// Sidebar sections. `soon` items are planned surfaces — shown as the // roadmap but not linked until their routes exist. const NAV: NavItem[] = [ { name: "Overview", href: "/admin", icon: Squares2X2Icon }, diff --git a/docs/plans/2026-06-09-moderation-overhaul-design.md b/docs/plans/2026-06-09-moderation-overhaul-design.md deleted file mode 100644 index 2ebfda98..00000000 --- a/docs/plans/2026-06-09-moderation-overhaul-design.md +++ /dev/null @@ -1,155 +0,0 @@ -# Moderation Overhaul — Design - -**Date:** 2026-06-09 -**Branch:** feat/relaunch-repositioning -**Status:** Approved (brainstorm complete) - -## Goal - -Tighten content moderation for the Codú relaunch ("community for AI builders & -indie hackers"). Fix the broken moderation-email link, add a real flag button -everywhere, add Bedrock Haiku auto-review at publish time, dedupe links and -discussions for freshness, and make sure nothing a user posts is ever deleted — -only hidden, recoverably. - -This builds on infrastructure that **already exists** on this branch: -`MODERATION_ENABLED` gate, `screenContent()` heuristic, `posts.status` enum -(`in_review`/`rejected`), `admin.moderatePost`, `admin.ban`/`unban`, -`ReportModal`/`ReportButton`, DynamoDB `rateLimit.ts`, SES email via -`utils/sendEmail.ts`. - -## Decisions (from brainstorm) - -- **Review scope:** every post type runs auto-review at publish (the "loader" - moment), then goes live unless flagged. **Articles additionally** always pass - through the human editorial gate (`in_review`), never auto-publishing. -- **Auto-mod action on a hit:** hide + queue (`in_review`), email admin, human - decides. Never auto-delete. -- **User flags:** any flag notifies the admin; content stays live until a human - hides it. No threshold/auto-hide. -- **Decline ≠ delete:** decline moves content to `rejected` ("Hidden by - moderator"); the author keeps the content and sees the status. -- **Auto-review policy:** loose and fair. Allow by default, including people - posting their own projects/launches. Only flag the obvious — porn/NSFW, - crypto/token shilling, malicious/scam links, dead/fake sources, plainly - off-theme content. -- **Dedupe:** content must be fresh. Links and discussions/questions are deduped - globally within a **6-month** window. - -## Section 1 — Status model, author states, email fix - -Reuse the existing `posts.status` enum. No new enum. - -| Status | Public sees | Author sees on their own post | -|---|---|---| -| `published` | Live | Live | -| `in_review` | Hidden | "Awaiting review" badge | -| `rejected` | Hidden | "Hidden by moderator" badge (+ reason) | - -**Principle:** nothing is ever deleted. Decline = `→ rejected`. Author keeps -content, sees it in dashboard/profile with a badge. Same for `in_review`. - -**New column:** `posts.moderationNote` (nullable text) — stores the auto-review -verdict/reasons (for the admin queue) and an optional decline note (for the -author). - -**Email bug fix:** `server/lib/moderation.ts` builds links from -`process.env.NEXTAUTH_URL`, which carries the `/api/auth` path → produced -`…/api/auth/admin/moderation`. Centralise a `getAppOrigin()` helper (origin -only, no path) and use it for all app-facing email links. Moderation email → -`…/admin/moderation?item=<postId>`. Audit other email builders (`report.ts`, -password-less auth) for the same `NEXTAUTH_URL`-as-base mistake. - -## Section 2 — Publish flow + Bedrock auto-review + loader - -Auto-review runs **synchronously** in `post.create` / `post.update` -(going-live), behind `MODERATION_ENABLED`. No queue exists; this mirrors the -existing inline `screenContent()`. - -1. **Links/resources** — "pre-visit" the URL: follow redirects, size cap, ~4s - timeout, extract `<title>`/meta/visible text. Validates the source is real - and gives Haiku real page content. -2. **Bedrock Haiku** — post (or fetched page text) + philosophy prompt → - structured JSON `{ verdict: "allow" | "review", category, reason }`. -3. **Decision:** - - `article` → always `in_review` (editorial). Auto-review still runs; verdict - stored in `moderationNote` for the queue. - - everything else → `allow` ⇒ live; `review` ⇒ `in_review` (hidden), admin - emailed. - -**Policy / prompt:** loose and fair. Allow self-promotion of own projects. Flag -only the obvious (porn, crypto, malicious/scam links, dead sources, plainly -off-theme). - -**Resilience:** Bedrock or fetch error/timeout → fail-open: fall back to the -heuristic screen; if clean, publish. Never block publishing on infra failure -(log to Sentry). Bedrock independently gated by its env vars. - -**Loader UX:** the editor awaits the mutation; show a themed "Reviewing your -post…" loader with rotating playful lines. On resolve → live post, or a -"Submitted for review" confirmation. - -## Section 3 — Freshness dedupe (links + discussions) - -**Links/resources — global 6-month freshness window:** -- New `posts.externalUrlNormalized` column (+ index). `normalizeUrl()`: - lowercase host, strip `www.`, drop tracking params (`utm_*`, `fbclid`, - `gclid`, ref), trim trailing slash/fragment. -- Normalised URL already present **within 6 months** (any author) → **blocked**, - with a pointer to the existing post. Older → allowed (stale, refresh fine). - -**Discussions/questions — no same/very-similar recent post:** -- Text similarity on normalised title via Postgres **`pg_trgm`** (enable - extension via Drizzle migration). Scoped to last 6 months. -- Exact/near-exact → **blocked**, pointing to the existing thread. -- Very similar (softer threshold) → **`in_review`** (human decides), not a hard - block — fuzzy matching shouldn't reject a borderline-distinct question. - -**Rate limiting:** reuse `enforceRateLimit` already on create; add a tighter -link-submission throttle. Checks run **before** Bedrock so we don't fetch/screen -a URL we'll reject as a dupe. - -## Section 4 — Flags, admin queue, infra, account-block - -**Flag/report (any flag → notify, stays live, admin hides):** -- Add nullable `postId` FK to `content_report` (+ relation) so reports on the new - `posts` table land in the DB queue. Keep `contentId`/`discussionId` for legacy. -- `report.create` handles `post`: insert, dedupe per user per post, email admin - (fixed origin, link to `…/admin/moderation?item=<id>`). Content stays live. -- Ensure `ReportButton`/`ReportModal` is on every surface (feed cards + all - detail pages + discussions), routing to the DB `report.create`, not the - legacy email-only `report.send`. Retire the email-only path for posts. - -**Admin moderation queue (`/admin/moderation`):** -- One view merging `in_review` posts (auto-mod/articles/dedupe-borderline) + - live-but-reported posts. -- Actions: Approve → `published`; Decline → `rejected` ("hidden by moderator" + - optional note); Hide a reported-live post → `in_review`/`rejected`; Dismiss - report. Show `moderationNote` reasons + report details/counts. -- Extend `admin.moderatePost` (or add `hidePost`/`reviewReport`) to act on live - posts, not just `in_review`. -- Optional convenience: "Ban author" from the queue (reuses `admin.ban`). - -**Author-facing states:** badges on the author's own post page + dashboard list -for `in_review` ("Awaiting review") and `rejected` ("Hidden by moderator" + -note). Public/feed queries already exclude non-published. - -**Bedrock infra (CDK + env + SDK):** -- Add `@aws-sdk/client-bedrock-runtime`. -- `server/lib/bedrock.ts` client mirroring `s3helpers.ts` (region from - `BEDROCK_REGION`, creds from `ACCESS_KEY`/`SECRET_KEY`). -- `cdk/lib/iam-stack.ts`: `bedrock:InvokeModel` PolicyStatement scoped to the - Haiku model / regional inference-profile ARN(s), granted to `appUser`. -- Env: `BEDROCK_REGION`, `BEDROCK_MODEL_ID` (Haiku via regional inference - profile). Confirm exact model id via the claude-api skill at implementation. -- `server/lib/autoReview.ts`: link-fetch + Haiku call + verdict; gated; - fail-open. - -**Account blocking:** already present (`admin.ban`/`unban` flips published→draft -and feed queries `LEFT JOIN banned_users`). Add a regression test to lock it in. - -## Out of scope / YAGNI - -- No SQS/queue — auto-review stays synchronous (acceptable few-second lag). -- No embeddings infra — discussion similarity uses `pg_trgm`, not vectors. -- No new status enum values — reuse `in_review`/`rejected`. diff --git a/docs/plans/2026-06-09-moderation-overhaul-plan.md b/docs/plans/2026-06-09-moderation-overhaul-plan.md deleted file mode 100644 index 0a09cb37..00000000 --- a/docs/plans/2026-06-09-moderation-overhaul-plan.md +++ /dev/null @@ -1,883 +0,0 @@ -# Moderation Overhaul Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. When implementing the Bedrock call (Phase 3), REQUIRED SUB-SKILL: read the claude-api skill first for the correct Bedrock model id / Messages API shape. - -**Goal:** Add Bedrock Haiku auto-review + freshness dedupe + a real flag button + a working admin queue to Codú's publish flow, fix the broken moderation-email link, and guarantee content is only ever hidden (never deleted). - -**Architecture:** Auto-review runs synchronously inside the publish path (no queue exists). The currently-duplicated moderation gate (in `content.ts` create/update/publish AND `post.ts` create/update) is unified into one `server/lib/moderation.ts` helper that all paths call. Pure logic (URL normalize, verdict parse, link fetch, policy) is extracted into small, unit-tested functions. Everything is gated behind `MODERATION_ENABLED`; Bedrock is independently gated by its env vars and fails open. - -**Tech Stack:** Next.js, tRPC, Drizzle ORM (Postgres), AWS SDK v3 (`@aws-sdk/client-bedrock-runtime`), Bedrock (Claude Haiku), DynamoDB rate limiter, SES email, Vitest (new, pure-logic units), Playwright (E2E flows). - -**Design doc:** `docs/plans/2026-06-09-moderation-overhaul-design.md` - ---- - -## Conventions for the executor - -- The **active editor publish path is `api.content.*`** (`server/api/router/content.ts`): `content.create` (~529), `content.update` (~655), `content.publish` (~1264). `post.ts` create/update (~447/~549) are a parallel copy. Both write the `posts` table. The unified helper must be wired into **all five** call sites. -- The DB post-status enum already has `in_review` and `rejected` (`server/db/schema.ts:38-47`). The my-posts badges (`app/(app)/my-posts/_client.tsx:152-161`) and `content.myDrafts` filter (`content.ts:1180`) already surface them. -- Credentials: runtime AWS uses `process.env.ACCESS_KEY`/`SECRET_KEY` (NOT standard AWS names), region per-service. Mirror `utils/s3helpers.ts`. -- Commit after every task. Run `npm run lint` before each commit. - ---- - -## Phase 0 — Tooling & schema foundations - -### Task 0.1: Add Vitest for pure-logic unit tests - -**Files:** -- Modify: `package.json` (devDeps + scripts) -- Create: `vitest.config.ts` -- Create: `server/lib/__tests__/smoke.test.ts` (temporary, deleted in Task 0.2) - -**Step 1:** Install: `npm i -D vitest` - -**Step 2:** Create `vitest.config.ts`: -```ts -import { defineConfig } from "vitest/config"; -import path from "node:path"; - -export default defineConfig({ - test: { - environment: "node", - include: ["**/*.test.ts"], - exclude: ["**/node_modules/**", "e2e/**", "**/*.spec.ts"], - }, - resolve: { - alias: { "@": path.resolve(__dirname, ".") }, - }, -}); -``` -Note: `include` is `*.test.ts` only (not `.tsx`) so it never collides with Playwright's `*.spec.ts` or the abandoned `.test.tsx` component files. Confirm Playwright's test glob in `playwright.config.ts` is `*.spec.ts` / an `e2e` dir; if Playwright also matches `*.test.ts`, narrow Vitest's include to `server/**` + `utils/**` and exclude in Playwright. - -**Step 3:** Add scripts to `package.json`: -```json -"test:unit": "vitest run", -"test:unit:watch": "vitest" -``` - -**Step 4:** Create smoke test `server/lib/__tests__/smoke.test.ts`: -```ts -import { describe, it, expect } from "vitest"; -describe("vitest", () => { - it("runs", () => expect(1 + 1).toBe(2)); -}); -``` - -**Step 5:** Run `npm run test:unit` → expect 1 passing test. - -**Step 6:** Commit: `git add -A && git commit -m "chore(test): add vitest for unit tests"` - ---- - -### Task 0.2: Schema migration — moderation columns + pg_trgm - -**Files:** -- Modify: `server/db/schema.ts` (posts table ~325-400; content_report ~1691-1758) -- Modify: `schema/post.ts:14-20` (PostStatusSchema) -- Generate: `drizzle/00XX_*.sql` via `npm run db:generate` -- Delete: `server/lib/__tests__/smoke.test.ts` - -**Step 1:** In `server/db/schema.ts` `posts` table, add two columns (mirror existing `externalUrl` text column): -```ts -moderationNote: text("moderationNote"), -externalUrlNormalized: text("externalUrlNormalized"), -``` -And in the posts index block, add: -```ts -externalUrlNormalizedIdx: index("posts_external_url_normalized_idx").on( - table.externalUrlNormalized, -), -``` - -**Step 2:** In `content_report` table (~1691), add a nullable FK mirroring `contentId`: -```ts -postId: text("postId").references(() => posts.id, { - onDelete: "cascade", - onUpdate: "cascade", -}), -``` -Add index in its `(table) => ({...})` block: -```ts -postIdIndex: index("ContentReport_postId_index").on(table.postId), -``` -And in `contentReportRelations` add: -```ts -post: one(posts, { - fields: [content_report.postId], - references: [posts.id], -}), -``` - -**Step 3:** Update `schema/post.ts:14-20` PostStatusSchema to include the moderation states (so update mutations can carry them and types line up): -```ts -export const PostStatusSchema = z.enum([ - "draft", "published", "scheduled", "unlisted", "in_review", "rejected", -]); -``` - -**Step 4:** Generate migration: `npm run db:generate`. Then **hand-edit the generated SQL** to prepend the trigram extension + index (drizzle won't emit these): -```sql -CREATE EXTENSION IF NOT EXISTS pg_trgm; --- after the columns are added: -CREATE INDEX IF NOT EXISTS posts_title_trgm_idx ON "posts" USING gin (lower("title") gin_trgm_ops); -``` -(The GIN trigram index makes the discussion-similarity query in Task 2.3 fast.) - -**Step 5:** Apply locally: `npm run db:migrate`. Verify columns exist (psql or drizzle studio). - -**Step 6:** Delete the smoke test. Run `npm run lint`. - -**Step 7:** Commit: `git add -A && git commit -m "feat(db): moderation columns, post report FK, pg_trgm"` - ---- - -## Phase 1 — Email link bug fix (isolated quick win) - -### Task 1.1: `getAppOrigin()` helper (Vitest TDD) - -**Files:** -- Create: `server/lib/url.ts` -- Create: `server/lib/url.test.ts` - -**Step 1 (failing test):** `server/lib/url.test.ts`: -```ts -import { describe, it, expect, afterEach } from "vitest"; -import { getAppOrigin } from "./url"; - -const save = { ...process.env }; -afterEach(() => { process.env = { ...save }; }); - -describe("getAppOrigin", () => { - it("strips a path like /api/auth from NEXTAUTH_URL", () => { - process.env.NEXTAUTH_URL = "http://localhost:3000/api/auth"; - expect(getAppOrigin()).toBe("http://localhost:3000"); - }); - it("prefers DOMAIN_NAME as https origin", () => { - process.env.DOMAIN_NAME = "www.codu.co"; - expect(getAppOrigin()).toBe("https://www.codu.co"); - }); - it("falls back to localhost", () => { - delete process.env.DOMAIN_NAME; delete process.env.VERCEL_URL; - delete process.env.NEXTAUTH_URL; delete process.env.AUTH_URL; - expect(getAppOrigin()).toBe("http://localhost:3000"); - }); -}); -``` - -**Step 2:** `npm run test:unit -- url` → FAIL (module missing). - -**Step 3:** Implement `server/lib/url.ts`: -```ts -/** - * The app's public origin (scheme + host, NO path). Use this for app-facing - * links in emails. NEXTAUTH_URL carries a /api/auth path which must be stripped - * — interpolating it directly produced .../api/auth/admin/moderation (bug). - */ -export function getAppOrigin(): string { - const domain = process.env.DOMAIN_NAME || process.env.VERCEL_URL; - if (domain) return `https://${domain.replace(/^https?:\/\//, "")}`; - const raw = process.env.NEXTAUTH_URL || process.env.AUTH_URL; - if (raw) { - try { return new URL(raw).origin; } catch { /* fall through */ } - } - return "http://localhost:3000"; -} -``` - -**Step 4:** `npm run test:unit -- url` → PASS. - -**Step 5:** Commit: `git commit -am "feat: getAppOrigin helper (origin-only, no path)"` - ---- - -### Task 1.2: Use `getAppOrigin` in moderation email - -**Files:** Modify `server/lib/moderation.ts:28-53` - -**Step 1:** Replace the broken base (`moderation.ts:35-36`) and link (`:46`): -```ts -import { getAppOrigin } from "@/server/lib/url"; -// ... -const base = getAppOrigin(); -// ... -<p><a href="${base}/admin/moderation?item=${opts.postId}">Review it in the moderation queue →</a></p> -``` - -**Step 2:** Manual verify: with `NEXTAUTH_URL=http://localhost:3000/api/auth`, trigger a review email (or unit-test the URL build). Confirm link is `http://localhost:3000/admin/moderation?item=<id>`. - -**Step 3:** Commit: `git commit -am "fix(email): moderation link points to /admin/moderation page (was /api/auth/...)"` - ---- - -### Task 1.3: Audit other email builders for the same bug - -**Files:** `server/api/router/report.ts` (getBaseUrl ~44), `utils/createArticleReportEmailTemplate.ts` (~19), any password-less auth email. - -**Step 1:** Grep: `grep -rn "NEXTAUTH_URL\|getBaseUrl" server utils app | grep -iv test`. The two `getBaseUrl()` copies use `DOMAIN_NAME||VERCEL_URL` (already correct-ish but duplicated). Replace both with `getAppOrigin()` and delete the duplicates (DRY). - -**Step 2:** Manual/grep verify no remaining `${...NEXTAUTH_URL...}/` app-link interpolation. - -**Step 3:** Commit: `git commit -am "refactor(email): single getAppOrigin, drop duplicated getBaseUrl"` - ---- - -## Phase 2 — URL normalize + freshness dedupe - -### Task 2.1: `normalizeUrl()` (Vitest TDD) - -**Files:** Create `server/lib/normalizeUrl.ts` + `server/lib/normalizeUrl.test.ts` - -**Step 1 (failing test):** -```ts -import { describe, it, expect } from "vitest"; -import { normalizeUrl } from "./normalizeUrl"; - -describe("normalizeUrl", () => { - it("lowercases host, strips www and trailing slash", () => { - expect(normalizeUrl("https://WWW.Example.com/Path/")) - .toBe("https://example.com/Path"); - }); - it("drops tracking params but keeps meaningful ones", () => { - expect(normalizeUrl("https://x.com/a?utm_source=t&id=5&fbclid=z")) - .toBe("https://x.com/a?id=5"); - }); - it("drops the fragment", () => { - expect(normalizeUrl("https://x.com/a#section")).toBe("https://x.com/a"); - }); - it("returns null for non-http input", () => { - expect(normalizeUrl("javascript:alert(1)")).toBeNull(); - expect(normalizeUrl("not a url")).toBeNull(); - }); -}); -``` - -**Step 2:** Run → FAIL. - -**Step 3:** Implement `normalizeUrl.ts`: -```ts -const TRACKING = /^(utm_|ref$|ref_|fbclid$|gclid$|mc_|igshid$)/i; - -/** Normalise an external URL for dedupe. Returns null if not http(s). */ -export function normalizeUrl(input: string): string | null { - let u: URL; - try { u = new URL(input.trim()); } catch { return null; } - if (u.protocol !== "http:" && u.protocol !== "https:") return null; - u.hostname = u.hostname.toLowerCase().replace(/^www\./, ""); - u.hash = ""; - const keep = new URLSearchParams(); - for (const [k, v] of u.searchParams) if (!TRACKING.test(k)) keep.append(k, v); - // stable order - keep.sort(); - u.search = keep.toString(); - let out = u.toString(); - if (out.endsWith("/") && u.pathname !== "/") out = out.slice(0, -1); - return out; -} -``` - -**Step 4:** Run → PASS. (Adjust expected test strings to the impl's exact output if param-ordering differs — keep tests and impl in sync.) - -**Step 5:** Commit: `git commit -am "feat: normalizeUrl for link dedupe"` - ---- - -### Task 2.2: Link freshness dedupe (6-month window) - -**Files:** Create `server/lib/dedupe.ts` + `server/lib/dedupe.test.ts` (pure predicate); wire into `content.ts` create/update and `post.ts`. - -**Step 1 (failing test for the pure predicate):** Extract the decision as a pure function so it's unit-testable without a DB: -```ts -import { describe, it, expect } from "vitest"; -import { isWithinFreshnessWindow } from "./dedupe"; - -describe("isWithinFreshnessWindow", () => { - const now = new Date("2026-06-09T00:00:00Z"); - it("true when existing post is < 6 months old", () => { - expect(isWithinFreshnessWindow(new Date("2026-03-01T00:00:00Z"), now)).toBe(true); - }); - it("false when existing post is > 6 months old", () => { - expect(isWithinFreshnessWindow(new Date("2025-01-01T00:00:00Z"), now)).toBe(false); - }); -}); -``` - -**Step 2:** Run → FAIL. - -**Step 3:** Implement in `server/lib/dedupe.ts`: -```ts -export const FRESHNESS_MONTHS = 6; - -export function isWithinFreshnessWindow(publishedAt: Date, now: Date): boolean { - const cutoff = new Date(now); - cutoff.setMonth(cutoff.getMonth() - FRESHNESS_MONTHS); - return publishedAt >= cutoff; -} - -/** Query helper: returns an existing fresh post sharing this normalized URL. */ -export async function findFreshDuplicateLink( - db: typeof import("@/server/db").db, - normalized: string, -): Promise<{ id: string; slug: string | null } | null> { - const cutoff = new Date(); - cutoff.setMonth(cutoff.getMonth() - FRESHNESS_MONTHS); - const { posts } = await import("@/server/db/schema"); - const { and, eq, gte } = await import("drizzle-orm"); - const rows = await db - .select({ id: posts.id, slug: posts.slug }) - .from(posts) - .where(and( - eq(posts.externalUrlNormalized, normalized), - eq(posts.status, "published"), - gte(posts.publishedAt, cutoff.toISOString()), - )) - .limit(1); - return rows[0] ?? null; -} -``` -(Adjust imports to match the repo's existing import style at the top of the file rather than dynamic import if the executor prefers; dynamic import shown only to keep the snippet self-contained.) - -**Step 4:** Run unit test → PASS. - -**Step 5:** Wire into `content.create` (~554, the `link`/`resource` branch) and `content.update`/`content.publish` going-live, and `post.create`/`post.update`. For `link`/`resource` with an `externalUrl`: -```ts -const normalized = normalizeUrl(input.externalUrl); -if (normalized) { - const dupe = await findFreshDuplicateLink(ctx.db, normalized); - if (dupe) { - throw new TRPCError({ - code: "CONFLICT", - message: "This link was already shared recently. Find it on Codú instead of reposting.", - }); - } -} -// store the normalized value: -externalUrlNormalized: normalized, -``` -**DRY note:** these checks belong in the unified gate helper from Phase 4 — if doing Phase 4 first, add this there. Otherwise add here and migrate. - -**Step 6:** Verify via E2E in Phase 10 (post a link twice → second is rejected). For now manual check. - -**Step 7:** Commit: `git commit -am "feat(moderation): 6-month link freshness dedupe"` - ---- - -### Task 2.3: Discussion/question similarity dedupe (pg_trgm) - -**Files:** Add to `server/lib/dedupe.ts`; wire into the `discussion`/`question` branch of create. - -**Step 1:** Implement a query using the trigram index from Task 0.2: -```ts -/** Find a recent same/very-similar discussion or question by title. */ -export async function findSimilarDiscussion( - db: typeof import("@/server/db").db, - title: string, -): Promise<{ id: string; slug: string | null; similarity: number } | null> { - const cutoff = new Date(); - cutoff.setMonth(cutoff.getMonth() - FRESHNESS_MONTHS); - const { sql } = await import("drizzle-orm"); - // similarity() from pg_trgm; threshold tuned below. - const rows = await db.execute(sql` - SELECT id, slug, similarity(lower(title), lower(${title})) AS sim - FROM "posts" - WHERE type IN ('discussion','question') - AND status = 'published' - AND "publishedAt" >= ${cutoff.toISOString()} - AND similarity(lower(title), lower(${title})) > 0.5 - ORDER BY sim DESC - LIMIT 1 - `); - const r = (rows as unknown as { rows: any[] }).rows?.[0]; - return r ? { id: r.id, slug: r.slug, similarity: Number(r.sim) } : null; -} -``` -(Confirm the drizzle `db.execute` return shape in this codebase — adjust `.rows` access accordingly.) - -**Step 2:** Wire into create for `discussion`/`question`: -```ts -if (input.type === "discussion" || input.type === "question") { - const similar = await findSimilarDiscussion(ctx.db, input.title); - if (similar && similar.similarity >= 0.8) { - throw new TRPCError({ code: "CONFLICT", - message: "This has already been asked recently — join the existing discussion." }); - } - if (similar) { - // very similar but not near-identical → let a human decide - forceInReview = true; // consumed by the gate to set status in_review - } -} -``` - -**Step 3:** Tune thresholds (0.5 candidate / 0.8 block / between → review) against real titles during verification; document chosen values in a comment. - -**Step 4:** Commit: `git commit -am "feat(moderation): discussion/question similarity dedupe via pg_trgm"` - ---- - -## Phase 3 — Bedrock Haiku auto-review - -### Task 3.1: Bedrock client (mockable) - -**Files:** `package.json`; create `server/lib/bedrock.ts` - -**Step 1:** `npm i @aws-sdk/client-bedrock-runtime` - -**Step 2:** Create `server/lib/bedrock.ts` mirroring `s3helpers.ts` + the email mock pattern: -```ts -import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime"; - -const hasKeys = process.env.ACCESS_KEY && process.env.SECRET_KEY; - -export const bedrockClient = new BedrockRuntimeClient({ - region: process.env.BEDROCK_REGION || "eu-west-1", - ...(hasKeys ? { - credentials: { - accessKeyId: process.env.ACCESS_KEY || "", - secretAccessKey: process.env.SECRET_KEY || "", - }, - } : {}), -}); - -export function isBedrockEnabled(): boolean { - return Boolean(process.env.BEDROCK_MODEL_ID && process.env.ACCESS_KEY); -} -``` - -**Step 3:** Commit: `git commit -am "feat: bedrock runtime client"` - ---- - -### Task 3.2: `parseVerdict()` (Vitest TDD) - -**Files:** `server/lib/autoReview.ts` (start it) + `server/lib/autoReview.test.ts` - -**Step 1 (failing test):** -```ts -import { describe, it, expect } from "vitest"; -import { parseVerdict } from "./autoReview"; - -describe("parseVerdict", () => { - it("parses an allow verdict", () => { - expect(parseVerdict('{"verdict":"allow","category":"none","reason":""}')) - .toEqual({ verdict: "allow", category: "none", reason: "" }); - }); - it("parses a review verdict with reason", () => { - const v = parseVerdict('{"verdict":"review","category":"crypto","reason":"token shill"}'); - expect(v.verdict).toBe("review"); - expect(v.category).toBe("crypto"); - }); - it("defaults to allow (fail-open) on garbage", () => { - expect(parseVerdict("not json").verdict).toBe("allow"); - }); - it("treats unknown verdict string as review (fail-safe for content)", () => { - expect(parseVerdict('{"verdict":"banana"}').verdict).toBe("review"); - }); -}); -``` -Design note for the executor: parsing **garbage/empty** (model/infra failure) fails **open** (allow — never block on infra failure), but a successfully-parsed-but-unexpected verdict value fails **safe** (review). Encode exactly that. - -**Step 2:** Run → FAIL. - -**Step 3:** Implement `parseVerdict` in `autoReview.ts`: -```ts -export type Verdict = { verdict: "allow" | "review"; category: string; reason: string }; - -export function parseVerdict(raw: string): Verdict { - let obj: any; - try { obj = JSON.parse(extractJson(raw)); } - catch { return { verdict: "allow", category: "none", reason: "unparseable" }; } - const v = obj?.verdict; - if (v === "allow") return { verdict: "allow", category: obj.category ?? "none", reason: obj.reason ?? "" }; - // any explicit non-allow (including unknown) → review - return { verdict: "review", category: obj?.category ?? "unknown", reason: obj?.reason ?? "" }; -} - -function extractJson(raw: string): string { - const start = raw.indexOf("{"); - const end = raw.lastIndexOf("}"); - return start >= 0 && end > start ? raw.slice(start, end + 1) : raw; -} -``` - -**Step 4:** Run → PASS. Commit: `git commit -am "feat: parseVerdict for auto-review"` - ---- - -### Task 3.3: Link page fetch util (Vitest TDD with injected fetch) - -**Files:** `server/lib/fetchPage.ts` + `server/lib/fetchPage.test.ts` - -**Step 1 (failing test, inject fetch):** -```ts -import { describe, it, expect } from "vitest"; -import { extractReadableText } from "./fetchPage"; - -describe("extractReadableText", () => { - it("pulls title and strips tags/scripts", () => { - const html = "<html><head><title>Hi

Hello world

"; - const out = extractReadableText(html); - expect(out).toContain("Hi"); - expect(out).toContain("Hello world"); - expect(out).not.toContain("script"); - }); - it("caps length", () => { - const out = extractReadableText("

" + "a".repeat(10000) + "

"); - expect(out.length).toBeLessThanOrEqual(4000); - }); -}); -``` - -**Step 2:** Run → FAIL. - -**Step 3:** Implement `fetchPage.ts`: -```ts -const MAX_TEXT = 4000; - -export function extractReadableText(html: string): string { - const title = /]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim() ?? ""; - const body = html - .replace(//gi, " ") - .replace(//gi, " ") - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim(); - return `${title}\n${body}`.slice(0, MAX_TEXT); -} - -/** Fetch a URL with a hard timeout + size cap; returns readable text or "". Never throws. */ -export async function fetchPageText(url: string, timeoutMs = 4000): Promise { - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), timeoutMs); - try { - const res = await fetch(url, { signal: ctrl.signal, redirect: "follow", - headers: { "user-agent": "CoduModerationBot/1.0" } }); - if (!res.ok) return ""; - const html = (await res.text()).slice(0, 200_000); - return extractReadableText(html); - } catch { return ""; } - finally { clearTimeout(t); } -} -``` - -**Step 4:** Run → PASS. Commit: `git commit -am "feat: fetchPageText link pre-visit util"` - ---- - -### Task 3.4: `autoReview()` — Haiku call (REQUIRED SUB-SKILL: claude-api) - -**Files:** finish `server/lib/autoReview.ts` - -**Step 1:** READ the claude-api skill to confirm: the correct Bedrock Haiku model id / regional inference-profile for `BEDROCK_MODEL_ID`, and the Bedrock `InvokeModel` Messages API request/response JSON shape. Default to the latest Haiku. - -**Step 2:** Implement, gated + fail-open, returning a `Verdict`: -```ts -import { bedrockClient, isBedrockEnabled } from "./bedrock"; -import { InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"; -import { fetchPageText } from "./fetchPage"; -import { screenContent } from "./moderation"; - -const SYSTEM = `You are the content moderator for Codú, a community for AI builders and indie hackers. -Be LOOSE AND FAIR. Allow by default — including people sharing their own projects, launches, and side-projects. -Only return "review" for the obvious: pornographic/NSFW content, crypto/token/coin shilling, malicious or scam links, -dead or fake sources, or content plainly off-theme for a developer community. -Reply ONLY with JSON: {"verdict":"allow"|"review","category":string,"reason":string}.`; - -export async function autoReview(input: { - type: string; title: string; body?: string | null; externalUrl?: string | null; -}): Promise { - if (!isBedrockEnabled()) { - // Fall back to the cheap heuristic; never block. - const s = screenContent({ title: input.title, body: input.body }); - return s.ok ? { verdict: "allow", category: "none", reason: "" } - : { verdict: "review", category: "heuristic", reason: s.reasons.join(",") }; - } - let pageText = ""; - if (input.externalUrl) pageText = await fetchPageText(input.externalUrl); - const userMsg = [ - `Type: ${input.type}`, - `Title: ${input.title}`, - input.body ? `Body: ${input.body.slice(0, 4000)}` : "", - input.externalUrl ? `URL: ${input.externalUrl}` : "", - pageText ? `Fetched page:\n${pageText}` : "", - ].filter(Boolean).join("\n"); - - try { - const res = await bedrockClient.send(new InvokeModelCommand({ - modelId: process.env.BEDROCK_MODEL_ID!, - contentType: "application/json", - accept: "application/json", - body: JSON.stringify({ - anthropic_version: "bedrock-2023-05-31", // confirm via claude-api skill - max_tokens: 200, - system: SYSTEM, - messages: [{ role: "user", content: userMsg }], - }), - })); - const decoded = JSON.parse(new TextDecoder().decode(res.body)); - const text = decoded?.content?.[0]?.text ?? ""; // confirm shape via claude-api skill - return parseVerdict(text); - } catch (err) { - // Fail OPEN on infra error — never block publishing. - Sentry.captureException(err); - return { verdict: "allow", category: "none", reason: "bedrock-error" }; - } -} -``` - -**Step 3:** No live Bedrock call in unit tests; `parseVerdict`/`fetchPageText` cover the pure parts. Add a Vitest test that `autoReview` returns `allow` when `isBedrockEnabled()` is false and content is clean (set env accordingly). - -**Step 4:** Commit: `git commit -am "feat(moderation): bedrock haiku auto-review (gated, fail-open)"` - ---- - -## Phase 4 — Unify the moderation gate - -### Task 4.1: Single `gatePublish()` helper; wire all 5 call sites - -**Files:** `server/lib/moderation.ts`; modify `content.ts` (create/update/publish), `post.ts` (create/update) - -**Step 1:** Add a single async helper that encapsulates the whole decision, so the five call sites stop duplicating logic: -```ts -export interface GateResult { - status: "published" | "in_review"; - publishedAt: string | null; - moderationNote: string | null; - externalUrlNormalized: string | null; -} - -/** - * Decide the stored status for a would-be-published post. - * - articles ALWAYS go to in_review (editorial gate) - * - everything else: auto-review verdict decides (allow→published, review→in_review) - * - dedupe (link freshness / discussion similarity) handled by callers BEFORE this - * (they throw CONFLICT) or pass forceInReview. - * Returns published if moderation disabled. - */ -export async function gatePublish(input: { - type: string; title: string; body?: string | null; - externalUrl?: string | null; forceInReview?: boolean; -}): Promise { - const normalized = input.externalUrl ? normalizeUrl(input.externalUrl) : null; - if (!isModerationEnabled()) { - return { status: "published", publishedAt: new Date().toISOString(), - moderationNote: null, externalUrlNormalized: normalized }; - } - if (input.type === "article") { - const v = await autoReview(input); // advisory note for the human editor - return { status: "in_review", publishedAt: null, - moderationNote: noteFrom(v), externalUrlNormalized: normalized }; - } - if (input.forceInReview) { - return { status: "in_review", publishedAt: null, - moderationNote: "similar-existing", externalUrlNormalized: normalized }; - } - const v = await autoReview(input); - if (v.verdict === "review") { - return { status: "in_review", publishedAt: null, - moderationNote: noteFrom(v), externalUrlNormalized: normalized }; - } - return { status: "published", publishedAt: new Date().toISOString(), - moderationNote: null, externalUrlNormalized: normalized }; -} - -function noteFrom(v: Verdict): string | null { - return v.verdict === "review" ? `${v.category}: ${v.reason}`.slice(0, 500) : null; -} -``` - -**Step 2:** In each of the 5 call sites, replace the inline `moderated = ... ; screenContent(...); dbStatus = ...` block with: run dedupe checks (Task 2.2/2.3, throwing CONFLICT or setting forceInReview), then `const gate = await gatePublish({...})`, then write `status: gate.status, publishedAt: gate.publishedAt, moderationNote: gate.moderationNote, externalUrlNormalized: gate.externalUrlNormalized`. Keep the existing `notifyAdminOfReview` call when `gate.status === "in_review"`. Keep the points-award only when `gate.status === "published"`. - -**Step 3:** Delete the now-unused inline `screenContent`/`isModerationEnabled` branches in those handlers (they live inside `gatePublish` now). Keep `screenContent` exported (used as the Bedrock fallback). - -**Step 4:** Run `npm run lint` + `npm run test:unit`. Manual smoke: publish an article with `MODERATION_ENABLED=true` → `in_review`; publish a clean `til` with Bedrock disabled → `published`. - -**Step 5:** Commit: `git commit -am "refactor(moderation): unify publish gate across content & post routers"` - ---- - -## Phase 5 — Flag/report into the DB queue - -### Task 5.1: `report.create` accepts posts (postId) - -**Files:** `schema/report.ts` (CreateReportSchema), `server/api/router/report.ts` (create ~202) - -**Step 1:** Add `postId: z.string().optional()` to `CreateReportSchema` alongside `contentId`/`discussionId`. Require exactly one target. - -**Step 2:** In `report.create`, when `postId` is provided: validate the post exists (`db.query` on `posts`), **dedupe per reporter+post** (one open report per user per post), insert `content_report` with `postId`, and email the admin via `getAppOrigin()` → `/admin/moderation?item=`. Content stays live (no status change). - -**Step 3:** Commit: `git commit -am "feat(report): store post reports in DB queue + notify admin"` - ---- - -### Task 5.2: Route post reports through the DB mutation - -**Files:** `components/ReportModal/ReportModal.tsx:152-178` - -**Step 1:** Change the `type === "post"` branch from `sendReport(...)` (legacy email) to: -```ts -createReport({ postId: id as string, reason: "OTHER", details: reportBody || undefined }); -``` -Leave `comment`/`article` legacy paths as-is unless they also target `posts` (verify; if articles are `posts`-table, route them through `postId` too). - -**Step 2:** Commit: `git commit -am "feat(report): post flags land in admin DB queue, not email-only"` - ---- - -### Task 5.3: Flag button on every content surface - -**Files:** feed card (`components/UnifiedContentCard/UnifiedContentCard.tsx`), detail components (`_feedArticleContent.tsx`, `_linkContentDetail.tsx`, `_userLinkDetail.tsx`), confirm `ArticleActionBar`/`ContentDetail/ActionBar` already have it. - -**Step 1:** Add `` (or `icon`) to any surface missing it. Reuse the existing component — do not build a new one. - -**Step 2:** Manual check each surface renders the flag affordance and opens the modal. - -**Step 3:** Commit: `git commit -am "feat(report): flag button on feed cards and all detail pages"` - ---- - -## Phase 6 — Admin moderation queue - -### Task 6.1: Merge in_review + reported-live into the queue - -**Files:** `server/api/router/admin.ts` (`listInReview` ~218 → extend/add), `server/api/router/report.ts` (`getAll`/`getCounts`) - -**Step 1:** Provide the admin page two lists (or one unified list): (a) posts with `status = in_review` (include `moderationNote`), (b) **live** posts that have open `content_report` rows (join `content_report.postId`, `status = PENDING`), with report count + latest reason. Keep within `adminOnlyProcedure`. - -**Step 2:** Commit: `git commit -am "feat(admin): moderation queue shows in-review + reported-live posts"` - ---- - -### Task 6.2: Decline-with-note, hide-live, dismiss-report actions - -**Files:** `server/api/router/admin.ts` (`moderatePost` ~239) - -**Step 1:** Extend `moderatePost`: -- `approve` → `published` + publishedAt + award (exists). -- `reject` (decline) → `rejected`, set `moderationNote` from an optional `note` input ("Hidden by moderator" reason). **Do not delete.** -- New `hide` decision (for a live reported post) → `in_review` (or `rejected`), allowed when `status = published`. Loosen the current `status !== "in_review"` guard to also allow `published` for `hide`. - -**Step 2:** Add `report.review` usage so dismissing a report sets `content_report.status = DISMISSED`/`ACTIONED` and stamps `reviewedById`/`reviewedAt` (the procedure already exists — wire the UI to it). - -**Step 3:** Commit: `git commit -am "feat(admin): decline-with-note, hide-live, dismiss-report"` - ---- - -### Task 6.3: Admin queue UI - -**Files:** `app/(app)/admin/moderation/_client.tsx` - -**Step 1:** Render both lists; show `moderationNote` (why auto-flagged) and report details. Wire Approve / Decline(+note) / Hide / Dismiss buttons to the mutations. Honour the `?item=` query param from the email to scroll/highlight that item. - -**Step 2:** Manual verify the full loop: flag a post → appears in queue → decline → author sees "Hidden by moderator". - -**Step 3:** Commit: `git commit -am "feat(admin): moderation queue UI actions + deep-link"` - ---- - -## Phase 7 — Loader UX + author visibility - -### Task 7.1: "Reviewing your post" loader - -**Files:** `app/(editor)/create/[[...paramsArr]]/_client.tsx` (publish flow ~204-249, loading ~195-198) - -**Step 1:** While `publishStatus === "pending"`, show a themed overlay "Reviewing your post…" with 3-4 rotating playful lines (auto-review can take a few seconds). On resolve, branch on returned `status`: `published` → go to the post; `in_review` → a "Submitted for review" confirmation explaining it's awaiting approval (don't route to a 404'd detail page). - -**Step 2:** Manual verify both branches (toggle `MODERATION_ENABLED`). - -**Step 3:** Commit: `git commit -am "feat(editor): auto-review loader + submitted-for-review state"` - ---- - -### Task 7.2: Author can view their own hidden post - -**Files:** `app/(app)/[username]/[slug]/page.tsx` (`getUserPost` where-clause ~75-91) - -**Step 1:** The detail fetcher hard-filters `status = published`, so an author hitting their own `in_review`/`rejected` post gets a 404. Add an **owner bypass**: if the viewing session user is the author, allow `in_review`/`rejected` and render a banner ("Awaiting review" / "Hidden by moderator" + `moderationNote`). Public visitors still get 404. - -**Step 2:** Manual verify: author sees banner; logged-out visitor gets 404; feed still excludes it (already does). - -**Step 3:** Commit: `git commit -am "feat: authors can view their own in-review/hidden posts with status banner"` - ---- - -## Phase 8 — CDK + env - -### Task 8.1: Grant `bedrock:InvokeModel` to the app IAM user - -**Files:** `cdk/lib/iam-stack.ts` (~33-47) - -**Step 1:** Add a PolicyStatement to `appUser`, scoped to the Haiku model / regional inference-profile ARN(s) in `BEDROCK_REGION` (not `*`): -```ts -appUser.addToPolicy(new iam.PolicyStatement({ - sid: "AppBedrockInvoke", - actions: ["bedrock:InvokeModel"], - resources: [ - // e.g. arn:aws:bedrock:::foundation-model/ - // and the inference-profile ARN if using a regional profile. - `arn:aws:bedrock:${props.region ?? "eu-west-1"}::foundation-model/*`, - ], -})); -``` -Tighten `resources` to the exact model/profile ARNs once confirmed (claude-api skill / Bedrock console). - -**Step 2:** `cd cdk && npx cdk diff` (with the provided CDK keys exported) to confirm only the policy changes. **User runs `cdk deploy`** per account. - -**Step 3:** Commit: `git commit -am "feat(cdk): grant app IAM user bedrock:InvokeModel"` - ---- - -### Task 8.2: Declare env vars - -**Files:** `config/env.js`, `.env.example` (if present) - -**Step 1:** Add to `server` AND `runtimeEnv` blocks: `BEDROCK_REGION`, `BEDROCK_MODEL_ID`, `MODERATION_ENABLED` (`z.enum(["true","false"]).optional()`), `ADMIN_EMAIL` (`z.string().email().optional()`). Document required values in `.env.example`. - -**Step 2:** `npm run build` to confirm env validation passes (with vars set/optional). - -**Step 3:** Commit: `git commit -am "chore(env): declare bedrock + moderation env vars"` - ---- - -## Phase 9 — Account-block regression test (E2E) - -### Task 9.1: Lock in ban-hides-posts - -**Files:** `e2e/` (new spec, match existing Playwright conventions) - -**Step 1:** E2E: admin bans a user who has a published post → that post 404s on its detail page and is absent from the feed; unban restores it. (Confirms `admin.ban`/`unban` + `banned_users` feed joins still work.) - -**Step 2:** `npm test -- ` → PASS. - -**Step 3:** Commit: `git commit -am "test(e2e): banning an author hides their posts"` - ---- - -## Phase 10 — E2E flows - -### Task 10.1: Publish → auto-review → in_review - -E2E with `MODERATION_ENABLED=true`, Bedrock disabled (heuristic path): publish an article → lands `in_review`, not on the feed; author's my-posts shows "In review". Commit. - -### Task 10.2: Flag → queue → decline → hidden - -E2E: user flags a live post → admin queue shows it → admin declines with note → post 404s for public, author sees "Hidden by moderator". Commit. - -### Task 10.3: Link dedupe - -E2E: post a link, then post the same link again → second submit shows the "already shared recently" error. Commit. - ---- - -## Final verification (superpowers:verification-before-completion) - -- `npm run test:unit` green; targeted Playwright specs green; `npm run lint` clean; `npm run build` clean. -- Manual: moderation email link resolves to `/admin/moderation?item=…` (the original bug). -- Confirm Bedrock works end-to-end in a deployed/preview env with real keys (unit tests can't cover the live call). - -## Notes / risks - -- **Secrets:** the pasted CDK keys are temporary STS (deploy-only); never commit them. Runtime Bedrock uses the app IAM user's `ACCESS_KEY`/`SECRET_KEY`. -- **Bedrock region:** Haiku must be enabled in `BEDROCK_REGION` for the account; may require requesting model access in the Bedrock console + a regional inference profile id. -- **Bedrock model id (confirmed enabled in dev+prod, 2026-06-09):** `anthropic.claude-haiku-4-5-20251001-v1:0`. Use as `BEDROCK_MODEL_ID`. At implementation, check whether the region requires a cross-region inference-profile prefix (e.g. `eu.anthropic.claude-haiku-4-5-20251001-v1:0` / `us.anthropic...`) vs the bare foundation-model id; the IAM grant (Task 8.1) must cover whichever ARN form is used. -- **Latency:** synchronous link-fetch (≤4s) + Haiku (~1-2s) on publish is acceptable per design; loader covers it; all fail open. -- **Dedupe TOCTOU:** link/discussion dedupe is check-then-insert, and `posts_external_url_normalized_idx` is non-unique, so two concurrent publishes of the same URL can both pass the check and both publish. Acceptable as spam-friction (moderation is default-off); the real fix, if dedupe is ever treated as authoritative, is a partial unique index `(externalUrlNormalized) WHERE status = 'published'`. diff --git a/docs/plans/2026-06-10-content-url-restructure-seo-aeo.md b/docs/plans/2026-06-10-content-url-restructure-seo-aeo.md deleted file mode 100644 index 4b8bfaea..00000000 --- a/docs/plans/2026-06-10-content-url-restructure-seo-aeo.md +++ /dev/null @@ -1,619 +0,0 @@ -# Content URL Restructure for SEO/AEO Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Move all content to stable, id-resolved, SEO/AEO-optimized URLs (`/{username}/{slug}-{urlId}` for member content, `/d/{slug}-{urlId}` for discussions/questions, `/s/{sourceSlug}/...` for aggregated links), with 301 self-correction, forum/profile structured data, AI-crawler access, and full test + redirect coverage — without losing existing SEO. - -**Architecture:** Every content URL resolves on a short immutable `urlId`; the slug and username segments are decorative and 301-corrected to canonical on any mismatch (safe title edits + username renames forever). Discussions/questions get a dedicated `/d/` forum namespace. Structured data (`DiscussionForumPosting`, `ProfilePage`, `Article`, `BreadcrumbList`, `Organization`) extends the existing `lib/structured-data/` builders. AEO work hardens `robots.ts`, splits sitemaps by type, and wires Bing IndexNow. - -**Tech Stack:** Next.js App Router (RSC), Drizzle ORM (Postgres), tRPC v11, Playwright (e2e), Vitest (unit), `next/font`, nanoid, schema.org JSON-LD. - -**Decisions locked (flip any before starting):** - -- Questions == discussions → single `/d/` namespace, `DiscussionForumPosting` schema. -- Feed sources move to `/s/{sourceSlug}` (frees top-level namespace). -- `urlId`: backfill from each post's existing trailing slug-hash (old links resolve unchanged). -- Google-Extended stays allowed. -- `/tag/{slug}` pages: reserve namespace now, build as a fast-follow (out of scope here). -- Member link-posts canonical = Codú; aggregated RSS links canonical = original source URL. - ---- - -## Phase 0 — Foundations - -### Task 1: `urlId` column + migration + backfill - -**Files:** - -- Modify: `server/db/schema.ts` (posts table, ~line 332) -- Create: `drizzle/00XX_add_post_url_id.sql` (via `npm run db:generate`) -- Create: `drizzle/backfill-url-id.ts` - -**Step 1:** Add the column to the `posts` table (after `slug`): - -```ts -// Short, immutable, URL-safe id — the canonical resolver for content URLs. -// The slug/username in the path are decorative and 301-corrected to match this. -urlId: varchar("url_id", { length: 16 }), -``` - -Add a unique index in the table's index block: - -```ts -urlIdKey: uniqueIndex("posts_url_id_key").on(table.urlId), -``` - -**Step 2:** Generate the migration: `npm run db:generate` → confirm a single `ADD COLUMN "url_id"` + unique index. - -**Step 3:** Write `drizzle/backfill-url-id.ts`: for every `posts` row with `urlId IS NULL`, set `urlId` to the trailing hex of the existing slug (regex `/-([0-9a-f]{4,})$/`); if no match or a collision, mint `nanoid(8)` (lowercase alphanumeric alphabet). Idempotent (skip rows that already have one). Use the `dotenv/config` + `postgres-js` pattern from `drizzle/migrate.ts`. - -**Step 4:** Make `urlId` `NOT NULL` in a follow-up migration once backfill has run in every environment (separate task at deploy time — do not block). - -**Step 5:** Update `generateSlug` usage in `server/api/router/content.ts` (~line 559): on create, mint `urlId = nanoid(8)` and insert it; keep `generateSlug(title)` for the decorative slug. - -**Step 6:** Commit: `feat(db): add immutable urlId to posts for stable content URLs`. - ---- - -### Task 2: Reserved-namespace list + username validation - -**Files:** - -- Create: `server/lib/reserved-usernames.ts` -- Create: `server/lib/reserved-usernames.test.ts` -- Modify: `schema/profile.ts:7-14` (saveSettingsSchema.username) - -**Step 1 (TDD):** Write `server/lib/reserved-usernames.test.ts`: - -```ts -import { describe, it, expect } from "vitest"; -import { isReservedUsername } from "./reserved-usernames"; - -describe("isReservedUsername", () => { - it("blocks live route names", () => { - expect(isReservedUsername("settings")).toBe(true); - expect(isReservedUsername("d")).toBe(true); - expect(isReservedUsername("articles")).toBe(true); - }); - it("blocks single letters and impersonation handles", () => { - expect(isReservedUsername("x")).toBe(true); - expect(isReservedUsername("codu")).toBe(true); - expect(isReservedUsername("admin")).toBe(true); - }); - it("is case-insensitive", () => { - expect(isReservedUsername("Settings")).toBe(true); - }); - it("allows real handles", () => { - expect(isReservedUsername("niall-maher")).toBe(false); - }); -}); -``` - -**Step 2:** Run → fails (module missing). - -**Step 3:** Create `server/lib/reserved-usernames.ts` — a `Set` of all reserved names (categories from the plan review: live routes, infra/well-known, auth/account, future sections, legal, impersonation/safety, all single letters `a`–`z`), plus `export function isReservedUsername(name: string): boolean { return RESERVED.has(name.trim().toLowerCase()); }`. - -**Step 4:** Run → passes. - -**Step 5:** Wire into `schema/profile.ts` username field with `.refine((u) => !isReservedUsername(u), "That username is reserved.")`. (Import is server-safe; if a client bundle complains, move the refine into the `profile.updateProfile` mutation in `server/api/router/profile.ts` instead.) - -**Step 6:** Commit: `feat(profile): reserve top-level namespaces from usernames`. - ---- - -### Task 3: Pure URL helpers (the heart of routing) - -**Files:** - -- Create: `server/lib/content-url.ts` -- Create: `server/lib/content-url.test.ts` - -**Step 1 (TDD):** Write `server/lib/content-url.test.ts`: - -```ts -import { describe, it, expect } from "vitest"; -import { - parseUrlId, - buildMemberPath, - buildDiscussionPath, - canonicalMismatch, -} from "./content-url"; - -describe("parseUrlId", () => { - it("extracts the trailing url id from a decorated path segment", () => { - expect(parseUrlId("why-rag-beats-finetuning-a1b2c3d4")).toBe("a1b2c3d4"); - }); - it("returns the whole segment when there is no slug prefix", () => { - expect(parseUrlId("a1b2c3d4")).toBe("a1b2c3d4"); - }); -}); - -describe("path builders", () => { - it("builds the canonical member path", () => { - expect(buildMemberPath("niall-maher", "why-rag-x", "a1b2c3d4")).toBe( - "/niall-maher/why-rag-x-a1b2c3d4", - ); - }); - it("builds the canonical discussion path", () => { - expect(buildDiscussionPath("how-do-you-test", "7x8y9z01")).toBe( - "/d/how-do-you-test-7x8y9z01", - ); - }); -}); - -describe("canonicalMismatch", () => { - it("is true when the requested path differs from canonical", () => { - expect( - canonicalMismatch("/niall/old-a1b2c3d4", "/niall-maher/new-a1b2c3d4"), - ).toBe(true); - }); - it("is false when they match", () => { - expect(canonicalMismatch("/d/x-7x8y9z01", "/d/x-7x8y9z01")).toBe(false); - }); -}); -``` - -**Step 2:** Run → fails. - -**Step 3:** Implement `server/lib/content-url.ts`: `parseUrlId` (split on last `-`, return last token), `buildMemberPath`, `buildDiscussionPath`, `buildSourcePath`, `canonicalMismatch` (string-equality ignoring querystring). Pure, no imports of `db`/`next`. - -**Step 4:** Run → passes. - -**Step 5:** Commit: `feat(url): pure helpers for content url parsing + canonicalization`. - ---- - -## Phase 1 — Routing & redirects - -### Task 4: Member content route resolves on `urlId` + self-corrects - -**Files:** - -- Modify: `app/(app)/[username]/[slug]/page.tsx` (resolvers ~470-960) - -**Step 1:** Change resolution to: `parseUrlId(params.slug)` → look up post by `urlId` (+ that it's a member, non-discussion type). If found, compute canonical `buildMemberPath(author.username, post.slug, post.urlId)`; if `canonicalMismatch(requestedPath, canonical)` → `permanentRedirect(canonical)` (handles both wrong slug AND renamed username). If not found by id, fall back to the existing slug+username lookup (legacy links) and `permanentRedirect` to the new canonical. - -**Step 2:** Keep the page a **server component** (content in initial HTML — required for AEO). No client-only data fetching for the body. - -**Step 3:** Deleted/unpublished member content → return **410 Gone** via a dedicated `not-found`/status path rather than soft-404. - -**Step 4:** Manual verify (dev server): create an article, edit its title, hit the old URL → 301 to new; rename the username → old author path 301s to new. - -**Step 5:** Commit: `feat(routing): resolve member content by urlId with 301 canonicalization`. - ---- - -### Task 5: New `/d/` discussion namespace + SSR detail page - -**Files:** - -- Create: `app/(app)/d/[slug]/page.tsx` -- Reuse: the existing discussion detail rendering (currently reached via `[username]/[slug]`) - -**Step 1:** New server-component route `app/(app)/d/[slug]/page.tsx`: `parseUrlId` → look up discussion/question by `urlId` → 301-correct slug → render OP + comments **server-side** (full thread in initial HTML). - -**Step 2:** Emit `DiscussionForumPosting` JSON-LD (Task 10) with nested `Comment[]`. - -**Step 3:** Comment anchors: each rendered comment gets `id="comment-{commentId}"`; the page supports `#comment-{id}` scroll. No standalone comment route. - -**Step 4:** Old discussion URLs (`/{username}/{discussion-slug}`) → 301 to `/d/{slug}-{urlId}` (handled in Task 8 + the Task 4 fallback when the resolved type is a discussion). - -**Step 5:** Commit: `feat(routing): dedicated /d/ namespace for discussions + questions (SSR)`. - ---- - -### Task 6: Fix the broken `/feed/${id}` card fallback (#4) - -**Files:** - -- Modify: `components/UnifiedContentCard/UnifiedContentCard.tsx:109-117` -- Modify: `components/SavedItemCard/SavedItemCard.tsx:63-68` - -**Step 1 (TDD where possible):** Build `cardUrl` via the Task 3 helpers from `urlId` + kind: - -- discussion/question → `buildDiscussionPath(slug, urlId)` -- aggregated link (has `source`) → `buildSourcePath(source.slug, slug, urlId)` -- member content → `buildMemberPath(author.username, slug, urlId)` -- If `urlId` is somehow missing → render the card **without** a broken link (no `/feed/${id}`); log to Sentry. - -**Step 2:** Ensure `content.getFeed` and `discussion.*` queries **select `urlId`** and pass it to the card (modify the tRPC selects + the `_client.tsx` prop mapping at `app/(app)/feed/_client.tsx:226-262` and `app/(app)/discussions/_client.tsx:165-173`). - -**Step 3:** Manual verify: the screenshot's link card now navigates to a real page, never `/[id]`. - -**Step 4:** Commit: `fix(feed): build content urls from urlId; kill /feed/:id fallback`. - ---- - -### Task 7: Feed sources → `/s/{sourceSlug}` - -**Files:** - -- Create: `app/(app)/s/[sourceSlug]/page.tsx` (move from `[username]` source branch) -- Create: `app/(app)/s/[sourceSlug]/[slug]/page.tsx` (aggregated article; canonical = original source URL) -- Modify: `app/(app)/[username]/page.tsx` (drop the source-slug fallback; `[username]` = users only) - -**Step 1:** Aggregated link detail page emits `` (NOT Codú) — we republish, we don't outrank the source. - -**Step 2:** Source profile + aggregated article 301s handled in Task 8. - -**Step 3:** Commit: `feat(routing): move feed sources under /s/ namespace`. - ---- - -### Task 8: Redirect map (one hop) + comment anchors - -**Files:** - -- Modify: `next.config.js:27-54` (`redirects()`) -- Modify: `app/(app)/articles/[slug]/page.tsx` (already redirect-only — point at new canonical) - -**Step 1:** Add permanent redirects (single hop, point directly at final canonical): - -- `/feed/:sourceSlug` → `/s/:sourceSlug` -- `/feed/:sourceSlug/:articleId` → `/s/:sourceSlug/:articleId` -- Old `/{sourceSlug}` and `/{sourceSlug}/{slug}` source URLs → `/s/...` (route-level redirect in the `[username]` resolver when the segment resolves to a source, not a user) -- `/articles/:slug` → resolve → `/{username}/{slug}-{urlId}` - -**Step 2:** Remove the now-obsolete `/feed/:sourceSlug → /:sourceSlug` rule (it caused the `/[id]` 404 chain). Verify **no redirect chains** (old → final in one hop). - -**Step 3:** Commit: `feat(seo): one-hop 301 redirect map for legacy content urls`. - ---- - -### Task 9: Global case + trailing-slash normalization - -**Files:** - -- Modify: `next.config.js` (`trailingSlash: false`) and/or a lightweight `middleware.ts` - -**Step 1:** Enforce no trailing slash + lowercase host/path for content routes; 301 `/Niall-Maher/...` → lowercase. Keep middleware minimal (it runs on every request). - -**Step 2:** Commit: `feat(seo): normalize trailing slash + case to canonical`. - ---- - -## Phase 2 — Structured data (extend `lib/structured-data/`) - -> JSON-LD templates for all four page types are in the **Appendix** below. - -### Task 10: `DiscussionForumPosting` builder (priority #1) - -**Files:** - -- Create: `lib/structured-data/schemas/discussion-forum-posting.ts` -- Create: `lib/structured-data/schemas/discussion-forum-posting.test.ts` -- Modify: `lib/structured-data/index.ts` (export), `lib/structured-data/types.ts` (types) -- Wire into: `app/(app)/d/[slug]/page.tsx` - -**Step 1 (TDD):** Test that the builder emits `@type: "DiscussionForumPosting"`, `headline`, `datePublished`, honest `dateModified`, `author` as `Person` with `url` → profile, `interactionStatistic` (comment/upvote counts), and `comment: Comment[]` each with `author` + `dateCreated` + `text`. - -**Step 2-4:** Implement → pass. - -**Step 5:** Commit: `feat(seo): DiscussionForumPosting + nested Comment JSON-LD`. - ---- - -### Task 11: `ProfilePage` builder (priority #3) - -**Files:** - -- Create: `lib/structured-data/schemas/profile-page.ts` (+ test) -- Wire into: `app/(app)/[username]/page.tsx` - -**Step 1 (TDD):** `@type: "ProfilePage"`, `mainEntity` = `Person` (name, url, image, sameAs socials), `dateCreated`. Establishes the author as an entity (E-E-A-T). - -**Step 5:** Commit: `feat(seo): ProfilePage JSON-LD on user profiles`. - ---- - -### Task 12: Article audit + remove SearchAction - -**Files:** - -- Modify: `lib/structured-data/schemas/article.ts`, `lib/structured-data/schemas/website.ts` - -**Step 1:** Ensure `Article` emits honest `dateModified` (from `posts.updatedAt`), `image`, `author.url` → `/{username}`, and `BlogPosting`/`Article` type. Member link-posts use the same builder with Codú canonical. - -**Step 2:** Remove `SearchAction` from `website.ts` (sitelinks search box deprecated). Drop the `SearchAction` export from `index.ts` if unused. - -**Step 3:** Commit: `chore(seo): honest dateModified, author.url; drop deprecated SearchAction`. - ---- - -### Task 13 + 14: Breadcrumbs + Organization - -**Files:** `app/(app)/[username]/[slug]/page.tsx`, `app/(app)/d/[slug]/page.tsx`, homepage/marketing layout. - -**Step 1:** `BreadcrumbList` on article (`Home › {username} › title`) and discussion (`Home › Discussions › title`) pages using existing `getBreadcrumbSchema`. - -**Step 2:** Verify `Organization` (logo + `sameAs` socials/GitHub) on the homepage via existing `getOrganizationSchema`. - -**Step 3:** Commit: `feat(seo): breadcrumbs on content + verify Organization on home`. - ---- - -## Phase 3 — AEO - -### Task 15: Harden `robots.ts` - -**Files:** `app/robots.ts` - -**Step 1:** Add `ClaudeBot`, `Claude-User`, `OAI-SearchBot` to the explicit allow list. **Critical fix:** give every named AI-bot rule the **same `disallow` array** as the `*` rule (a named user-agent rule overrides `*`, so the current rules expose `/settings/`, `/api/`, `/draft/`, `/notifications/` to AI crawlers). Factor the disallow list into a shared const. - -**Step 2:** Commit: `fix(seo): stop leaking private routes to AI crawlers; add ClaudeBot/OAI-SearchBot`. - ---- - -### Task 16: Rewrite sitemap, split by type - -**Files:** `app/sitemap.ts` (consider `app/sitemap.xml/route.ts` for a sitemap index) - -**Step 1:** Rebuild from current tables (`posts`/`user`, not legacy `post`): split sections — **articles** (`/{username}/{slug}-{urlId}`), **discussions** (`/d/{slug}-{urlId}`), **profiles** (`/{username}`, **filter `username IS NOT NULL`**), **sources** (`/s/...`). Honest `lastModified` from `updatedAt`. Exclude cross-posted (`canonicalUrl IS NOT NULL`) and aggregated-source items where canonical points off-site. - -**Step 2:** Split into a sitemap index if any section exceeds ~5k URLs. - -**Step 3:** Commit: `feat(seo): rebuild sitemap on new url scheme, split by type`. - ---- - -### Task 17: Bing IndexNow - -**Files:** - -- Create: `public/{indexnow-key}.txt`, `server/lib/indexnow.ts` -- Wire into: publish/edit/delete in `server/api/router/content.ts`, `admin.ts` (approval) - -**Step 1:** On publish/approve/edit/delete, POST the canonical URL to IndexNow (`https://api.indexnow.org/indexnow`) with the key. Fire-and-forget, wrapped in try/catch + Sentry. Verify Bing Webmaster Tools separately (ops task). - -**Step 2:** Commit: `feat(aeo): IndexNow ping on content publish/edit/delete`. - ---- - -### Task 18 + 19: llms.txt + quotable structure - -**Files:** `app/llms.txt/route.ts` (or `public/llms.txt`); article rendering components. - -**Step 1:** Serve `llms.txt` (site summary + key section links). Low effort, optional payoff. - -**Step 2:** Guidance/UX: article pages lead with a 2–3 sentence summary; encourage H2s phrased as questions with a direct first-sentence answer (template/placeholder in the editor). Mark up nothing fake. - -**Step 3:** Commit: `feat(aeo): llms.txt + quotable article summary slot`. - ---- - -## Phase 4 — Replies tab (#3) + badge link (#1) - -### Task 20 + 21: Profile "Replies" tab - -**Files:** - -- Modify: `server/api/router/engagement.ts` or `profile.ts` (new query: a user's comments joined to parent discussion `slug`+`urlId` for anchor links) -- Modify: `app/(app)/[username]/_usernameClient.tsx:118` (add `"Replies"` to `TABS`) + a new panel (mirror the Posts panel style) - -**Step 1 (TDD on the query shape):** Query returns `{ body, createdAt, discussionTitle, href: /d/{slug}-{urlId}#comment-{id} }[]`. - -**Step 2:** Add the tab + panel; each reply links to its anchor permalink. - -**Step 3:** Commit: `feat(profile): Replies tab linking to comment anchors`. - ---- - -### Task 22: Fix "See your badges" → /settings (#1) - -**Files:** `app/(app)/layout.tsx:52-58`, `components/Celebrate/BadgeUnlock.tsx:31` - -**Step 1:** Root cause is a null `username` (handle-less account) falling back to `/settings`. Verify the layout query threads `username` correctly; if a user can reach the celebration without a username, send them to `/settings/profile` with a toast "set a username to get your public profile" instead of a bare `/settings`. Otherwise the `/{username}?tab=achievements` link is correct. - -**Step 2:** Commit: `fix(celebrate): robust See-your-badges destination when handle unset`. - ---- - -## Phase 5 — Tests & migration - -### Task 23: Playwright e2e - -**Files:** `e2e/content-urls.spec.ts` (new), extend `e2e/setup.ts` seed if needed. - -**Step 1:** Cover, with assertions: - -- article page renders at `/{username}/{slug}-{urlId}` (200, title in HTML) -- discussion page renders at `/d/{slug}-{urlId}` (200, OP + a comment in HTML) -- profile renders at `/{username}` (200) -- **301**: old `/{username}/{old-slug}` → new canonical; junk slug + real id → canonical; old discussion path → `/d/...`; uppercase → lowercase -- broken-fallback regression: a feed/discussion card link never resolves to `/[id]` -- deleted content → 410 -- `Replies` tab lists a comment and links to `#comment-{id}` - -**Step 2:** Commit: `test(e2e): content url scheme, redirects, replies tab`. - ---- - -### Task 24: Migration & launch checklist (ops, at deploy) - -- [ ] Run `urlId` backfill in every env; then the `NOT NULL` migration (Task 1 Step 4). -- [ ] Build the full redirect map; confirm one hop, no chains (crawl with a tool). -- [ ] Resubmit split sitemaps in Google Search Console **and** Bing Webmaster Tools. -- [ ] Verify IndexNow key file is reachable; confirm pings in Bing. -- [ ] Watch GSC + Bing coverage for 404/410 spikes for 2 weeks. -- [ ] Confirm OG/Twitter URLs == canonical on a sample of each page type. - ---- - -## Appendix — JSON-LD templates - -### A. Article / BlogPosting (`/{username}/{slug}-{urlId}`) - -```json -{ - "@context": "https://schema.org", - "@type": "BlogPosting", - "headline": "Why RAG beats fine-tuning for most apps", - "datePublished": "2026-06-01T09:00:00Z", - "dateModified": "2026-06-09T14:12:00Z", - "image": ["https://www.codu.co/.../cover.png"], - "author": { - "@type": "Person", - "name": "Niall Maher", - "url": "https://www.codu.co/niall-maher" - }, - "publisher": { - "@type": "Organization", - "name": "Codú", - "logo": { "@type": "ImageObject", "url": "https://www.codu.co/logo.png" } - }, - "mainEntityOfPage": "https://www.codu.co/niall-maher/why-rag-beats-finetuning-a1b2c3d4" -} -``` - -### B. DiscussionForumPosting (`/d/{slug}-{urlId}`) - -```json -{ - "@context": "https://schema.org", - "@type": "DiscussionForumPosting", - "headline": "How do you test AI agents?", - "text": "I keep getting flaky runs...", - "datePublished": "2026-06-08T10:00:00Z", - "dateModified": "2026-06-09T08:00:00Z", - "author": { - "@type": "Person", - "name": "Niall Maher", - "url": "https://www.codu.co/niall-maher" - }, - "interactionStatistic": [ - { - "@type": "InteractionCounter", - "interactionType": "https://schema.org/CommentAction", - "userInteractionCount": 12 - }, - { - "@type": "InteractionCounter", - "interactionType": "https://schema.org/LikeAction", - "userInteractionCount": 34 - } - ], - "comment": [ - { - "@type": "Comment", - "text": "We snapshot the tool-call traces and diff them.", - "dateCreated": "2026-06-08T11:30:00Z", - "author": { - "@type": "Person", - "name": "Dev Two", - "url": "https://www.codu.co/dev-two" - }, - "url": "https://www.codu.co/d/how-do-you-test-ai-agents-7x8y9z01#comment-5678" - } - ] -} -``` - -### C. ProfilePage (`/{username}`) - -```json -{ - "@context": "https://schema.org", - "@type": "ProfilePage", - "dateCreated": "2025-02-01T00:00:00Z", - "mainEntity": { - "@type": "Person", - "name": "Niall Maher", - "url": "https://www.codu.co/niall-maher", - "image": "https://www.codu.co/.../avatar.png", - "description": "Building Codú.", - "sameAs": ["https://github.com/NiallJoeMaher", "https://twitter.com/..."] - } -} -``` - -### D. BreadcrumbList (articles + discussions) - -```json -{ - "@context": "https://schema.org", - "@type": "BreadcrumbList", - "itemListElement": [ - { - "@type": "ListItem", - "position": 1, - "name": "Codú", - "item": "https://www.codu.co/" - }, - { - "@type": "ListItem", - "position": 2, - "name": "Discussions", - "item": "https://www.codu.co/discussions" - }, - { - "@type": "ListItem", - "position": 3, - "name": "How do you test AI agents?", - "item": "https://www.codu.co/d/how-do-you-test-ai-agents-7x8y9z01" - } - ] -} -``` - ---- - -## Migration runbook - -In-repo copy of the production cutover checklist for the content-URL restructure -(mirrors the external watch-doc, but lives with the code the team ships). -`urlId` is the immutable canonical resolver for every content URL; the steps -below get it onto every row, enforce it, and confirm the new URL surface + -crawler coverage are healthy. - -### 1. Schema + backfill (DB, run in order) - -1. **Run migration `0033`** (`0033_complex_wrecking_crew.sql`) — adds the - nullable `posts.url_id` column + the `posts_url_id_key` unique index. - - Command: `npm run db:migrate` (applies all pending Drizzle migrations from - `./drizzle` against `DATABASE_URL`). On Vercel production this also runs in - `vercel-build`, but run it explicitly first so the backfill has the column. -2. **Backfill `url_id` on prod** — `npx tsx -r dotenv/config ./drizzle/backfill-url-id.ts`. - - Idempotent: only touches rows where `url_id IS NULL`. It derives the id from - the slug's trailing hex token when present, otherwise mints a fresh one, and - guarantees uniqueness against ids already in use. - - Verify zero NULLs remain before proceeding: - `SELECT count(*) FROM posts WHERE url_id IS NULL;` → must be `0`. -3. **NOT NULL follow-up migration** — generate + apply the migration that flips - `posts.url_id` to `NOT NULL` (`drizzle-kit generate` after marking the column - `.notNull()` in `server/db/schema.ts`, then `npm run db:migrate`). - - ONLY run this once step 2 reports 0 NULLs in prod — the NOT NULL constraint - will fail the migration otherwise. - -### 2. Verify content lives in `posts` (not legacy `post`) - -- Confirm prod articles are served from the new `posts` table, not the legacy - `"Post"` table (both still exist during the transition): - - `SELECT count(*) FROM posts WHERE status = 'published';` should match the - live article/discussion/link count. - - Spot-check a few live URLs resolve via the new routes: - `/{username}/{slug}`, `/d/{slug}`, `/s/{sourceSlug}/{slug}`. -- The e2e regression suite `e2e/content-urls.spec.ts` pins the full URL surface - - every redirect (301/308) and the no-routeless-`/[id]` guard; run it green - before cutover. - -### 3. Search engine resubmission - -- **Rebuild + submit the sitemap** (`app/sitemap.ts` emits the new urlId-suffixed - URLs) to **Google Search Console** and **Bing Webmaster Tools**. Resubmit the - sitemap URL so both recrawl against the new scheme. -- **IndexNow**: ensure the IndexNow key file is reachable at the site root - (`https://www.codu.co/{key}.txt` returns 200) so push-notifications of changed - URLs are accepted. (Key file is added as part of the IndexNow setup — confirm - it's deployed and 200s before relying on IndexNow pings.) - -### 4. Post-cutover watch (2 weeks) - -- **Monitor 404 / 410 coverage** in GSC + Bing for ~2 weeks. Expectations: - - Legacy URLs should report as **301/308 redirects** to canonical (not 404s). - - Genuinely removed content should report **410/404** intentionally, not as - soft-404s on live content. - - Watch for any spike in 404s on `/{id}`-shaped paths — that would signal a - routeless-`/[id]` regression (the exact case `e2e/content-urls.spec.ts` - guards). Investigate immediately if it appears. -- Keep the legacy `"Post"` table until coverage is stable, then schedule its - drop in a separate, reversible migration. 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 deleted file mode 100644 index e27bcc6f..00000000 --- a/docs/plans/2026-06-14-admin-shell-and-ai-content-design.md +++ /dev/null @@ -1,284 +0,0 @@ -# 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/server/db/schema.ts b/server/db/schema.ts index 18d35c30..b237c410 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -71,7 +71,7 @@ export const reportStatus = pgEnum("report_status", [ // 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) +// AI content pipeline (nightly review cron): sentiment/topic/quality signals. export const sentiment = pgEnum("sentiment", [ "positive", "neutral", @@ -737,8 +737,7 @@ 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 +// AI CONTENT METADATA (nightly review cron) // 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 From e12d84961aa5d88bd70e31498001a2d432ee45c8 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 14 Jun 2026 21:49:45 +0100 Subject: [PATCH 3/5] feat: personalized 'For you' feed (topic follow/mute + affinity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in feed personalization built on the topic vocabulary: - Schema (migration 0039, additive): user_topic_pref (follow/mute) and user_topic_affinity (implicit interest, time-decayed). - feedRanking: pure, unit-tested scoring — a transparent weighted blend of recency, quality, and topic affinity, with muted topics filtered out. - topicAffinity: derive per-user affinity from votes/bookmarks/comments through post_topic edges with decay; recomputed for active users by the nightly cron. - profile.getTopicPrefs / setTopicPref: manage follows and mutes. - content.getForYouFeed: re-rank a recent candidate window for the user; cold start (no signal) falls back to recency, so the existing feed is untouched. Also trims verbose comments across the content-pipeline modules. --- app/api/cron/daily-review/route.ts | 55 +- drizzle/0039_feed_personalization.sql | 23 + drizzle/meta/0039_snapshot.json | 6317 +++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + server/api/router/content.ts | 114 + server/api/router/profile.ts | 46 + server/db/schema.ts | 92 +- server/lib/contentAnalysis.ts | 29 +- server/lib/feedPersonalization.ts | 59 + server/lib/feedRanking.test.ts | 131 + server/lib/feedRanking.ts | 115 + server/lib/topicAffinity.test.ts | 21 + server/lib/topicAffinity.ts | 169 + 13 files changed, 7127 insertions(+), 51 deletions(-) create mode 100644 drizzle/0039_feed_personalization.sql create mode 100644 drizzle/meta/0039_snapshot.json create mode 100644 server/lib/feedPersonalization.ts create mode 100644 server/lib/feedRanking.test.ts create mode 100644 server/lib/feedRanking.ts create mode 100644 server/lib/topicAffinity.test.ts create mode 100644 server/lib/topicAffinity.ts diff --git a/app/api/cron/daily-review/route.ts b/app/api/cron/daily-review/route.ts index f6896775..0959e609 100644 --- a/app/api/cron/daily-review/route.ts +++ b/app/api/cron/daily-review/route.ts @@ -30,31 +30,25 @@ import { type TopicVocabEntry, } from "@/server/lib/contentAnalysis"; import { autoReview } from "@/server/lib/autoReview"; +import { + findRecentlyActiveUsers, + recomputeUserAffinity, +} from "@/server/lib/topicAffinity"; 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: -// 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. +// Nightly review cron (auth via CRON_SECRET; invoked by EventBridge — see +// cdk/lib/cron-stack.ts). Incremental, capped passes that no-op on an empty +// worklist: topic/sentiment tagging, quality scoring, post+comment moderation +// re-screen, affinity recompute, and a digest email. 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. +// Sentinel modelId for heuristic-scored rows (Bedrock off), so they're distinct +// from human-curated rows (modelId IS NULL) and can be upgraded once it's on. const HEURISTIC_MODEL = "heuristic"; function isAuthorized(request: Request): boolean { @@ -124,10 +118,8 @@ async function reviewPosts( 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. + // Worklist: published posts never analysed, stale (edited / schema bumped), or + // a heuristic placeholder now Bedrock is on. modelId IS NULL = human-curated, skip. const staleBranches = [ gt(posts.updatedAt, post_metadata.analyzedAt), lt(post_metadata.schemaVersion, ANALYSIS_SCHEMA_VERSION), @@ -384,6 +376,25 @@ async function sendDigest(summary: { return true; } +const AFFINITY_USER_CAP = 500; + +// Recompute implicit topic affinity for users who interacted in the last 24h. +async function reviewAffinity(): Promise<{ usersUpdated: number }> { + const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const now = Date.now(); + const users = await findRecentlyActiveUsers(db, since, AFFINITY_USER_CAP); + let usersUpdated = 0; + for (const userId of users) { + try { + await recomputeUserAffinity(db, userId, now); + usersUpdated += 1; + } catch (err) { + Sentry.captureException(err); + } + } + return { usersUpdated }; +} + async function loadVocab(): Promise<{ vocab: TopicVocabEntry[]; slugToId: Map; @@ -414,6 +425,7 @@ async function handle(request: Request) { const { vocab, slugToId } = await loadVocab(); const postResult = await reviewPosts(vocab, slugToId); const commentResult = await reviewComments(); + const affinityResult = await reviewAffinity(); const summary = { postsAnalyzed: postResult.analyzed, @@ -421,6 +433,7 @@ async function handle(request: Request) { proposedTopics: postResult.proposed, commentsModerated: commentResult.moderated, commentsFlagged: commentResult.flagged, + affinityUsersUpdated: affinityResult.usersUpdated, }; const digestSent = await sendDigest(summary); diff --git a/drizzle/0039_feed_personalization.sql b/drizzle/0039_feed_personalization.sql new file mode 100644 index 00000000..48ec71da --- /dev/null +++ b/drizzle/0039_feed_personalization.sql @@ -0,0 +1,23 @@ +CREATE TYPE "public"."topic_pref" AS ENUM('follow', 'mute');--> statement-breakpoint +CREATE TABLE "user_topic_affinity" ( + "user_id" text NOT NULL, + "topic_id" integer NOT NULL, + "score" real NOT NULL, + "updated_at" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "user_topic_affinity_user_id_topic_id_pk" PRIMARY KEY("user_id","topic_id") +); +--> statement-breakpoint +CREATE TABLE "user_topic_pref" ( + "user_id" text NOT NULL, + "topic_id" integer NOT NULL, + "pref" "topic_pref" NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "user_topic_pref_user_id_topic_id_pk" PRIMARY KEY("user_id","topic_id") +); +--> statement-breakpoint +ALTER TABLE "user_topic_affinity" ADD CONSTRAINT "user_topic_affinity_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_topic_affinity" ADD CONSTRAINT "user_topic_affinity_topic_id_topic_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topic"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_topic_pref" ADD CONSTRAINT "user_topic_pref_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_topic_pref" ADD CONSTRAINT "user_topic_pref_topic_id_topic_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topic"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "user_topic_affinity_user_id_idx" ON "user_topic_affinity" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "user_topic_pref_user_id_idx" ON "user_topic_pref" USING btree ("user_id"); \ No newline at end of file diff --git a/drizzle/meta/0039_snapshot.json b/drizzle/meta/0039_snapshot.json new file mode 100644 index 00000000..5604f9c1 --- /dev/null +++ b/drizzle/meta/0039_snapshot.json @@ -0,0 +1,6317 @@ +{ + "id": "c6e28137-961e-40e0-bdf3-5df887d0ce02", + "prevId": "4e6b6c33-b5ca-494b-9e68-7b370d344ffc", + "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 + }, + "public.user_topic_affinity": { + "name": "user_topic_affinity", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_topic_affinity_user_id_idx": { + "name": "user_topic_affinity_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_topic_affinity_user_id_user_id_fk": { + "name": "user_topic_affinity_user_id_user_id_fk", + "tableFrom": "user_topic_affinity", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_topic_affinity_topic_id_topic_id_fk": { + "name": "user_topic_affinity_topic_id_topic_id_fk", + "tableFrom": "user_topic_affinity", + "tableTo": "topic", + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_topic_affinity_user_id_topic_id_pk": { + "name": "user_topic_affinity_user_id_topic_id_pk", + "columns": ["user_id", "topic_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_topic_pref": { + "name": "user_topic_pref", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pref": { + "name": "pref", + "type": "topic_pref", + "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": { + "user_topic_pref_user_id_idx": { + "name": "user_topic_pref_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_topic_pref_user_id_user_id_fk": { + "name": "user_topic_pref_user_id_user_id_fk", + "tableFrom": "user_topic_pref", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_topic_pref_topic_id_topic_id_fk": { + "name": "user_topic_pref_topic_id_topic_id_fk", + "tableFrom": "user_topic_pref", + "tableTo": "topic", + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_topic_pref_user_id_topic_id_pk": { + "name": "user_topic_pref_user_id_topic_id_pk", + "columns": ["user_id", "topic_id"] + } + }, + "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_pref": { + "name": "topic_pref", + "schema": "public", + "values": ["follow", "mute"] + }, + "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 00730e29..b9c6e382 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -274,6 +274,13 @@ "when": 1781464677918, "tag": "0038_ai_content_metadata", "breakpoints": true + }, + { + "idx": 39, + "version": "7", + "when": 1781469292706, + "tag": "0039_feed_personalization", + "breakpoints": true } ] } diff --git a/server/api/router/content.ts b/server/api/router/content.ts index 102f033a..951d3a32 100644 --- a/server/api/router/content.ts +++ b/server/api/router/content.ts @@ -30,6 +30,7 @@ import { user, comments, point_event, + post_metadata, } from "@/server/db/schema"; import { and, @@ -46,6 +47,11 @@ import { inArray, } from "drizzle-orm"; import { increment } from "./utils"; +import { rankCandidates, hasProfileSignal } from "@/server/lib/feedRanking"; +import { + loadUserProfile, + loadTopicsByPost, +} from "@/server/lib/feedPersonalization"; import { applyGate, notifyAdminOfReview } from "@/server/lib/moderation"; import { runDedupeAndGate } from "@/server/lib/dedupe"; import { enforceRateLimit, clientIpFromHeaders } from "@/server/lib/rateLimit"; @@ -1604,4 +1610,112 @@ export const contentRouter = createTRPCRouter({ }, }; }), + + // Personalized "For you" feed: re-rank a recent candidate window by the user's + // topic follows/mutes and learned affinity. Cold start (no signal) falls back + // to recency, so a new user sees the normal feed. + getForYouFeed: protectedProcedure + .input( + z.object({ + limit: z.number().int().min(1).max(50).optional(), + offset: z.number().int().min(0).optional(), + }), + ) + .query(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const limit = input?.limit ?? 25; + const offset = input?.offset ?? 0; + const WINDOW = 200; + + const profile = await loadUserProfile(ctx.db, userId); + + const userVotes = ctx.db + .select({ postId: post_votes.postId, voteType: post_votes.voteType }) + .from(post_votes) + .where(eq(post_votes.userId, userId)) + .as("userVotes"); + const userBookmarks = ctx.db + .select({ postId: bookmarks.postId }) + .from(bookmarks) + .where(eq(bookmarks.userId, userId)) + .as("userBookmarks"); + + const rows = await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + externalUrl: posts.externalUrl, + imageUrl: posts.coverImage, + ogImageUrl: posts.coverImage, + slug: posts.slug, + urlId: posts.urlId, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + clickCount: posts.viewsCount, + readTimeMins: posts.readingTime, + userId: posts.authorId, + sourceId: posts.sourceId, + sourceAuthor: posts.sourceAuthor, + createdAt: posts.createdAt, + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + sourceCategory: feed_sources.category, + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + userVote: userVotes.voteType, + isBookmarked: sql`${userBookmarks.postId} IS NOT NULL`, + qualityScore: post_metadata.qualityScore, + }) + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) + .leftJoin(post_metadata, eq(post_metadata.postId, posts.id)) + .leftJoin(userVotes, eq(posts.id, userVotes.postId)) + .leftJoin(userBookmarks, eq(posts.id, userBookmarks.postId)) + .where( + and( + eq(posts.status, "published"), + or( + lte(posts.publishedAt, new Date().toISOString()), + isNull(posts.publishedAt), + )!, + ), + ) + .orderBy(desc(posts.publishedAt)) + .limit(WINDOW); + + const personalized = hasProfileSignal(profile); + let ordered = rows; + if (personalized) { + const topicsByPost = await loadTopicsByPost( + ctx.db, + rows.map((r) => r.id), + ); + const candidates = rows.map((r) => ({ + id: r.id, + publishedAt: r.publishedAt, + score: r.upvotes - r.downvotes, + qualityScore: r.qualityScore, + topicIds: topicsByPost.get(r.id) ?? [], + row: r, + })); + ordered = rankCandidates(candidates, profile, Date.now()).map( + (c) => c.item.row, + ); + } + + const items = ordered + .slice(offset, offset + limit) + .map((item) => ({ ...item, type: toFrontendType(item.type) })); + const nextOffset = + ordered.length > offset + limit ? offset + limit : undefined; + + return { items, nextOffset, personalized }; + }), }); diff --git a/server/api/router/profile.ts b/server/api/router/profile.ts index ed14a5db..aaade795 100644 --- a/server/api/router/profile.ts +++ b/server/api/router/profile.ts @@ -4,6 +4,8 @@ import { comments, posts, feed_sources as feedSources, + topic, + user_topic_pref, } from "@/server/db/schema"; import { buildCommentHref } from "@/server/lib/content-url"; import { @@ -86,6 +88,50 @@ export const profileRouter = createTRPCRouter({ }; }), + // Follow/mute topics from the controlled vocabulary — drives the "For you" feed. + getTopicPrefs: protectedProcedure.query(async ({ ctx }) => { + return ctx.db + .select({ + topicId: user_topic_pref.topicId, + slug: topic.slug, + label: topic.label, + pref: user_topic_pref.pref, + }) + .from(user_topic_pref) + .innerJoin(topic, eq(user_topic_pref.topicId, topic.id)) + .where(eq(user_topic_pref.userId, ctx.session.user.id)); + }), + + setTopicPref: protectedProcedure + .input( + z.object({ + topicId: z.number().int().positive(), + pref: z.enum(["follow", "mute", "none"]), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + if (input.pref === "none") { + await ctx.db + .delete(user_topic_pref) + .where( + and( + eq(user_topic_pref.userId, userId), + eq(user_topic_pref.topicId, input.topicId), + ), + ); + return { topicId: input.topicId, pref: null }; + } + await ctx.db + .insert(user_topic_pref) + .values({ userId, topicId: input.topicId, pref: input.pref }) + .onConflictDoUpdate({ + target: [user_topic_pref.userId, user_topic_pref.topicId], + set: { pref: input.pref }, + }); + return { topicId: input.topicId, pref: input.pref }; + }), + edit: rateLimitedProcedure({ name: "profile-edit", limit: 5, diff --git a/server/db/schema.ts b/server/db/schema.ts index b237c410..d8e1bb95 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -83,6 +83,9 @@ 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"]); +// Explicit per-user topic preference for feed personalization: a followed topic +// boosts matching posts, a muted topic filters them out. +export const topicPref = pgEnum("topic_pref", ["follow", "mute"]); // Job board export const jobType = pgEnum("job_type", [ @@ -739,9 +742,7 @@ export const reportsRelations = relations(reports, ({ one }) => ({ // AI CONTENT METADATA (nightly review cron) -// 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. +// Per-post AI signals, 1:1 with posts. `analyzedAt` is the incremental watermark. export const post_metadata = pgTable( "post_metadata", { @@ -752,15 +753,12 @@ export const post_metadata = pgTable( 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"), + modelId: text("model_id"), // NULL = human-curated; cron skips those rows 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) => ({ @@ -836,6 +834,86 @@ export const postTopicRelations = relations(post_topic, ({ one }) => ({ topic: one(topic, { fields: [post_topic.topicId], references: [topic.id] }), })); +// FEED PERSONALIZATION (explicit prefs + implicit affinity) + +// Explicit, user-controlled topic preferences. Follows boost matching posts in +// the personalized feed; mutes filter them out. Predictable and transparent. +export const user_topic_pref = pgTable( + "user_topic_pref", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + topicId: integer("topic_id") + .notNull() + .references(() => topic.id, { onDelete: "cascade" }), + pref: topicPref("pref").notNull(), + createdAt: timestamp("created_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.userId, table.topicId] }), + userIdIdx: index("user_topic_pref_user_id_idx").on(table.userId), + }), +); + +export const userTopicPrefRelations = relations(user_topic_pref, ({ one }) => ({ + user: one(user, { + fields: [user_topic_pref.userId], + references: [user.id], + }), + topic: one(topic, { + fields: [user_topic_pref.topicId], + references: [topic.id], + }), +})); + +// Implicit topic affinity derived from a user's interactions (votes, bookmarks, +// comments) through post_topic edges, with time decay. Recomputed incrementally +// by the nightly cron for recently-active users. +export const user_topic_affinity = pgTable( + "user_topic_affinity", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + topicId: integer("topic_id") + .notNull() + .references(() => topic.id, { onDelete: "cascade" }), + score: real("score").notNull(), // >= 0; higher = stronger inferred interest + updatedAt: timestamp("updated_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.userId, table.topicId] }), + userIdIdx: index("user_topic_affinity_user_id_idx").on(table.userId), + }), +); + +export const userTopicAffinityRelations = relations( + user_topic_affinity, + ({ one }) => ({ + user: one(user, { + fields: [user_topic_affinity.userId], + references: [user.id], + }), + topic: one(topic, { + fields: [user_topic_affinity.topicId], + references: [topic.id], + }), + }), +); + // TAGS (shared between legacy and new system) export const tag = pgTable( diff --git a/server/lib/contentAnalysis.ts b/server/lib/contentAnalysis.ts index 72f86a80..e9c6576b 100644 --- a/server/lib/contentAnalysis.ts +++ b/server/lib/contentAnalysis.ts @@ -4,9 +4,7 @@ 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. +// Bump to force re-analysis of every post (vs post_metadata.schemaVersion). export const ANALYSIS_SCHEMA_VERSION = 1; export type Sentiment = "positive" | "neutral" | "negative"; @@ -63,12 +61,7 @@ Reply with ONLY this JSON shape: 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. - */ +// Fallback when Bedrock is off: heuristic moderation only, no AI signals. function heuristicAnalysis(input: AnalyzePostInput): ContentAnalysis { const result = screenContent({ title: input.title, body: input.body }); return { @@ -88,14 +81,8 @@ function heuristicAnalysis(input: AnalyzePostInput): ContentAnalysis { }; } -/** - * 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. - */ +// One Bedrock call -> topics + sentiment + quality + moderation. Fail-open: any +// error falls back to the heuristic so a model failure never blocks the pipeline. export async function analyzePost( input: AnalyzePostInput, vocab: TopicVocabEntry[], @@ -157,12 +144,8 @@ function clamp(n: unknown, min: number, max: number): number | 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. - */ +// Parse the model's JSON. Unknown topics are dropped; unparseable input falls +// back to the heuristic; an unexpected verdict routes to review. export function parseAnalysis( raw: string, vocab: TopicVocabEntry[], diff --git a/server/lib/feedPersonalization.ts b/server/lib/feedPersonalization.ts new file mode 100644 index 00000000..7af4fc6f --- /dev/null +++ b/server/lib/feedPersonalization.ts @@ -0,0 +1,59 @@ +import { eq, inArray } from "drizzle-orm"; +import type { db as defaultDb } from "@/server/db"; +import { + user_topic_pref, + user_topic_affinity, + post_topic, +} from "@/server/db/schema"; +import type { UserProfile } from "@/server/lib/feedRanking"; + +type Db = typeof defaultDb; + +// Load a user's prefs + affinity into the ranking module's shape. +export async function loadUserProfile( + db: Db, + userId: string, +): Promise { + const [prefs, affinity] = await Promise.all([ + db + .select({ topicId: user_topic_pref.topicId, pref: user_topic_pref.pref }) + .from(user_topic_pref) + .where(eq(user_topic_pref.userId, userId)), + db + .select({ + topicId: user_topic_affinity.topicId, + score: user_topic_affinity.score, + }) + .from(user_topic_affinity) + .where(eq(user_topic_affinity.userId, userId)), + ]); + + const follows = new Set(); + const mutes = new Set(); + for (const p of prefs) { + (p.pref === "mute" ? mutes : follows).add(p.topicId); + } + const affinityMap = new Map(); + for (const a of affinity) affinityMap.set(a.topicId, a.score); + + return { follows, mutes, affinity: affinityMap }; +} + +// topicIds per post for a candidate window. +export async function loadTopicsByPost( + db: Db, + postIds: string[], +): Promise> { + const byPost = new Map(); + if (postIds.length === 0) return byPost; + const edges = await db + .select({ postId: post_topic.postId, topicId: post_topic.topicId }) + .from(post_topic) + .where(inArray(post_topic.postId, postIds)); + for (const e of edges) { + const arr = byPost.get(e.postId) ?? []; + arr.push(e.topicId); + byPost.set(e.postId, arr); + } + return byPost; +} diff --git a/server/lib/feedRanking.test.ts b/server/lib/feedRanking.test.ts new file mode 100644 index 00000000..cd9643ad --- /dev/null +++ b/server/lib/feedRanking.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { + recencyComponent, + qualityComponent, + affinityComponent, + isMuted, + scoreCandidate, + rankCandidates, + hasProfileSignal, + RECENCY_HALFLIFE_HOURS, + type UserProfile, + type FeedCandidate, +} from "./feedRanking"; + +const NOW = Date.parse("2026-06-14T00:00:00.000Z"); + +function emptyProfile(): UserProfile { + return { affinity: new Map(), follows: new Set(), mutes: new Set() }; +} + +function candidate(over: Partial = {}): FeedCandidate { + return { + id: "p1", + publishedAt: new Date(NOW).toISOString(), + score: 0, + qualityScore: null, + topicIds: [], + ...over, + }; +} + +describe("recencyComponent", () => { + it("is 1 for a just-published post", () => { + expect(recencyComponent(new Date(NOW).toISOString(), NOW)).toBeCloseTo(1); + }); + it("halves after one half-life", () => { + const older = NOW - RECENCY_HALFLIFE_HOURS * 3_600_000; + expect(recencyComponent(new Date(older).toISOString(), NOW)).toBeCloseTo( + 0.5, + ); + }); + it("is 0 for null or unparseable timestamps", () => { + expect(recencyComponent(null, NOW)).toBe(0); + expect(recencyComponent("not-a-date", NOW)).toBe(0); + }); +}); + +describe("qualityComponent", () => { + it("log-damps votes and adds the AI quality score", () => { + expect( + qualityComponent(candidate({ score: 9, qualityScore: 0.5 })), + ).toBeCloseTo(Math.log10(10) + 0.5); + }); + it("never goes negative on downvoted posts and treats null quality as 0", () => { + expect(qualityComponent(candidate({ score: -5, qualityScore: null }))).toBe( + 0, + ); + }); +}); + +describe("affinityComponent", () => { + it("sums explicit follow boosts and implicit affinity over the post's topics", () => { + const profile = emptyProfile(); + profile.follows.add(1); + profile.affinity.set(1, 2).set(2, 0.5); + // topic 1: follow(1) + affinity(2) = 3; topic 2: affinity(0.5) + expect(affinityComponent([1, 2], profile)).toBeCloseTo(3.5); + }); + it("is 0 when the post shares no topics with the profile", () => { + const profile = emptyProfile(); + profile.affinity.set(99, 5); + expect(affinityComponent([1, 2], profile)).toBe(0); + }); +}); + +describe("isMuted / scoreCandidate", () => { + it("filters a post that has any muted topic (returns null)", () => { + const profile = emptyProfile(); + profile.mutes.add(7); + expect(isMuted([3, 7], profile)).toBe(true); + expect( + scoreCandidate(candidate({ topicIds: [3, 7] }), profile, NOW), + ).toBeNull(); + }); + it("scores a non-muted post as the weighted blend", () => { + const profile = emptyProfile(); + profile.follows.add(1); + const c = candidate({ topicIds: [1], score: 9, qualityScore: 0 }); + // recency 1*1 + quality 0.5*log10(10) + affinity 1.5*1 + const expected = 1 * 1 + 0.5 * Math.log10(10) + 1.5 * 1; + expect(scoreCandidate(c, profile, NOW)).toBeCloseTo(expected); + }); +}); + +describe("rankCandidates", () => { + it("drops muted posts and sorts by score descending", () => { + const profile = emptyProfile(); + profile.follows.add(1); + profile.mutes.add(9); + const items = [ + candidate({ id: "muted", topicIds: [9] }), + candidate({ id: "plain", topicIds: [] }), + candidate({ id: "followed", topicIds: [1] }), + ]; + const ranked = rankCandidates(items, profile, NOW); + expect(ranked.map((r) => r.item.id)).toEqual(["followed", "plain"]); + }); + + it("breaks score ties by newest publishedAt", () => { + const profile = emptyProfile(); + const newer = candidate({ + id: "newer", + publishedAt: new Date(NOW).toISOString(), + }); + const older = candidate({ + id: "older", + publishedAt: new Date(NOW - 3_600_000).toISOString(), + }); + const ranked = rankCandidates([older, newer], profile, NOW); + expect(ranked.map((r) => r.item.id)).toEqual(["newer", "older"]); + }); +}); + +describe("hasProfileSignal", () => { + it("is false for a cold-start (empty) profile and true with any signal", () => { + expect(hasProfileSignal(emptyProfile())).toBe(false); + const p = emptyProfile(); + p.affinity.set(1, 0.1); + expect(hasProfileSignal(p)).toBe(true); + }); +}); diff --git a/server/lib/feedRanking.ts b/server/lib/feedRanking.ts new file mode 100644 index 00000000..3d411656 --- /dev/null +++ b/server/lib/feedRanking.ts @@ -0,0 +1,115 @@ +// Feed personalization scoring. The score is a weighted sum of named components +// (recency, quality, affinity) so a post's ranking is always explainable. Pure +// and deterministic — time is passed in — so it's unit-testable without a DB. + +export interface RankingWeights { + recency: number; + quality: number; + affinity: number; +} + +export const DEFAULT_WEIGHTS: RankingWeights = { + recency: 1, + quality: 0.5, + affinity: 1.5, +}; + +export const RECENCY_HALFLIFE_HOURS = 48; + +export interface FeedCandidate { + id: string; + publishedAt: string | null; + score: number; // upvotes - downvotes + qualityScore: number | null; // AI quality in [0,1], or null + topicIds: number[]; +} + +export interface UserProfile { + affinity: Map; + follows: Set; + mutes: Set; +} + +export function recencyComponent( + publishedAt: string | null, + nowMs: number, +): number { + if (!publishedAt) return 0; + const publishedMs = Date.parse(publishedAt); + if (Number.isNaN(publishedMs)) return 0; + const ageHours = Math.max(0, (nowMs - publishedMs) / 3_600_000); + return Math.pow(0.5, ageHours / RECENCY_HALFLIFE_HOURS); +} + +export function qualityComponent(candidate: FeedCandidate): number { + // Log-damp votes so a viral post can't dominate on raw count alone. + const voteSignal = Math.log10(Math.max(0, candidate.score) + 1); + return voteSignal + (candidate.qualityScore ?? 0); +} + +export function affinityComponent( + topicIds: number[], + profile: UserProfile, +): number { + let total = 0; + for (const topicId of topicIds) { + if (profile.follows.has(topicId)) total += 1; + total += profile.affinity.get(topicId) ?? 0; + } + return total; +} + +export function isMuted(topicIds: number[], profile: UserProfile): boolean { + return topicIds.some((topicId) => profile.mutes.has(topicId)); +} + +// Returns null when the post should be hidden (a muted topic). +export function scoreCandidate( + candidate: FeedCandidate, + profile: UserProfile, + nowMs: number, + weights: RankingWeights = DEFAULT_WEIGHTS, +): number | null { + if (isMuted(candidate.topicIds, profile)) return null; + return ( + weights.recency * recencyComponent(candidate.publishedAt, nowMs) + + weights.quality * qualityComponent(candidate) + + weights.affinity * affinityComponent(candidate.topicIds, profile) + ); +} + +export interface RankedCandidate { + item: T; + score: number; +} + +// Drop muted posts, score the rest, sort by score with a newest-first tiebreak +// so pagination is deterministic. +export function rankCandidates( + candidates: T[], + profile: UserProfile, + nowMs: number, + weights: RankingWeights = DEFAULT_WEIGHTS, +): RankedCandidate[] { + const ranked: RankedCandidate[] = []; + for (const item of candidates) { + const score = scoreCandidate(item, profile, nowMs, weights); + if (score !== null) ranked.push({ item, score }); + } + ranked.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + const at = a.item.publishedAt ? Date.parse(a.item.publishedAt) : 0; + const bt = b.item.publishedAt ? Date.parse(b.item.publishedAt) : 0; + if (bt !== at) return bt - at; + return a.item.id < b.item.id ? 1 : -1; + }); + return ranked; +} + +export function hasProfileSignal(profile: UserProfile): boolean { + return ( + profile.follows.size > 0 || + profile.mutes.size > 0 || + profile.affinity.size > 0 + ); +} diff --git a/server/lib/topicAffinity.test.ts b/server/lib/topicAffinity.test.ts new file mode 100644 index 00000000..8083f63b --- /dev/null +++ b/server/lib/topicAffinity.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { decayedWeight, HALF_LIFE_DAYS } from "./topicAffinity"; + +describe("decayedWeight", () => { + it("returns the base weight for a fresh interaction", () => { + expect(decayedWeight(4, 0)).toBe(4); + expect(decayedWeight(4, -1)).toBe(4); + }); + + it("halves the weight after one half-life", () => { + expect(decayedWeight(4, HALF_LIFE_DAYS)).toBeCloseTo(2); + }); + + it("quarters the weight after two half-lives", () => { + expect(decayedWeight(4, HALF_LIFE_DAYS * 2)).toBeCloseTo(1); + }); + + it("decays negative weights toward zero too", () => { + expect(decayedWeight(-1, HALF_LIFE_DAYS)).toBeCloseTo(-0.5); + }); +}); diff --git a/server/lib/topicAffinity.ts b/server/lib/topicAffinity.ts new file mode 100644 index 00000000..6857d098 --- /dev/null +++ b/server/lib/topicAffinity.ts @@ -0,0 +1,169 @@ +import { desc, eq, gt, inArray } from "drizzle-orm"; +import type { db as defaultDb } from "@/server/db"; +import { + post_votes, + bookmarks, + comments, + post_topic, + user_topic_affinity, +} from "@/server/db/schema"; + +// Implicit topic affinity: how much a user has engaged with each topic, derived +// from their votes/bookmarks/comments via post_topic edges with time decay. + +type Db = typeof defaultDb; + +export const HALF_LIFE_DAYS = 60; +const MS_PER_DAY = 86_400_000; +const PER_SOURCE_LIMIT = 300; + +export const INTERACTION_WEIGHT = { + upvote: 3, + downvote: -1, + bookmark: 4, + comment: 2, +} as const; + +export function decayedWeight( + base: number, + ageDays: number, + halfLifeDays: number = HALF_LIFE_DAYS, +): number { + if (ageDays <= 0) return base; + return base * Math.pow(0.5, ageDays / halfLifeDays); +} + +interface Interaction { + postId: string; + base: number; + createdAt: string; +} + +// Recompute and persist one user's topic affinity, replacing their existing +// rows. Returns the number of topics with positive affinity. +export async function recomputeUserAffinity( + db: Db, + userId: string, + nowMs: number, +): Promise { + const [votes, marks, cmts] = await Promise.all([ + db + .select({ + postId: post_votes.postId, + voteType: post_votes.voteType, + createdAt: post_votes.createdAt, + }) + .from(post_votes) + .where(eq(post_votes.userId, userId)) + .orderBy(desc(post_votes.createdAt)) + .limit(PER_SOURCE_LIMIT), + db + .select({ postId: bookmarks.postId, createdAt: bookmarks.createdAt }) + .from(bookmarks) + .where(eq(bookmarks.userId, userId)) + .orderBy(desc(bookmarks.createdAt)) + .limit(PER_SOURCE_LIMIT), + db + .select({ postId: comments.postId, createdAt: comments.createdAt }) + .from(comments) + .where(eq(comments.authorId, userId)) + .orderBy(desc(comments.createdAt)) + .limit(PER_SOURCE_LIMIT), + ]); + + const interactions: Interaction[] = [ + ...votes.map((v) => ({ + postId: v.postId, + base: + v.voteType === "up" + ? INTERACTION_WEIGHT.upvote + : INTERACTION_WEIGHT.downvote, + createdAt: v.createdAt, + })), + ...marks.map((m) => ({ + postId: m.postId, + base: INTERACTION_WEIGHT.bookmark, + createdAt: m.createdAt, + })), + ...cmts.map((c) => ({ + postId: c.postId, + base: INTERACTION_WEIGHT.comment, + createdAt: c.createdAt, + })), + ]; + + if (interactions.length === 0) { + await db + .delete(user_topic_affinity) + .where(eq(user_topic_affinity.userId, userId)); + return 0; + } + + const postIds = Array.from(new Set(interactions.map((i) => i.postId))); + const edges = await db + .select({ postId: post_topic.postId, topicId: post_topic.topicId }) + .from(post_topic) + .where(inArray(post_topic.postId, postIds)); + + const topicsByPost = new Map(); + for (const edge of edges) { + const arr = topicsByPost.get(edge.postId) ?? []; + arr.push(edge.topicId); + topicsByPost.set(edge.postId, arr); + } + + const scores = new Map(); + for (const it of interactions) { + const topics = topicsByPost.get(it.postId); + if (!topics?.length) continue; + const ageDays = Math.max( + 0, + (nowMs - Date.parse(it.createdAt)) / MS_PER_DAY, + ); + const weight = decayedWeight(it.base, ageDays); + for (const topicId of topics) { + scores.set(topicId, (scores.get(topicId) ?? 0) + weight); + } + } + + const updatedAt = new Date(nowMs).toISOString(); + const rows = [...scores.entries()] + .filter(([, score]) => score > 0) + .map(([topicId, score]) => ({ userId, topicId, score, updatedAt })); + + await db + .delete(user_topic_affinity) + .where(eq(user_topic_affinity.userId, userId)); + if (rows.length > 0) { + await db.insert(user_topic_affinity).values(rows); + } + return rows.length; +} + +// User ids with at least one interaction since `sinceIso`, capped. +export async function findRecentlyActiveUsers( + db: Db, + sinceIso: string, + limit: number, +): Promise { + const [voters, markers, commenters] = await Promise.all([ + db + .selectDistinct({ userId: post_votes.userId }) + .from(post_votes) + .where(gt(post_votes.createdAt, sinceIso)), + db + .selectDistinct({ userId: bookmarks.userId }) + .from(bookmarks) + .where(gt(bookmarks.createdAt, sinceIso)), + db + .selectDistinct({ userId: comments.authorId }) + .from(comments) + .where(gt(comments.createdAt, sinceIso)), + ]); + + const ids = new Set(); + for (const row of voters) ids.add(row.userId); + for (const row of markers) ids.add(row.userId); + for (const row of commenters) ids.add(row.userId); + return Array.from(ids).slice(0, limit); +} From 71921ba7998bdd3acac4871c7c698e4d1c2bc287 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Mon, 15 Jun 2026 08:30:41 +0100 Subject: [PATCH 4/5] style: reading width, shell layout, and prose typography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Widen app shell to 1300px (right rail 300→280px); fold breakpoint moved to 1300px so the center column holds its ~672px reading measure at all sizes - Narrow article/discussion/link readers to max-w-prose (672px), unifying all content surfaces and matching the readability sweet spot (~70 chars/line) - Remove double-up side padding from content wrappers; shell grid gap and mobile shell gutter (bumped to 1rem) now own all horizontal breathing room - Re-key prose overrides onto design tokens (bg-inset, border-hairline, text-accent-soft, color-muted/fg) so styles flip correctly in dark mode; inline code selector narrowed to :not(pre)>code to avoid touching fenced blocks --- .../[username]/[slug]/_userLinkDetail.tsx | 6 +-- .../[slug]/_feedArticleContent.tsx | 2 +- components/ContentDetail/PostReader.tsx | 2 +- styles/globals.css | 51 ++++++++++++++----- 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/app/(app)/[username]/[slug]/_userLinkDetail.tsx b/app/(app)/[username]/[slug]/_userLinkDetail.tsx index 75bdf001..14f2bf14 100644 --- a/app/(app)/[username]/[slug]/_userLinkDetail.tsx +++ b/app/(app)/[username]/[slug]/_userLinkDetail.tsx @@ -119,7 +119,7 @@ const UserLinkDetail = ({ username, contentSlug, initialContent }: Props) => { if (status === "pending") { return ( -
+
@@ -134,7 +134,7 @@ const UserLinkDetail = ({ username, contentSlug, initialContent }: Props) => { if (status === "error" || !linkContent) { return ( -
+
{ const isOwner = session?.user?.id === linkContent.author?.id; return ( -
+
{ const safeExternalUrl = safeExternalHref(article.externalUrl); return ( -
+
} {breadcrumbSchema && } -
+