Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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*
59 changes: 59 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="mx-auto max-w-2xl px-0 py-4 sm:px-4 sm:py-8 text-fg">
<div className="mx-auto max-w-2xl px-0 py-4 text-fg sm:px-4 sm:py-8">
<div className="bg-danger/12 rounded-lg border border-danger/30 p-6 text-center">
<h1 className="text-lg font-semibold text-danger">
Publication Not Found
Expand Down
55 changes: 34 additions & 21 deletions app/api/cron/daily-review/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<string, number>;
Expand Down Expand Up @@ -414,13 +425,15 @@ 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,
postsFlagged: postResult.flagged,
proposedTopics: postResult.proposed,
commentsModerated: commentResult.moderated,
commentsFlagged: commentResult.flagged,
affinityUsersUpdated: affinityResult.usersUpdated,
};

const digestSent = await sendDigest(summary);
Expand Down
13 changes: 10 additions & 3 deletions app/og/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 1 addition & 2 deletions components/Admin/AdminShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
155 changes: 0 additions & 155 deletions docs/plans/2026-06-09-moderation-overhaul-design.md

This file was deleted.

Loading
Loading