diff --git a/.gitignore b/.gitignore
index 104a24b50..3bb96bd26 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 000000000..13f3cfbd7
--- /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/(app)/s/[sourceSlug]/_sourceProfileClient.tsx b/app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx
index 0f985419a..8363b5184 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/api/cron/daily-review/route.ts b/app/api/cron/daily-review/route.ts
index ded8ff7fe..0959e6099 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 (see docs/plans/2026-06-14-admin-shell-and-ai-content-design.md):
-// 1. topic + sentiment tagging (posts)
-// 2. quality / spam scoring (posts) — passes 1+2 share one Bedrock call
-// 3. re-screen moderation (posts + comments) -> reports queue (source=system)
-// 4. daily digest -> email the founder only when something needs attention
-//
-// Everything is incremental (per-row analyzedAt / moderatedAt watermark) and
-// capped per run, so an empty worklist is a near-zero-cost no-op and a backfill
-// can't blow the Lambda timeout. Each item is isolated (try/catch + Sentry) so
-// one bad row never kills the batch.
+// 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/app/og/route.tsx b/app/og/route.tsx
index 04d7042eb..094021599 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/components/Admin/AdminShell.tsx b/components/Admin/AdminShell.tsx
index de52a608f..ccd61050b 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 2ebfda980..000000000
--- 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=`. 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 ``/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=`). 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 0a09cb379..000000000
--- 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();
-// ...
-Review it in the moderation queue →
-```
-
-**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=`.
-
-**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 = "HiHello 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(/