Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.
6 changes: 3 additions & 3 deletions app/(app)/[username]/[slug]/_userLinkDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ const UserLinkDetail = ({ username, contentSlug, initialContent }: Props) => {

if (status === "pending") {
return (
<div className="mx-auto max-w-prose px-0 py-4 sm:px-4 sm:py-8">
<div className="mx-auto max-w-prose py-4 sm:py-8">
<div className="animate-pulse">
<div className="mb-4 h-6 w-24 rounded bg-elevated" />
<div className="mb-4 h-4 w-48 rounded bg-elevated" />
Expand All @@ -134,7 +134,7 @@ const UserLinkDetail = ({ username, contentSlug, initialContent }: Props) => {

if (status === "error" || !linkContent) {
return (
<div className="mx-auto max-w-prose px-0 py-4 sm:px-4 sm:py-8">
<div className="mx-auto max-w-prose py-4 sm:py-8">
<Link
href="/"
className="mb-6 inline-flex items-center gap-1.5 font-mono text-sm text-muted transition-colors hover:text-fg"
Expand Down Expand Up @@ -173,7 +173,7 @@ const UserLinkDetail = ({ username, contentSlug, initialContent }: Props) => {
const isOwner = session?.user?.id === linkContent.author?.id;

return (
<article className="mx-auto max-w-prose px-0 py-4 sm:px-4 sm:py-8">
<article className="mx-auto max-w-prose py-4 sm:py-8">
<Link
href="/"
className="mb-6 inline-flex items-center gap-1.5 font-mono text-sm text-muted transition-colors hover:text-fg"
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/s/[sourceSlug]/[slug]/_feedArticleContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const FeedArticleContent = ({ sourceSlug, article }: Props) => {
const safeExternalUrl = safeExternalHref(article.externalUrl);

return (
<article className="mx-auto max-w-prose px-0 py-4 sm:px-4 sm:py-8">
<article className="mx-auto max-w-prose py-4 sm:py-8">
<Link
href="/"
className="mb-6 inline-flex items-center gap-1.5 font-mono text-sm text-muted transition-colors hover:text-fg"
Expand Down
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
2 changes: 1 addition & 1 deletion components/ContentDetail/PostReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ const PostReader = async ({
{articleSchema && <JsonLd data={articleSchema} />}
{breadcrumbSchema && <JsonLd data={breadcrumbSchema} />}

<div className="mx-auto max-w-3xl px-0 py-4 sm:px-4 sm:py-8">
<div className="mx-auto max-w-prose py-4 sm:py-8">
<nav className="mb-6 flex items-center gap-2 text-sm text-muted">
<Link href="/" className="hover:text-fg">
Feed
Expand Down
Loading
Loading