Skip to content

Commit d1ad53f

Browse files
feat: personalized 'For you' feed + open-source repo hygiene (#1336)
* chore: prettier-format OG card files * 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. * feat: personalized 'For you' feed (topic follow/mute + affinity) 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.
1 parent 5bdff59 commit d1ad53f

26 files changed

Lines changed: 7880 additions & 2138 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,8 @@ ssmSetup.zsh
7676
# Local-only deploy notes (never commit)
7777
local.md
7878
logs/
79+
80+
# AI assistant local config / worktrees / scratch (never commit)
81+
.claude/
82+
.cursor/
83+
.aider*

CLAUDE.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Codú — contributor & assistant guide
2+
3+
Codú is the community platform for AI builders and indie hackers: a curated feed
4+
of articles, tips, questions, and links, with profiles, discussions, moderation,
5+
and a build-board. This file orients anyone (human or AI assistant) working in the
6+
repo.
7+
8+
## ⚠️ This is a public, open-source repository
9+
10+
Everything committed here is public and permanent in git history. Before you write
11+
or commit anything:
12+
13+
- **No private or internal content.** No secrets, API keys, access tokens,
14+
customer data, internal planning docs, design docs, scratch notes, or
15+
personal/operational details. Local-only notes belong in gitignored files
16+
(`local.md`, `.claude/`).
17+
- **No assistant attribution or scratch artifacts.** Do not add AI co-author
18+
trailers, "generated by" notes, planning/design markdown, or tool config to
19+
commits, commit messages, or PR descriptions.
20+
- **Code must clear public code review.** Assume every line will be read by
21+
external contributors and maintainers. Hold a high bar: clear naming, no dead
22+
code, no debug logging, tests for new logic, and changes scoped to one concern.
23+
24+
## Stack
25+
26+
- **Next.js (App Router)** + React + TypeScript.
27+
- **tRPC** for the API (`server/api/router/*`), **Drizzle ORM** over **Postgres**
28+
(`server/db/schema.ts`, migrations in `drizzle/`).
29+
- **NextAuth** for auth; **Tailwind CSS** for styling (design tokens in
30+
`styles/globals.css`).
31+
- **AWS**: S3 (uploads), Bedrock (content moderation/analysis), CDK-managed cron
32+
Lambdas + EventBridge (`cdk/`). Deployed on **Vercel** (the `develop` branch is
33+
production; `db:migrate` runs on the production build).
34+
- **Testing**: Vitest unit tests (`*.test.ts`), Playwright e2e (`e2e/`).
35+
36+
## Layout (route groups = layout boundaries)
37+
38+
`app/` is split into route groups, each its own layout "world":
39+
40+
- `(app)` — the public 3-column rail shell (`AppShell`): feed (home), profiles,
41+
posts, discussions. The feed is the homepage.
42+
- `(admin)` — private, full-width admin cockpit (`AdminShell`); ADMIN-role gate in
43+
its `layout.tsx`. Not part of the public shell.
44+
- `(auth)`, `(editor)`, `(marketing)` — their own chrome.
45+
46+
A page that should not use the public rail shell does **not** live in `(app)` — it
47+
gets a sibling route group. Don't reach for runtime flags to opt out of a layout.
48+
49+
## Working in this repo
50+
51+
- **Before claiming done, run and pass locally:** `npm run lint`,
52+
`npm run prettier`, `npm run test:unit`, and `npm run build`. Migrations:
53+
`npm run db:generate` after schema changes (review the generated SQL).
54+
- **Schema changes** are additive where possible; migrations apply on the prod
55+
deploy, so never write a migration that can fail destructively.
56+
- **Match the surrounding code**: comment density, naming, and idioms. New tRPC
57+
procedures go in the relevant `server/api/router/*` file; keep DB access there.
58+
- **Keep PRs focused** — one concern per PR, with a clear description of what and
59+
why.

app/(app)/s/[sourceSlug]/_sourceProfileClient.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ const SourceProfileContent = ({ sourceSlug, initialProfile }: Props) => {
6565
// first render (including SSR) is the real profile rather than a skeleton.
6666
if (status === "error" || !pub) {
6767
return (
68-
<div className="mx-auto max-w-2xl px-0 py-4 sm:px-4 sm:py-8 text-fg">
68+
<div className="mx-auto max-w-2xl px-0 py-4 text-fg sm:px-4 sm:py-8">
6969
<div className="bg-danger/12 rounded-lg border border-danger/30 p-6 text-center">
7070
<h1 className="text-lg font-semibold text-danger">
7171
Publication Not Found

app/api/cron/daily-review/route.ts

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,31 +30,25 @@ import {
3030
type TopicVocabEntry,
3131
} from "@/server/lib/contentAnalysis";
3232
import { autoReview } from "@/server/lib/autoReview";
33+
import {
34+
findRecentlyActiveUsers,
35+
recomputeUserAffinity,
36+
} from "@/server/lib/topicAffinity";
3337
import sendEmail from "@/utils/sendEmail";
3438

35-
// Nightly review cron. Auth via Bearer CRON_SECRET (a headless scheduler can't
36-
// use admin-session auth); unset secret refuses to run (500), wrong/missing
37-
// token 401. Wired via AWS Lambda + EventBridge (cdk/lib/cron-stack.ts).
38-
//
39-
// Four incremental passes (see docs/plans/2026-06-14-admin-shell-and-ai-content-design.md):
40-
// 1. topic + sentiment tagging (posts)
41-
// 2. quality / spam scoring (posts) — passes 1+2 share one Bedrock call
42-
// 3. re-screen moderation (posts + comments) -> reports queue (source=system)
43-
// 4. daily digest -> email the founder only when something needs attention
44-
//
45-
// Everything is incremental (per-row analyzedAt / moderatedAt watermark) and
46-
// capped per run, so an empty worklist is a near-zero-cost no-op and a backfill
47-
// can't blow the Lambda timeout. Each item is isolated (try/catch + Sentry) so
48-
// one bad row never kills the batch.
39+
// Nightly review cron (auth via CRON_SECRET; invoked by EventBridge — see
40+
// cdk/lib/cron-stack.ts). Incremental, capped passes that no-op on an empty
41+
// worklist: topic/sentiment tagging, quality scoring, post+comment moderation
42+
// re-screen, affinity recompute, and a digest email. Each item is isolated
43+
// (try/catch + Sentry) so one bad row never kills the batch.
4944

5045
export const dynamic = "force-dynamic";
5146
export const maxDuration = 300;
5247

5348
const POST_CAP = 100;
5449
const COMMENT_CAP = 200;
55-
// Sentinel modelId for rows scored by the cheap heuristic (Bedrock disabled), so
56-
// they're distinguishable from human-curated rows (modelId IS NULL) and can be
57-
// upgraded once Bedrock is enabled.
50+
// Sentinel modelId for heuristic-scored rows (Bedrock off), so they're distinct
51+
// from human-curated rows (modelId IS NULL) and can be upgraded once it's on.
5852
const HEURISTIC_MODEL = "heuristic";
5953

6054
function isAuthorized(request: Request): boolean {
@@ -124,10 +118,8 @@ async function reviewPosts(
124118
const bedrock = isBedrockEnabled();
125119
const now = new Date().toISOString();
126120

127-
// Incremental worklist: published posts that have never been analysed, whose
128-
// AI metadata is stale (post edited / schema bumped), or that only have a
129-
// heuristic placeholder now that Bedrock is available. Rows with modelId IS
130-
// NULL are human-curated and deliberately skipped.
121+
// Worklist: published posts never analysed, stale (edited / schema bumped), or
122+
// a heuristic placeholder now Bedrock is on. modelId IS NULL = human-curated, skip.
131123
const staleBranches = [
132124
gt(posts.updatedAt, post_metadata.analyzedAt),
133125
lt(post_metadata.schemaVersion, ANALYSIS_SCHEMA_VERSION),
@@ -384,6 +376,25 @@ async function sendDigest(summary: {
384376
return true;
385377
}
386378

379+
const AFFINITY_USER_CAP = 500;
380+
381+
// Recompute implicit topic affinity for users who interacted in the last 24h.
382+
async function reviewAffinity(): Promise<{ usersUpdated: number }> {
383+
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
384+
const now = Date.now();
385+
const users = await findRecentlyActiveUsers(db, since, AFFINITY_USER_CAP);
386+
let usersUpdated = 0;
387+
for (const userId of users) {
388+
try {
389+
await recomputeUserAffinity(db, userId, now);
390+
usersUpdated += 1;
391+
} catch (err) {
392+
Sentry.captureException(err);
393+
}
394+
}
395+
return { usersUpdated };
396+
}
397+
387398
async function loadVocab(): Promise<{
388399
vocab: TopicVocabEntry[];
389400
slugToId: Map<string, number>;
@@ -414,13 +425,15 @@ async function handle(request: Request) {
414425
const { vocab, slugToId } = await loadVocab();
415426
const postResult = await reviewPosts(vocab, slugToId);
416427
const commentResult = await reviewComments();
428+
const affinityResult = await reviewAffinity();
417429

418430
const summary = {
419431
postsAnalyzed: postResult.analyzed,
420432
postsFlagged: postResult.flagged,
421433
proposedTopics: postResult.proposed,
422434
commentsModerated: commentResult.moderated,
423435
commentsFlagged: commentResult.flagged,
436+
affinityUsersUpdated: affinityResult.usersUpdated,
424437
};
425438

426439
const digestSent = await sendDigest(summary);

app/og/route.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,20 @@ async function logo(origin: string) {
3131
const res = await fetch(`${origin}/og/wordmark-white.png`);
3232
const bytes = new Uint8Array(await res.arrayBuffer());
3333
let binary = "";
34-
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
34+
for (let i = 0; i < bytes.length; i++)
35+
binary += String.fromCharCode(bytes[i]);
3536
return (_logo = `data:image/png;base64,${btoa(binary)}`);
3637
}
3738

3839
const list = (v: string | null) =>
39-
v ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
40-
const num = (v: string | null, d = 0) => (v != null && v !== "" ? Number(v) : d);
40+
v
41+
? v
42+
.split(",")
43+
.map((s) => s.trim())
44+
.filter(Boolean)
45+
: undefined;
46+
const num = (v: string | null, d = 0) =>
47+
v != null && v !== "" ? Number(v) : d;
4148

4249
export async function GET(req: Request) {
4350
try {

components/Admin/AdminShell.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ interface NavItem {
2626
soon?: boolean;
2727
}
2828

29-
// Sidebar sections. `soon` items are Phase 2/3 surfaces (see
30-
// docs/plans/2026-06-14-admin-shell-and-ai-content-design.md) — shown as the
29+
// Sidebar sections. `soon` items are planned surfaces — shown as the
3130
// roadmap but not linked until their routes exist.
3231
const NAV: NavItem[] = [
3332
{ name: "Overview", href: "/admin", icon: Squares2X2Icon },

docs/plans/2026-06-09-moderation-overhaul-design.md

Lines changed: 0 additions & 155 deletions
This file was deleted.

0 commit comments

Comments
 (0)