From e360d1feb5a64bac487ca920ed0dbbdebe05c336 Mon Sep 17 00:00:00 2001 From: vtempest <1274452+vtempest@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:40:11 -0700 Subject: [PATCH] . --- cf-app/.dev.vars.example | 13 + cf-app/.gitignore | 8 + cf-app/README.md | 124 ++++++ cf-app/docs/CLOUDFLARE-MIGRATION.md | 336 +++++++++++++++++ cf-app/drizzle.config.ts | 10 + cf-app/migrations/0000_init.sql | 195 ++++++++++ cf-app/next.config.mjs | 12 + cf-app/open-next.config.ts | 4 + cf-app/package.json | 37 ++ cf-app/src/app/api/_status/route.ts | 30 ++ cf-app/src/app/confirmForgotPassword/route.ts | 39 ++ .../src/app/debug/matchmaking-pool/route.ts | 8 + cf-app/src/app/forgotPassword/route.ts | 35 ++ cf-app/src/app/googleLogin/route.ts | 78 ++++ cf-app/src/app/layout.tsx | 16 + cf-app/src/app/leaderboard/route.ts | 101 +++++ cf-app/src/app/login/route.ts | 42 +++ cf-app/src/app/matchmaking/heartbeat/route.ts | 51 +++ cf-app/src/app/page.tsx | 24 ++ cf-app/src/app/signup/route.ts | 72 ++++ .../src/app/user/check-displayname/route.ts | 23 ++ cf-app/src/app/user/fetchprofile/route.ts | 126 +++++++ cf-app/src/app/user/updateprofile/route.ts | 55 +++ cf-app/src/app/verifyEmail/route.ts | 49 +++ cf-app/src/app/verifyToken/route.ts | 30 ++ cf-app/src/db/client.ts | 18 + cf-app/src/db/schema.ts | 327 ++++++++++++++++ cf-app/src/durable-objects/DebateRoom.ts | 215 +++++++++++ cf-app/src/lib/auth.ts | 69 ++++ cf-app/src/lib/cloudflare-env.d.ts | 28 ++ cf-app/src/lib/email.ts | 69 ++++ cf-app/src/lib/env.ts | 21 ++ cf-app/src/lib/gemini.ts | 49 +++ cf-app/src/lib/google.ts | 27 ++ cf-app/src/lib/http.ts | 28 ++ cf-app/src/lib/ids.ts | 33 ++ cf-app/src/lib/kv.ts | 111 ++++++ cf-app/src/lib/password.ts | 20 + cf-app/src/lib/users.ts | 48 +++ cf-app/src/worker/index.ts | 60 +++ cf-app/src/worker/matchmaking-sweep.ts | 49 +++ cf-app/tsconfig.json | 21 ++ cf-app/wrangler.toml | 54 +++ docs/REPOSITORY_GUIDE.md | 356 ++++++++++++++++++ 44 files changed, 3121 insertions(+) create mode 100644 cf-app/.dev.vars.example create mode 100644 cf-app/.gitignore create mode 100644 cf-app/README.md create mode 100644 cf-app/docs/CLOUDFLARE-MIGRATION.md create mode 100644 cf-app/drizzle.config.ts create mode 100644 cf-app/migrations/0000_init.sql create mode 100644 cf-app/next.config.mjs create mode 100644 cf-app/open-next.config.ts create mode 100644 cf-app/package.json create mode 100644 cf-app/src/app/api/_status/route.ts create mode 100644 cf-app/src/app/confirmForgotPassword/route.ts create mode 100644 cf-app/src/app/debug/matchmaking-pool/route.ts create mode 100644 cf-app/src/app/forgotPassword/route.ts create mode 100644 cf-app/src/app/googleLogin/route.ts create mode 100644 cf-app/src/app/layout.tsx create mode 100644 cf-app/src/app/leaderboard/route.ts create mode 100644 cf-app/src/app/login/route.ts create mode 100644 cf-app/src/app/matchmaking/heartbeat/route.ts create mode 100644 cf-app/src/app/page.tsx create mode 100644 cf-app/src/app/signup/route.ts create mode 100644 cf-app/src/app/user/check-displayname/route.ts create mode 100644 cf-app/src/app/user/fetchprofile/route.ts create mode 100644 cf-app/src/app/user/updateprofile/route.ts create mode 100644 cf-app/src/app/verifyEmail/route.ts create mode 100644 cf-app/src/app/verifyToken/route.ts create mode 100644 cf-app/src/db/client.ts create mode 100644 cf-app/src/db/schema.ts create mode 100644 cf-app/src/durable-objects/DebateRoom.ts create mode 100644 cf-app/src/lib/auth.ts create mode 100644 cf-app/src/lib/cloudflare-env.d.ts create mode 100644 cf-app/src/lib/email.ts create mode 100644 cf-app/src/lib/env.ts create mode 100644 cf-app/src/lib/gemini.ts create mode 100644 cf-app/src/lib/google.ts create mode 100644 cf-app/src/lib/http.ts create mode 100644 cf-app/src/lib/ids.ts create mode 100644 cf-app/src/lib/kv.ts create mode 100644 cf-app/src/lib/password.ts create mode 100644 cf-app/src/lib/users.ts create mode 100644 cf-app/src/worker/index.ts create mode 100644 cf-app/src/worker/matchmaking-sweep.ts create mode 100644 cf-app/tsconfig.json create mode 100644 cf-app/wrangler.toml create mode 100644 docs/REPOSITORY_GUIDE.md diff --git a/cf-app/.dev.vars.example b/cf-app/.dev.vars.example new file mode 100644 index 00000000..8dd49b11 --- /dev/null +++ b/cf-app/.dev.vars.example @@ -0,0 +1,13 @@ +# Copy to `.dev.vars` for local `wrangler`/`next dev`. Never commit `.dev.vars`. +# Use the SAME JWT_SECRET as the existing Go backend so tokens stay interoperable +# during a phased migration. +JWT_SECRET="dev-secret-change-me" +JWT_EXPIRY_MINUTES="1440" +APP_BASE_URL="http://localhost:3000" + +GOOGLE_OAUTH_CLIENT_ID="" +GEMINI_API_KEY="" + +EMAIL_PROVIDER="console" # console = log the email instead of sending +EMAIL_FROM="DebateAI " +RESEND_API_KEY="" diff --git a/cf-app/.gitignore b/cf-app/.gitignore new file mode 100644 index 00000000..9df1a1fd --- /dev/null +++ b/cf-app/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.next/ +.open-next/ +.wrangler/ +.dev.vars +*.tsbuildinfo +.env*.local +src/lib/cloudflare-env.d.ts.bak diff --git a/cf-app/README.md b/cf-app/README.md new file mode 100644 index 00000000..8cb036ba --- /dev/null +++ b/cf-app/README.md @@ -0,0 +1,124 @@ +# DebateAI — Cloudflare edition (`cf-app/`) + +A Next.js (App Router) app that runs entirely on Cloudflare Workers via +[`@opennextjs/cloudflare`](https://opennext.js.org/cloudflare), replacing the Go +backend's infrastructure: + +| Was (Go backend) | Now (this app) | +| ------------------------------------ | ----------------------------------------------- | +| MongoDB (`go.mongodb.org/mongo-driver`) | **D1** (SQLite) via Drizzle ORM | +| Redis (`redis/go-redis`) | **Workers KV** (TTL / ephemeral state) | +| gorilla WebSocket hub + turn timers | **Durable Object** `DebateRoom` + cron sweep | +| background goroutines | **Cron Triggers** (`scheduled()` handler) | +| `net/smtp` | HTTPS email (Resend / MailChannels) | +| `google.golang.org/genai` | Gemini REST via `fetch` | +| Gin route groups + `AuthMiddleware` | Next.js route handlers + `requireUser()` | +| Casbin + mongodb-adapter | `role_grants` / `user_roles` tables | + +This scaffold **fully ports auth, profile, and leaderboard** as the reference +pattern. Every other domain has a schema, an adapter, and an entry in +`GET /api/_status`. See [`docs/CLOUDFLARE-MIGRATION.md`](./docs/CLOUDFLARE-MIGRATION.md) +for the porting playbook and the MongoDB→D1 data-migration steps. + +--- + +## Prerequisites + +- Node 20+ +- A Cloudflare account + `npx wrangler login` + +## One-time setup + +```bash +cd cf-app +npm install + +# 1. Create the D1 database and KV namespace, then paste the IDs into wrangler.toml +npx wrangler d1 create debateai +npx wrangler kv namespace create KV + +# 2. Local secrets +cp .dev.vars.example .dev.vars +# -> set JWT_SECRET to the SAME value as the Go backend's jwt.secret +# so existing tokens keep working during a phased cutover + +# 3. Apply the schema to the local D1 +npm run db:migrate:local +``` + +## Run locally + +```bash +npm run dev # next dev, with real D1/KV/DO bindings via OpenNext +# app on http://localhost:3000 +``` + +`next dev` runs the route handlers but **not** `src/worker/index.ts` (the +WebSocket router + cron wrapper). To exercise those, build for Workers and run +the real runtime: + +```bash +npm run preview # opennextjs-cloudflare build && wrangler dev +``` + +Smoke test: + +```bash +curl -s localhost:3000/api/_status | jq +curl -s -XPOST localhost:3000/signup -H 'content-type: application/json' \ + -d '{"email":"a@b.com","password":"hunter2hunter2"}' +# EMAIL_PROVIDER=console -> the verification code is printed in the dev log +``` + +## Deploy + +```bash +# secrets (once per environment) +npx wrangler secret put JWT_SECRET +npx wrangler secret put GEMINI_API_KEY +npx wrangler secret put RESEND_API_KEY # if EMAIL_PROVIDER=resend + +npm run db:migrate:remote +npm run deploy # opennextjs-cloudflare build && wrangler deploy +``` + +## Using it from the existing React frontend + +The ported routes keep the **same paths and JSON shapes** as the Go API, so the +current `frontend/` works against this app by changing one env var: + +``` +VITE_BASE_URL="https://debateai..workers.dev" +``` + +Live-debate sockets move from `ws:///ws/debate/:id` (same path) — the token +is passed as `?token=` instead of an `Authorization` header, since browsers +can't set headers on `WebSocket`. `/ws/matchmaking` is replaced by polling +`POST /matchmaking/heartbeat` every ~30s. + +## Layout + +``` +cf-app/ + wrangler.toml bindings: DB (D1), KV, DEBATE_ROOM (DO), cron + open-next.config.ts OpenNext adapter config + drizzle.config.ts schema -> ./migrations + migrations/0000_init.sql runnable D1 schema + RBAC seed + src/ + db/schema.ts D1 tables (was Mongo collections) + db/client.ts getDb() -> drizzle(env.DB) + lib/ + auth.ts signToken / verifyToken / requireUser (was utils/auth.go + AuthMiddleware) + password.ts bcrypt (hashes migrate verbatim) + google.ts Google ID-token verify (was idtoken.Validate) + gemini.ts Gemini REST + email.ts Resend / MailChannels / console + kv.ts Redis replacement: matchmaking pool, rate limits, poll cache + users.ts userResponse / normalizeUserStats / nameFromEmail + http.ts json/ok/badRequest/... helpers + ids.ts ObjectID-compatible id generator + app/ route handlers (paths mirror the Go router) + durable-objects/DebateRoom.ts live debate: sockets + phase/turn state + alarm timer + worker/index.ts custom entry: WS routing + cron, wraps OpenNext + worker/matchmaking-sweep.ts cron pairing (was periodicMatchmaking goroutine) +``` diff --git a/cf-app/docs/CLOUDFLARE-MIGRATION.md b/cf-app/docs/CLOUDFLARE-MIGRATION.md new file mode 100644 index 00000000..82d15d02 --- /dev/null +++ b/cf-app/docs/CLOUDFLARE-MIGRATION.md @@ -0,0 +1,336 @@ +# DebateAI → Cloudflare Workers + D1 + KV: migration & integration guide + +This document explains how the Go backend (`backend/`) maps onto the Cloudflare +stack in `cf-app/`, how to move the data, and how to port each remaining domain. + +- [1. Target architecture](#1-target-architecture) +- [2. Service mapping](#2-service-mapping) +- [3. Data migration: MongoDB → D1](#3-data-migration-mongodb--d1) +- [4. Redis → KV](#4-redis--kv) +- [5. WebSockets → Durable Objects](#5-websockets--durable-objects) +- [6. Auth](#6-auth) +- [7. Porting a domain (worked recipe)](#7-porting-a-domain-worked-recipe) +- [8. Remaining domains — checklist](#8-remaining-domains--checklist) +- [9. Frontend integration](#9-frontend-integration) +- [10. Cutover strategy](#10-cutover-strategy) + +--- + +## 1. Target architecture + +``` + ┌──────────────────────── Cloudflare Worker (1 script) ───────────────────────┐ + browser ───▶│ src/worker/index.ts │ + (React) │ ├─ /ws/debate/:id ──▶ Durable Object DebateRoom (sockets + timers) │ + │ └─ everything else ──▶ OpenNext handler ──▶ Next.js App Router routes │ + │ │ │ + │ ├─ getDb() ──▶ D1 (SQLite) [was Mongo]│ + │ ├─ lib/kv ──▶ KV [was Redis]│ + │ ├─ lib/gemini ─▶ Gemini REST │ + │ └─ lib/email ─▶ Resend / MailChannels │ + │ scheduled() ── cron "* * * * *" ──▶ matchmaking sweep [was goroutine] │ + └────────────────────────────────────────────────────────────────────────────┘ +``` + +Everything is one Worker deployment. D1, KV and the DO are **bindings** on that +Worker (see `wrangler.toml`), not separate services to run or scale. + +## 2. Service mapping + +| Go package / file | Cloudflare equivalent | Notes | +| --- | --- | --- | +| `config/config.go` (`config.prod.yml`) | `src/lib/env.ts` | No runtime file. `[vars]` in `wrangler.toml`, `wrangler secret put`, `.dev.vars` locally. | +| `db/db.go` `ConnectMongoDB` | `src/db/client.ts` `getDb()` | Request-scoped; no pool, no connect step. | +| `db.GetCollection("x")` | Drizzle table in `src/db/schema.ts` | `db.select().from(x)` | +| `db/db.go` `ConnectRedis` | `src/lib/kv.ts` | KV namespace binding `KV`. | +| `middlewares/InitCasbin`, `middlewares/rbac.go` | `role_grants` + `user_roles` tables | One `SELECT` to check `(role, resource, action)`. | +| `middlewares/auth.go` `AuthMiddleware` | `src/lib/auth.ts` `requireUser()` | Same bearer-token → load-user flow. | +| `utils/auth.go` (JWT, bcrypt) | `src/lib/auth.ts` + `src/lib/password.ts` | `jose` HS256, `bcryptjs`. Token & hash formats unchanged. | +| `utils/email.go` (`net/smtp`) | `src/lib/email.ts` | Workers can't do raw SMTP; HTTPS providers. | +| `services/gemini.go`, `services/ai.go` | `src/lib/gemini.ts` | `fetch` to `generativelanguage.googleapis.com`. | +| `services/matchmaking.go` (in-mem map + goroutines) | `src/lib/kv.ts` `matchmaking` + `src/worker/matchmaking-sweep.ts` | Pool in KV; pairing on cron. | +| `websocket/*.go` (gorilla hub) | `src/durable-objects/DebateRoom.ts` | The one thing that must be a DO. | +| `internal/debate/redis_client.go`, `poll_store.go`, `rate_limiter.go`, `stream_consumer.go` | `src/lib/kv.ts` (`polls`, `rateLimit`) + DebateRoom storage | Strong-consistency parts belong in the DO; caches/counters in KV. | +| `cmd/server/main.go` `router.Run` | `src/worker/index.ts` `export default { fetch }` | | +| `utils/populate.go` `SeedDebateData` / `PopulateTestUsers` | a `scripts/seed.ts` you run with `wrangler d1 execute` | not included here | +| `transcribeService.py` (Whisper) | Workers AI `@cf/openai/whisper` **or** a standalone service | Python ML can't run on Workers. | + +## 3. Data migration: MongoDB → D1 + +**Model choice — "D1 + JSON columns".** Columns that are filtered / sorted / +counted get real typed columns + indexes (see `src/db/schema.ts`). Nested +sub-documents (turn arrays, AI evaluation blobs, rosters, per-format settings) +go into a single `data TEXT` JSON column, read with `json_extract()` when needed. + +**ID compatibility.** Mongo `_id` is a 12-byte ObjectID → 24 hex chars. D1 `id` +columns are `TEXT` holding that same hex string. `src/lib/ids.ts` `newId()` +produces new IDs in the identical format, so exported IDs migrate unchanged and +foreign-key-like references (`userId`, `authorId`, …) keep working. + +### Steps + +1. **Export** each collection from Mongo: + + ```bash + mongoexport --uri "$MONGO_URI" --collection users --jsonArray --out users.json + # repeat for: saved_debate_transcripts debates_vs_bot debates team_debates + # posts comments likes follows notifications rooms teams + # ratings_history admin_action_logs + ``` + +2. **Transform** to rows matching `schema.ts`. A tiny Node script per collection: + + ```ts + // scripts/xform-users.ts (run with: npx tsx) + import fs from "node:fs"; + const docs = JSON.parse(fs.readFileSync("users.json", "utf8")); + const rows = docs.map((d: any) => ({ + id: d._id.$oid ?? d._id, + email: d.email, + display_name: d.displayName ?? null, + nickname: d.nickname ?? null, + bio: d.bio ?? "", + rating: d.rating ?? 1200, + rd: d.rd ?? 350, + volatility: d.volatility ?? 0.06, + last_rating_update: iso(d.lastRatingUpdate), + avatar_url: d.avatarUrl ?? null, + password: d.password ?? null, + is_verified: d.isVerified ? 1 : 0, + verification_code: d.verificationCode ?? null, + reset_password_code: d.resetPasswordCode ?? null, + score: d.score ?? 0, + badges: JSON.stringify(d.badges ?? []), + current_streak: d.currentStreak ?? 0, + last_activity_date: iso(d.lastActivityDate), + created_at: iso(d.createdAt) ?? new Date().toISOString(), + updated_at: iso(d.updatedAt) ?? new Date().toISOString(), + })); + fs.writeFileSync("users.sql", toInsert("users", rows)); + + function iso(v: any) { + if (!v) return null; + return v.$date ? new Date(v.$date).toISOString() : new Date(v).toISOString(); + } + function toInsert(table: string, rows: any[]) { + return rows + .map((r) => { + const cols = Object.keys(r).join(","); + const vals = Object.values(r) + .map((x) => (x === null ? "NULL" : typeof x === "number" ? x : `'${String(x).replace(/'/g, "''")}'`)) + .join(","); + return `INSERT INTO ${table} (${cols}) VALUES (${vals});`; + }) + .join("\n"); + } + ``` + + - `debates_vs_bot.created_at` stays a **unix-seconds integer** (Go used `int64`). + - Anything without a dedicated column goes into `data` as `JSON.stringify(...)`. + - Dedupe on the unique indexes (`email`, `display_name`, `likes(post,user)`, + `follows(follower,followee)`) before import or the batch fails. + +3. **Load** into D1: + + ```bash + npx wrangler d1 execute debateai --remote --file=users.sql + # ...one per collection. For big files split into <100k-statement chunks. + ``` + +4. **Verify counts**: `SELECT count(*) FROM users;` vs the Mongo count. + +### Casbin → tables + +The Go RBAC model was `sub, obj, act`. Export the `casbin_rule` collection and +turn `p, , , ` lines into `role_grants` rows, and +`g, , ` lines into `user_roles` rows. The three default grants are +already seeded in `0000_init.sql`. Check permission with: + +```ts +const [grant] = await db.select().from(roleGrants) + .where(and(eq(roleGrants.role, role), eq(roleGrants.resource, res), eq(roleGrants.action, act))) + .limit(1); +``` + +## 4. Redis → KV + +| Redis usage (Go) | KV key | Consistency note | +| --- | --- | --- | +| matchmaking pool (in-mem map, but conceptually shared) | `mm:pool:` (TTL 120s) | eventually consistent; fine — pairing is a cron sweep | +| `rate:question::` | `rl:q::` (TTL) | **no atomic INCR** — best-effort abuse mitigation only | +| `rate:reaction::` | `rl:r::` (TTL) | same | +| `debate::poll::counts` | `poll:::counts` (snapshot) | authoritative tally lives in the DebateRoom DO; KV is a read cache | +| `debate::poll::voters` (SET) | `poll:::v:` (per-voter key, TTL) | | +| Redis Streams (`stream_consumer.go`) | DO storage + `broadcast()` | Streams have no KV equivalent; the DO fans out directly | + +**Rule of thumb:** if losing or double-counting a value would corrupt a live +debate result, it goes in the **DO** (`state.storage`, transactional). If it's a +counter, a cache, or a short-lived queue entry, **KV** is fine. + +If you need true atomic counters or pub/sub semantics, the drop-in is +**Upstash Redis** (HTTP, works from Workers) — add `UPSTASH_REDIS_REST_URL` / +`_TOKEN` and swap `src/lib/kv.ts` internals; the public API of that module is +designed to stay the same. + +## 5. WebSockets → Durable Objects + +Workers can accept a WebSocket in a plain `fetch`, but there's no shared memory +between isolates, so a debate room needs a single owner. That's `DebateRoom` +(`src/durable-objects/DebateRoom.ts`), one instance per `debateID` via +`idFromName(debateID)`. + +It reimplements: + +- **hub / broadcast** — `this.sessions` set + `broadcast()` +- **roles** — `debater` vs `spectator`, derived from `room.debaters` +- **phase machine** — `lobby → opening → cross → closing → voting → ended` +- **turn clock** — `state.storage.setAlarm(turnEndsAt)` → `alarm()` advances the + turn / phase and broadcasts a `timeout`. This replaces the Go `time.Timer` + goroutines in `services/team_turn_service.go` / `internal/debate`. +- **poll authority** — keep vote tallies in `state.storage` inside the DO; + mirror a snapshot to KV (`polls.putSnapshot`) for cheap reads elsewhere. + +**Client changes:** browsers can't set headers on `WebSocket`, so the JWT goes in +the query string: `wss:///ws/debate/?token=`. The Worker verifies +it (`src/worker/index.ts`), then forwards the upgrade to the DO with `?uid=` +attached. `reconnecting-websocket` (already a frontend dep) handles drops. + +**Still TODO in the DO skeleton:** persisting the final transcript to +`saved_debate_transcripts`, calling the rating update, spectator poll CRUD +messages, and WebRTC signaling relay (the Go app passed SDP/ICE through the same +socket — add `case "rtc-offer" / "rtc-answer" / "rtc-ice"` to `onMessage` and +`broadcast` them to the other debater). + +`/ws/gamification` and `/ws/team` — either give each its own DO +(`GamificationRoom`, `TeamRoom`) following the same shape, or, if they're just +notification fan-out, replace with SSE (`ReadableStream` from a route handler) +backed by a KV/DO pubsub. + +## 6. Auth + +- **JWT**: HS256, claims `{ sub: , iat, exp }` — byte-identical to + `generateJWT` in `controllers/auth.go`. Set `JWT_SECRET` to the **same** value + as the Go `jwt.secret` and tokens are mutually valid, so you can run both + backends side by side during cutover. +- **Passwords**: `bcryptjs`, cost 10 (= `bcrypt.DefaultCost`). Existing + `users.password` hashes verify unchanged. `bcryptjs` is pure-JS; a cost-10 + hash is a few hundred ms of isolate CPU — acceptable at login volume. Optional: + re-hash to WebCrypto PBKDF2 on next successful login. +- **Google**: `jose` `createRemoteJWKSet` against Google's certs + + issuer/audience check = `idtoken.Validate`. +- **`requireUser(req)`** returns the `User` row or a `Response` (401). Pattern: + + ```ts + const auth = await requireUser(req); + if (auth instanceof Response) return auth; + // auth is the user row (was c.GetString("email") / c.Get("userID")) + ``` + +## 7. Porting a domain (worked recipe) + +Example: **community** (`routes/community.go` + `controllers/{post,comment,like,follow}_controller.go`). + +1. **Schema** — already in `src/db/schema.ts` (`posts`, `comments`, `likes`, + `follows`). Add columns for anything the controller filters/sorts on; + everything else → `data`. + +2. **Route files** — mirror the Go paths under `src/app/`: + + ``` + src/app/posts/route.ts -> POST (create) + GET /posts/feed via ?feed=1 or a /posts/feed/route.ts + src/app/posts/[id]/route.ts -> GET, DELETE + src/app/posts/[id]/like/route.ts -> POST (toggle) + src/app/posts/top/likes/route.ts -> GET + src/app/comments/route.ts -> POST + src/app/comments/[transcriptId]/route.ts -> GET + src/app/users/[userId]/follow/route.ts -> POST, DELETE + ``` + + Next.js dynamic segments: `export async function GET(req, { params })`. + +3. **Handler body** — translate the Mongo calls: + + | Mongo | Drizzle | + | --- | --- | + | `col.InsertOne(ctx, doc)` | `db.insert(posts).values({ id: newId(), ... })` | + | `col.FindOne(ctx, bson.M{"_id": id})` | `db.select().from(posts).where(eq(posts.id, id)).limit(1)` | + | `col.Find(ctx, filter, opts.SetSort(...).SetLimit(n))` | `db.select().from(posts).where(...).orderBy(desc(posts.createdAt)).limit(n)` | + | `col.UpdateOne(ctx, filter, bson.M{"$set": patch})` | `db.update(posts).set(patch).where(...)` | + | `col.UpdateOne(..., bson.M{"$inc": {"likeCount": 1}})` | `db.update(posts).set({ likeCount: sql\`${posts.likeCount} + 1\` }).where(...)` | + | `col.CountDocuments(ctx, filter)` | `db.select({ n: count() }).from(posts).where(...)` | + | `col.DeleteOne(ctx, filter)` | `db.delete(posts).where(...)` | + | `col.Aggregate([...])` | usually a `groupBy` + join; or app-side after a `select` | + +4. **Auth + RBAC** — `const auth = await requireUser(req)` at the top; for admin/ + moderator deletes, add the `role_grants` check from §3. + +5. **Response shape** — keep `c.JSON` bodies identical so the frontend is + untouched. Use `ok()` / `badRequest()` from `src/lib/http.ts`. + +6. **Update `GET /api/_status`** — flip the domain from `todo` to `ported`. + +## 8. Remaining domains — checklist + +| Domain | Go source | Cloudflare work | Data | +| --- | --- | --- | --- | +| debate-vs-bot | `routes/debatevsbot.go`, `services/debatevsbot.go`, `controllers/debatevsbot_controller.go` | route handlers; stream the model reply or return once; `geminiGenerate()` | `debates_vs_bot` (`created_at` = unix int) | +| coach | `routes/coach.go`, `services/coach.go` | 2 handlers (`weak-statement`, `evaluate`); `geminiGenerate({ json: true })`; then `db.update(users).set({ score: sql\`score + ?\` })` | `users.score` | +| transcripts | `routes/transcriptroutes.go`, `controllers/transcript_controller.go` | CRUD on `saved_debate_transcripts`; the big turn array → `data` JSON | `saved_debate_transcripts` | +| community | see §7 | §7 | `posts/comments/likes/follows` | +| gamification | `routes/gamification.go`, `websocket/gamification*.go` | REST handlers now; `/ws/gamification` → SSE or a `GamificationRoom` DO | `users.badges/score/currentStreak` | +| notifications | `routes/notification.go` | list / mark-read / delete on `notifications` | `notifications` | +| rooms | `routes/rooms.go` | CRUD on `rooms`; join = append to `participants` JSON (or a `room_members` table) | `rooms` | +| team + team debate + team chat + team matchmaking | `routes/team*.go`, `services/team_*` | `teams` + `team_debates`; team matchmaking = second KV pool; team chat + turn clock = `TeamRoom` DO (clone `DebateRoom`) | `teams`, `team_debates` | +| admin | `routes/admin.go`, `controllers/{admin,analytics,comment}_controller.go` | separate `AdminAuth` (role check via `user_roles`); analytics = `count()` queries; log every action to `admin_action_logs` | `admin_action_logs`, `role_grants`, `user_roles` | +| rating | `services/rating_service.go`, `rating/` | pure function — port as `src/lib/rating.ts`; call from DebateRoom `finish()` and `/debate/result` | `users.rating/rd/volatility`, `ratings_history` | +| live debate results | `websocket/websocket.go` | DebateRoom `finish()` → write transcript + call rating | | +| transcription | `transcribeService.py` | Workers AI `env.AI.run("@cf/openai/whisper", ...)` (add `[ai] binding = "AI"`), or keep the Python service on Fly/Render and `fetch` it | audio in R2 if you need to store it | +| WebRTC signaling | inside the debate socket | add `rtc-*` message relay in `DebateRoom.onMessage` (TURN via Cloudflare Calls or an external TURN server) | | + +## 9. Frontend integration + +Two options. + +### A. Keep `frontend/` (Vite/React) as-is — fastest + +Point it at the Worker: + +``` +# frontend/.env +VITE_BASE_URL="https://debateai..workers.dev" +``` + +Because the ported routes preserve paths + JSON, most screens work immediately. +Only socket setup changes: + +```ts +// was: new WebSocket(`${WS_BASE}/ws/debate/${id}`) with Authorization somewhere +const ws = new ReconnectingWebSocket( + `${WS_BASE}/ws/debate/${id}?token=${accessToken}`, +); +``` + +and swap the `/ws/matchmaking` socket for a 30s `POST /matchmaking/heartbeat` +poll that reads `{ status, roomId }`. + +### B. Fold the frontend into this Next.js app — one deploy + +`npx create-next-app`-style move: copy `frontend/src` in, convert +`react-router` routes to App Router folders, replace `import.meta.env.VITE_*` +with `process.env.NEXT_PUBLIC_*`, and drop the AWS Amplify dev-deps. Server +Components can then call the DB directly instead of round-tripping through +`/api`. Bigger lift; do it after the API side is fully ported. + +## 10. Cutover strategy + +1. Ship `cf-app` with **auth + profile + leaderboard** (this scaffold). Same + `JWT_SECRET` as Go. +2. Put Cloudflare in front as the origin; **proxy unported paths** to the Go + service from `src/worker/index.ts` (`fetch(new Request("https://go-origin"+path, request))`) + so nothing 404s. +3. Port domains one at a time (§7), flipping each from proxy → native and + updating `/api/_status`. Migrate that collection's data (§3) just before you + flip it. +4. Port the live-debate DO + rating last; run a few real debates against it. +5. Remove the proxy and decommission MongoDB / Redis / the Go service. +6. (Optional) do frontend option B. diff --git a/cf-app/drizzle.config.ts b/cf-app/drizzle.config.ts new file mode 100644 index 00000000..d1d17a48 --- /dev/null +++ b/cf-app/drizzle.config.ts @@ -0,0 +1,10 @@ +import type { Config } from "drizzle-kit"; + +// `npm run db:generate` diffs src/db/schema.ts and writes SQL into ./migrations, +// which `wrangler d1 migrations apply` then runs against D1. +export default { + schema: "./src/db/schema.ts", + out: "./migrations", + dialect: "sqlite", + driver: "d1-http", +} satisfies Config; diff --git a/cf-app/migrations/0000_init.sql b/cf-app/migrations/0000_init.sql new file mode 100644 index 00000000..4ac18bce --- /dev/null +++ b/cf-app/migrations/0000_init.sql @@ -0,0 +1,195 @@ +-- Initial D1 schema for DebateAI (Cloudflare edition). +-- Hand-written to match src/db/schema.ts so `wrangler d1 migrations apply` works +-- before you run `npm run db:generate`. Regenerate from schema.ts thereafter. + +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `email` text NOT NULL, + `display_name` text, + `nickname` text, + `bio` text DEFAULT '', + `rating` real NOT NULL DEFAULT 1200, + `rd` real NOT NULL DEFAULT 350, + `volatility` real NOT NULL DEFAULT 0.06, + `last_rating_update` text, + `avatar_url` text, + `twitter` text, + `instagram` text, + `linkedin` text, + `password` text, + `is_verified` integer NOT NULL DEFAULT 0, + `verification_code` text, + `reset_password_code` text, + `score` integer NOT NULL DEFAULT 0, + `badges` text DEFAULT '[]', + `current_streak` integer NOT NULL DEFAULT 0, + `last_activity_date` text, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + `updated_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE UNIQUE INDEX `users_email_idx` ON `users` (`email`); +CREATE UNIQUE INDEX `users_display_name_idx` ON `users` (`display_name`); +CREATE INDEX `users_rating_idx` ON `users` (`rating`); + +CREATE TABLE `saved_debate_transcripts` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `topic` text, + `result` text, + `opponent` text, + `debate_type` text, + `data` text, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + `updated_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX `transcripts_user_idx` ON `saved_debate_transcripts` (`user_id`); +CREATE INDEX `transcripts_created_idx` ON `saved_debate_transcripts` (`created_at`); + +CREATE TABLE `debates_vs_bot` ( + `id` text PRIMARY KEY NOT NULL, + `email` text NOT NULL, + `user_id` text, + `outcome` text, + `created_at` integer NOT NULL, + `data` text +); +CREATE INDEX `dvb_email_idx` ON `debates_vs_bot` (`email`); +CREATE INDEX `dvb_created_idx` ON `debates_vs_bot` (`created_at`); + +CREATE TABLE `debates` ( + `id` text PRIMARY KEY NOT NULL, + `email` text NOT NULL, + `topic` text, + `result` text, + `elo_change` real DEFAULT 0, + `rating` real, + `date` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX `debates_email_idx` ON `debates` (`email`); +CREATE INDEX `debates_date_idx` ON `debates` (`date`); + +CREATE TABLE `team_debates` ( + `id` text PRIMARY KEY NOT NULL, + `status` text, + `format` text, + `data` text, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + `updated_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX `team_debates_status_idx` ON `team_debates` (`status`); +CREATE INDEX `team_debates_created_idx` ON `team_debates` (`created_at`); + +CREATE TABLE `posts` ( + `id` text PRIMARY KEY NOT NULL, + `author_id` text NOT NULL, + `content` text NOT NULL, + `like_count` integer NOT NULL DEFAULT 0, + `data` text, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX `posts_author_idx` ON `posts` (`author_id`); +CREATE INDEX `posts_created_idx` ON `posts` (`created_at`); +CREATE INDEX `posts_likes_idx` ON `posts` (`like_count`); + +CREATE TABLE `comments` ( + `id` text PRIMARY KEY NOT NULL, + `author_id` text NOT NULL, + `post_id` text, + `transcript_id` text, + `content` text NOT NULL, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX `comments_post_idx` ON `comments` (`post_id`); +CREATE INDEX `comments_transcript_idx` ON `comments` (`transcript_id`); + +CREATE TABLE `likes` ( + `id` text PRIMARY KEY NOT NULL, + `post_id` text NOT NULL, + `user_id` text NOT NULL, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE UNIQUE INDEX `likes_post_user_idx` ON `likes` (`post_id`,`user_id`); + +CREATE TABLE `follows` ( + `id` text PRIMARY KEY NOT NULL, + `follower_id` text NOT NULL, + `followee_id` text NOT NULL, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE UNIQUE INDEX `follows_pair_idx` ON `follows` (`follower_id`,`followee_id`); +CREATE INDEX `follows_followee_idx` ON `follows` (`followee_id`); + +CREATE TABLE `notifications` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `type` text, + `message` text, + `is_read` integer NOT NULL DEFAULT 0, + `data` text, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX `notifications_user_idx` ON `notifications` (`user_id`); + +CREATE TABLE `rooms` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `owner_id` text NOT NULL, + `topic` text, + `is_private` integer NOT NULL DEFAULT 0, + `participants` text DEFAULT '[]', + `data` text, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX `rooms_owner_idx` ON `rooms` (`owner_id`); + +CREATE TABLE `teams` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `owner_id` text NOT NULL, + `members` text DEFAULT '[]', + `data` text, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE `ratings_history` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `opponent_id` text, + `outcome` text, + `topic` text, + `rating_before` real, + `rating_after` real, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX `ratings_history_user_idx` ON `ratings_history` (`user_id`); + +CREATE TABLE `admin_action_logs` ( + `id` text PRIMARY KEY NOT NULL, + `admin_id` text NOT NULL, + `action` text NOT NULL, + `target_type` text, + `target_id` text, + `data` text, + `created_at` text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE TABLE `role_grants` ( + `id` text PRIMARY KEY NOT NULL, + `role` text NOT NULL, + `resource` text NOT NULL, + `action` text NOT NULL +); +CREATE UNIQUE INDEX `role_grants_idx` ON `role_grants` (`role`,`resource`,`action`); + +CREATE TABLE `user_roles` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `role` text NOT NULL +); +CREATE UNIQUE INDEX `user_roles_idx` ON `user_roles` (`user_id`,`role`); + +-- RBAC seed (was rbac_model.conf + Casbin policy rows) +INSERT INTO `role_grants` (`id`,`role`,`resource`,`action`) VALUES + ('seed-admin-debate-delete', 'admin', 'debate', 'delete'), + ('seed-admin-comment-delete', 'admin', 'comment', 'delete'), + ('seed-mod-comment-delete', 'moderator', 'comment', 'delete'); diff --git a/cf-app/next.config.mjs b/cf-app/next.config.mjs new file mode 100644 index 00000000..5355a59f --- /dev/null +++ b/cf-app/next.config.mjs @@ -0,0 +1,12 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // OpenNext handles the Workers adapter; nothing Cloudflare-specific is needed here. + eslint: { ignoreDuringBuilds: true }, + typescript: { ignoreBuildErrors: false }, +}; + +export default nextConfig; + +// Enable the Cloudflare bindings (env.DB, env.KV, ...) during `next dev`. +import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; +await initOpenNextCloudflareForDev(); diff --git a/cf-app/open-next.config.ts b/cf-app/open-next.config.ts new file mode 100644 index 00000000..b046b892 --- /dev/null +++ b/cf-app/open-next.config.ts @@ -0,0 +1,4 @@ +import { defineCloudflareConfig } from "@opennextjs/cloudflare"; + +// Default config. Incremental cache can be pointed at KV or R2 later if needed. +export default defineCloudflareConfig({}); diff --git a/cf-app/package.json b/cf-app/package.json new file mode 100644 index 00000000..6e606b1c --- /dev/null +++ b/cf-app/package.json @@ -0,0 +1,37 @@ +{ + "name": "debateai-cf", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --port 3000", + "build": "next build", + "cf:build": "opennextjs-cloudflare build", + "preview": "opennextjs-cloudflare build && wrangler dev", + "deploy": "opennextjs-cloudflare build && wrangler deploy", + "cf-typegen": "wrangler types --env-interface CloudflareEnv ./src/lib/cloudflare-env.d.ts", + "db:generate": "drizzle-kit generate", + "db:migrate:local": "wrangler d1 migrations apply debateai --local", + "db:migrate:remote": "wrangler d1 migrations apply debateai --remote", + "lint": "next lint" + }, + "dependencies": { + "next": "15.1.6", + "react": "19.0.0", + "react-dom": "19.0.0", + "drizzle-orm": "^0.38.3", + "jose": "^5.9.6", + "bcryptjs": "^2.4.3" + }, + "devDependencies": { + "@opennextjs/cloudflare": "^0.3.9", + "@cloudflare/workers-types": "^4.20250109.0", + "@types/bcryptjs": "^2.4.6", + "@types/node": "^22.10.5", + "@types/react": "19.0.4", + "@types/react-dom": "19.0.2", + "drizzle-kit": "^0.30.1", + "typescript": "^5.7.3", + "wrangler": "^3.101.0" + } +} diff --git a/cf-app/src/app/api/_status/route.ts b/cf-app/src/app/api/_status/route.ts new file mode 100644 index 00000000..42a47b11 --- /dev/null +++ b/cf-app/src/app/api/_status/route.ts @@ -0,0 +1,30 @@ +import { ok } from "@/lib/http"; + +/** + * GET /api/_status — living TODO map for the port. Each Go route group and its + * Cloudflare status. "ported" = done in this scaffold, "stub" = handler exists + * but returns 501, "todo" = not yet created. + */ +export async function GET() { + return ok({ + datastore: { mongo: "→ D1 (drizzle, src/db/schema.ts)", redis: "→ KV (src/lib/kv.ts)" }, + realtime: "→ Durable Object DebateRoom + cron sweep", + domains: { + auth: "ported", // /signup /login /verifyEmail /googleLogin /forgotPassword /confirmForgotPassword /verifyToken + profile: "ported", // /user/fetchprofile /user/updateprofile /user/check-displayname + leaderboard: "ported", // /leaderboard + matchmaking: "partial", // HTTP heartbeat + cron sweep done; UI wiring TODO + "debate-vs-bot": "todo", // routes/debatevsbot.go -> Gemini via src/lib/gemini.ts + coach: "todo", // routes/coach.go -> Gemini + "debate (live user-vs-user)": "partial", // DebateRoom DO skeleton; results/transcript persistence TODO + transcripts: "todo", // routes/transcriptroutes.go + community: "todo", // routes/community.go (posts/comments/likes/follows) + gamification: "todo", // routes/gamification.go + /ws/gamification + notifications: "todo", // routes/notification.go + rooms: "todo", // routes/rooms.go + team: "todo", // routes/team.go + /ws/team + admin: "todo", // routes/admin.go + Casbin -> role_grants/user_roles tables + "python transcription": "external", // transcribeService.py -> Workers AI Whisper or a separate service + }, + }); +} diff --git a/cf-app/src/app/confirmForgotPassword/route.ts b/cf-app/src/app/confirmForgotPassword/route.ts new file mode 100644 index 00000000..8799fa92 --- /dev/null +++ b/cf-app/src/app/confirmForgotPassword/route.ts @@ -0,0 +1,39 @@ +import { and, eq } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { users } from "@/db/schema"; +import { hashPassword } from "@/lib/password"; +import { badRequest, ok, readJson, serverError } from "@/lib/http"; + +// POST /confirmForgotPassword — port of controllers.VerifyForgotPassword +export async function POST(req: Request) { + const body = await readJson<{ email?: string; code?: string; newPassword?: string }>( + req, + ); + if (!body?.email || !body?.code || !body?.newPassword) { + return badRequest("Invalid input"); + } + const db = getDb(); + + const [user] = await db + .select({ id: users.id }) + .from(users) + .where( + and(eq(users.email, body.email), eq(users.resetPasswordCode, body.code)), + ) + .limit(1); + if (!user) return badRequest("Invalid email or reset code"); + + try { + await db + .update(users) + .set({ + password: await hashPassword(body.newPassword), + resetPasswordCode: null, + updatedAt: new Date().toISOString(), + }) + .where(eq(users.id, user.id)); + } catch (e) { + return serverError("Failed to reset password", { message: String(e) }); + } + return ok({ message: "Password successfully changed" }); +} diff --git a/cf-app/src/app/debug/matchmaking-pool/route.ts b/cf-app/src/app/debug/matchmaking-pool/route.ts new file mode 100644 index 00000000..34a4c53e --- /dev/null +++ b/cf-app/src/app/debug/matchmaking-pool/route.ts @@ -0,0 +1,8 @@ +import { matchmaking } from "@/lib/kv"; +import { ok } from "@/lib/http"; + +// GET /debug/matchmaking-pool — port of routes.GetMatchmakingPoolStatusHandler +export async function GET() { + const pool = (await matchmaking.list()).filter((e) => e.startedMatchmaking); + return ok({ pool, poolSize: pool.length, timestamp: new Date().toISOString() }); +} diff --git a/cf-app/src/app/forgotPassword/route.ts b/cf-app/src/app/forgotPassword/route.ts new file mode 100644 index 00000000..acdb4e4e --- /dev/null +++ b/cf-app/src/app/forgotPassword/route.ts @@ -0,0 +1,35 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { users } from "@/db/schema"; +import { numericCode } from "@/lib/ids"; +import { sendPasswordResetEmail } from "@/lib/email"; +import { badRequest, ok, readJson, serverError } from "@/lib/http"; + +// POST /forgotPassword — port of controllers.ForgotPassword +export async function POST(req: Request) { + const body = await readJson<{ email?: string }>(req); + if (!body?.email) return badRequest("Invalid input", { message: "Check email format" }); + const db = getDb(); + + const [user] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, body.email)) + .limit(1); + if (!user) return badRequest("User not found"); + + const code = numericCode(6); + await db + .update(users) + .set({ resetPasswordCode: code, updatedAt: new Date().toISOString() }) + .where(eq(users.id, user.id)); + + try { + await sendPasswordResetEmail(body.email, code); + } catch (e) { + return serverError("Failed to send reset email", { message: String(e) }); + } + return ok({ + message: "Password reset initiated. Check your email for further instructions.", + }); +} diff --git a/cf-app/src/app/googleLogin/route.ts b/cf-app/src/app/googleLogin/route.ts new file mode 100644 index 00000000..f1b93ae8 --- /dev/null +++ b/cf-app/src/app/googleLogin/route.ts @@ -0,0 +1,78 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { users } from "@/db/schema"; +import { verifyGoogleIdToken } from "@/lib/google"; +import { signToken } from "@/lib/auth"; +import { nameFromEmail, normalizeUserStats, userResponse } from "@/lib/users"; +import { newId } from "@/lib/ids"; +import { badRequest, ok, readJson, serverError, unauthorized } from "@/lib/http"; + +// POST /googleLogin — port of controllers.GoogleLogin +export async function POST(req: Request) { + const body = await readJson<{ idToken?: string }>(req); + if (!body?.idToken) return badRequest("Invalid input", { message: "idToken required" }); + + let payload; + try { + payload = await verifyGoogleIdToken(body.idToken); + } catch (e) { + return unauthorized("Invalid Google ID token"); + } + const email = payload.email; + if (!email) return badRequest("Email not found in Google token"); + + const nickname = payload.name || nameFromEmail(email); + const avatarUrl = payload.picture ?? ""; + const db = getDb(); + + let [user] = await db.select().from(users).where(eq(users.email, email)).limit(1); + + if (!user) { + const [dnTaken] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.displayName, nickname)) + .limit(1); + if (dnTaken) return badRequest("Display name already taken"); + + const now = new Date().toISOString(); + const row = { + id: newId(), + email, + displayName: nickname, + nickname, + bio: "", + rating: 1200, + rd: 350, + volatility: 0.06, + lastRatingUpdate: now, + avatarUrl, + isVerified: true, + score: 0, + badges: [] as string[], + currentStreak: 0, + createdAt: now, + updatedAt: now, + }; + try { + await db.insert(users).values(row); + } catch (e) { + return serverError("Failed to create user", { message: String(e) }); + } + [user] = await db.select().from(users).where(eq(users.id, row.id)).limit(1); + } + + const patch = normalizeUserStats(user!); + if (patch) await db.update(users).set(patch).where(eq(users.id, user!.id)); + + try { + const token = await signToken(user!.email); + return ok({ + message: "Google login successful", + accessToken: token, + user: userResponse({ ...user!, ...patch }), + }); + } catch (e) { + return serverError("Failed to generate token", { message: String(e) }); + } +} diff --git a/cf-app/src/app/layout.tsx b/cf-app/src/app/layout.tsx new file mode 100644 index 00000000..c18b983c --- /dev/null +++ b/cf-app/src/app/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: "DebateAI", + description: "AI-enhanced real-time debating platform — Cloudflare edition", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/cf-app/src/app/leaderboard/route.ts b/cf-app/src/app/leaderboard/route.ts new file mode 100644 index 00000000..eec5d4b4 --- /dev/null +++ b/cf-app/src/app/leaderboard/route.ts @@ -0,0 +1,101 @@ +import { and, count, desc, gte, lt, sql } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { + debates, + debatesVsBot, + savedDebateTranscripts, + teamDebates, + users, +} from "@/db/schema"; +import { requireUser } from "@/lib/auth"; +import { DEFAULT_AVATAR, nameFromEmail } from "@/lib/users"; +import { ok, serverError } from "@/lib/http"; + +// GET /leaderboard — port of controllers.GetLeaderboard +export async function GET(req: Request) { + const auth = await requireUser(req); + if (auth instanceof Response) return auth; + + const db = getDb(); + try { + const rows = await db.select().from(users).orderBy(desc(users.rating)); + + const debaters = rows.map((u, i) => { + const name = u.displayName || nameFromEmail(u.email); + return { + id: u.id, + rank: i + 1, + name, + score: u.score, + rating: Math.trunc(u.rating), + avatarUrl: u.avatarUrl || DEFAULT_AVATAR(name), + currentUser: u.email === auth.email, + }; + }); + + const dayStart = new Date(); + dayStart.setUTCHours(0, 0, 0, 0); + const startIso = dayStart.toISOString(); + const endIso = new Date(dayStart.getTime() + 86_400_000).toISOString(); + const startUnix = Math.floor(dayStart.getTime() / 1000); + const endUnix = startUnix + 86_400; + + const [[tCount], [tdCount], [dCount], [botCount], [activeTeam]] = await Promise.all([ + db + .select({ n: count() }) + .from(savedDebateTranscripts) + .where( + and( + gte(savedDebateTranscripts.createdAt, startIso), + lt(savedDebateTranscripts.createdAt, endIso), + ), + ), + db + .select({ n: count() }) + .from(teamDebates) + .where(and(gte(teamDebates.createdAt, startIso), lt(teamDebates.createdAt, endIso))), + db + .select({ n: count() }) + .from(debates) + .where(and(gte(debates.date, startIso), lt(debates.date, endIso))), + db + .select({ n: count() }) + .from(debatesVsBot) + .where( + and( + gte(debatesVsBot.createdAt, startUnix), + lt(debatesVsBot.createdAt, endUnix), + ), + ), + db + .select({ n: count() }) + .from(teamDebates) + .where(sql`${teamDebates.status} = 'active'`), + ]); + + const debatesToday = + (tCount?.n ?? 0) + (tdCount?.n ?? 0) + (dCount?.n ?? 0) + (botCount?.n ?? 0); + const debatingNow = activeTeam?.n ?? 0; + + const [experts] = await db + .select({ n: count() }) + .from(users) + .where( + and( + gte(users.rating, 1500), + gte(users.updatedAt, new Date(Date.now() - 30 * 60_000).toISOString()), + ), + ); + + const stats = [ + { icon: "crown", value: String(rows.length), label: "REGISTERED DEBATERS" }, + { icon: "chessQueen", value: String(debatesToday), label: "DEBATES TODAY" }, + { icon: "medal", value: String(debatingNow), label: "DEBATING NOW" }, + { icon: "crown", value: String(experts?.n ?? 0), label: "EXPERTS ONLINE" }, + ]; + + return ok({ debaters, stats }); + } catch (e) { + return serverError("Failed to fetch leaderboard data", { message: String(e) }); + } +} diff --git a/cf-app/src/app/login/route.ts b/cf-app/src/app/login/route.ts new file mode 100644 index 00000000..1b736fc5 --- /dev/null +++ b/cf-app/src/app/login/route.ts @@ -0,0 +1,42 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { users } from "@/db/schema"; +import { verifyPassword } from "@/lib/password"; +import { signToken } from "@/lib/auth"; +import { normalizeUserStats, userResponse } from "@/lib/users"; +import { badRequest, ok, readJson, serverError, unauthorized } from "@/lib/http"; + +// POST /login — port of controllers.Login +export async function POST(req: Request) { + const body = await readJson<{ email?: string; password?: string }>(req); + if (!body?.email || !body?.password) { + return badRequest("Invalid input", { message: "Check email and password format" }); + } + const db = getDb(); + + const [user] = await db + .select() + .from(users) + .where(eq(users.email, body.email)) + .limit(1); + if (!user) return unauthorized("Invalid email or password"); + + const patch = normalizeUserStats(user); + if (patch) await db.update(users).set(patch).where(eq(users.id, user.id)); + + if (!user.isVerified) return unauthorized("Email not verified"); + if (!user.password || !(await verifyPassword(body.password, user.password))) { + return unauthorized("Invalid email or password"); + } + + try { + const token = await signToken(user.email); + return ok({ + message: "Sign-in successful", + accessToken: token, + user: userResponse({ ...user, ...patch }), + }); + } catch (e) { + return serverError("Failed to generate token", { message: String(e) }); + } +} diff --git a/cf-app/src/app/matchmaking/heartbeat/route.ts b/cf-app/src/app/matchmaking/heartbeat/route.ts new file mode 100644 index 00000000..014a3fbd --- /dev/null +++ b/cf-app/src/app/matchmaking/heartbeat/route.ts @@ -0,0 +1,51 @@ +import { requireUser } from "@/lib/auth"; +import { matchmaking, type PoolEntry } from "@/lib/kv"; +import { badRequest, ok, readJson } from "@/lib/http"; + +/** + * POST /matchmaking/heartbeat — HTTP replacement for the `/ws/matchmaking` + * WebSocket loop (websocket.MatchmakingHandler + services.MatchmakingService). + * + * The browser polls this every ~30s while the "Find opponent" screen is open: + * { action: "join" | "start" | "leave" } + * + * Pairing itself is done by the cron sweep (see src/worker/index.ts `scheduled`), + * which reads the KV pool and, on a match, creates a DebateRoom and writes the + * room id onto both entries. The client sees `match` on its next heartbeat. + */ +export async function POST(req: Request) { + const auth = await requireUser(req); + if (auth instanceof Response) return auth; + + const body = await readJson<{ action?: "join" | "start" | "leave" }>(req); + if (!body?.action) return badRequest("action required"); + + if (body.action === "leave") { + await matchmaking.remove(auth.id); + return ok({ status: "left" }); + } + + const existing = await matchmaking.get(auth.id); + const elo = Math.trunc(auth.rating); + const entry: PoolEntry = { + userId: auth.id, + username: auth.displayName || auth.email, + elo, + minElo: elo - 200, + maxElo: elo + 200, + joinedAt: existing?.joinedAt ?? Date.now(), + lastActivity: Date.now(), + startedMatchmaking: + body.action === "start" ? true : existing?.startedMatchmaking ?? false, + }; + await matchmaking.upsert(entry); + + // If the sweep already paired this user, `matchState` will be present. + const state = await matchmaking.get(auth.id); + const matchRoomId = (state as PoolEntry & { matchRoomId?: string })?.matchRoomId; + return ok( + matchRoomId + ? { status: "matched", roomId: matchRoomId } + : { status: entry.startedMatchmaking ? "searching" : "queued" }, + ); +} diff --git a/cf-app/src/app/page.tsx b/cf-app/src/app/page.tsx new file mode 100644 index 00000000..43ff9b81 --- /dev/null +++ b/cf-app/src/app/page.tsx @@ -0,0 +1,24 @@ +export default function Home() { + return ( +
+

DebateAI — Cloudflare edition

+

+ Next.js on Workers · D1 (was MongoDB) · KV (was Redis) · Durable Objects + (live debates). This app can host the existing React frontend or be + consumed as an API by it. +

+

+ Ported reference endpoints: /signup, /login,{" "} + /verifyEmail, /googleLogin,{" "} + /forgotPassword, /confirmForgotPassword,{" "} + /verifyToken, /user/fetchprofile,{" "} + /user/updateprofile, /user/check-displayname,{" "} + /leaderboard, /debug/matchmaking-pool. +

+

+ See GET /api/_status for the full migration map, and{" "} + docs/CLOUDFLARE-MIGRATION.md for the porting guide. +

+
+ ); +} diff --git a/cf-app/src/app/signup/route.ts b/cf-app/src/app/signup/route.ts new file mode 100644 index 00000000..6d4bae76 --- /dev/null +++ b/cf-app/src/app/signup/route.ts @@ -0,0 +1,72 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { users } from "@/db/schema"; +import { hashPassword } from "@/lib/password"; +import { newId, numericCode } from "@/lib/ids"; +import { nameFromEmail } from "@/lib/users"; +import { sendVerificationEmail } from "@/lib/email"; +import { badRequest, ok, readJson, serverError } from "@/lib/http"; + +// POST /signup — port of controllers.SignUp +export async function POST(req: Request) { + const body = await readJson<{ email?: string; password?: string }>(req); + if (!body?.email || !body?.password) { + return badRequest("Invalid input", { message: "email and password required" }); + } + const db = getDb(); + + const [existing] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, body.email)) + .limit(1); + if (existing) return badRequest("User already exists"); + + const displayName = nameFromEmail(body.email); + const [dnTaken] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.displayName, displayName)) + .limit(1); + if (dnTaken) return badRequest("Display name already taken"); + + const code = numericCode(6); + const now = new Date().toISOString(); + + try { + await db.insert(users).values({ + id: newId(), + email: body.email, + displayName, + nickname: displayName, + bio: "", + rating: 1200, + rd: 350, + volatility: 0.06, + lastRatingUpdate: now, + avatarUrl: "https://api.dicebear.com/9.x/big-ears/svg?seed=Jude", + password: await hashPassword(body.password), + isVerified: false, + verificationCode: code, + score: 0, + badges: [], + currentStreak: 0, + createdAt: now, + updatedAt: now, + }); + } catch (e) { + const msg = String(e); + if (msg.includes("UNIQUE") && msg.includes("display_name")) { + return badRequest("Display name already taken"); + } + return serverError("Failed to create user", { message: msg }); + } + + try { + await sendVerificationEmail(body.email, code); + } catch (e) { + return serverError("Failed to send verification email", { message: String(e) }); + } + + return ok({ message: "Sign-up successful. Please verify your email." }); +} diff --git a/cf-app/src/app/user/check-displayname/route.ts b/cf-app/src/app/user/check-displayname/route.ts new file mode 100644 index 00000000..c592972c --- /dev/null +++ b/cf-app/src/app/user/check-displayname/route.ts @@ -0,0 +1,23 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { users } from "@/db/schema"; +import { requireUser } from "@/lib/auth"; +import { badRequest, ok } from "@/lib/http"; + +// GET /user/check-displayname?displayName=... — port of controllers.CheckDisplayName +export async function GET(req: Request) { + const auth = await requireUser(req); + if (auth instanceof Response) return auth; + + const displayName = (new URL(req.url).searchParams.get("displayName") ?? "").trim(); + if (!displayName) return badRequest("displayName query param required"); + + const db = getDb(); + const [existing] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.displayName, displayName)) + .limit(1); + + return ok({ available: !existing || existing.email === auth.email }); +} diff --git a/cf-app/src/app/user/fetchprofile/route.ts b/cf-app/src/app/user/fetchprofile/route.ts new file mode 100644 index 00000000..538438aa --- /dev/null +++ b/cf-app/src/app/user/fetchprofile/route.ts @@ -0,0 +1,126 @@ +import { desc, eq } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { savedDebateTranscripts, users } from "@/db/schema"; +import { requireUser } from "@/lib/auth"; +import { DEFAULT_AVATAR, nameFromEmail } from "@/lib/users"; +import { badRequest, notFound, ok, serverError } from "@/lib/http"; + +// GET /user/fetchprofile[?userId=] — port of controllers.GetProfile +export async function GET(req: Request) { + const auth = await requireUser(req); + if (auth instanceof Response) return auth; + + const db = getDb(); + const url = new URL(req.url); + const target = (url.searchParams.get("userId") ?? "").trim(); + + // --- Public path: another user's profile card ----------------------------- + if (target && target !== "undefined" && target !== "null") { + if (!/^[0-9a-f]{24}$/.test(target)) { + return badRequest("Invalid user ID format", { provided: target }); + } + const [u] = await db.select().from(users).where(eq(users.id, target)).limit(1); + if (!u) return notFound("User not found"); + + const displayName = u.displayName || nameFromEmail(u.email); + return ok({ + profile: { + id: u.id, + email: u.email, + displayName, + bio: u.bio, + rating: u.rating, + score: u.score, + badges: u.badges ?? [], + currentStreak: u.currentStreak, + avatarUrl: u.avatarUrl || DEFAULT_AVATAR(displayName), + lastActivityAt: u.lastActivityDate, + }, + }); + } + + // --- Authenticated user's full profile ----------------------------------- + const user = auth; + const displayName = user.displayName || nameFromEmail(user.email); + const avatar = user.avatarUrl || DEFAULT_AVATAR(displayName); + + let top5, transcripts; + try { + top5 = await db + .select() + .from(users) + .orderBy(desc(users.rating)) + .limit(5); + transcripts = await db + .select() + .from(savedDebateTranscripts) + .where(eq(savedDebateTranscripts.userId, user.id)) + .orderBy(desc(savedDebateTranscripts.createdAt)); + } catch (e) { + return serverError("Database error", { message: String(e) }); + } + + const leaderboard = top5.map((u, i) => { + const name = u.displayName || nameFromEmail(u.email); + return { + rank: i + 1, + name, + score: Math.trunc(u.rating), + avatarUrl: u.avatarUrl || DEFAULT_AVATAR(name), + currentUser: u.email === user.email, + }; + }); + + let wins = 0, + losses = 0, + draws = 0; + const eloHistory: { elo: number; date: string }[] = []; + const recentDebates: unknown[] = []; + for (const t of transcripts) { + if (recentDebates.length < 10) { + recentDebates.push({ + id: t.id, + topic: t.topic, + result: t.result, + opponent: t.opponent, + debateType: t.debateType, + date: t.createdAt, + eloChange: 0, + }); + } + eloHistory.push({ elo: Math.trunc(user.rating), date: t.createdAt ?? "" }); + if (t.result === "win") wins++; + else if (t.result === "loss") losses++; + else if (t.result === "draw") draws++; + } + const total = wins + losses + draws; + const winRate = total > 0 ? (wins / total) * 100 : 0; + + return ok({ + profile: { + id: user.id, + displayName, + email: user.email, + bio: user.bio, + rating: Math.trunc(user.rating), + score: user.score, + badges: user.badges ?? [], + currentStreak: user.currentStreak, + twitter: user.twitter, + instagram: user.instagram, + linkedin: user.linkedin, + avatarUrl: avatar, + }, + leaderboard, + stats: { + wins, + losses, + draws, + winRate, + totalDebates: total, + eloHistory, + debateHistory: [], + recentDebates, + }, + }); +} diff --git a/cf-app/src/app/user/updateprofile/route.ts b/cf-app/src/app/user/updateprofile/route.ts new file mode 100644 index 00000000..62986cf4 --- /dev/null +++ b/cf-app/src/app/user/updateprofile/route.ts @@ -0,0 +1,55 @@ +import { eq } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { users } from "@/db/schema"; +import { requireUser } from "@/lib/auth"; +import { badRequest, conflict, ok, readJson, serverError } from "@/lib/http"; + +// PUT /user/updateprofile — port of controllers.UpdateProfile +export async function PUT(req: Request) { + const auth = await requireUser(req); + if (auth instanceof Response) return auth; + + const body = await readJson<{ + displayName?: string; + bio?: string; + twitter?: string; + instagram?: string; + linkedin?: string; + avatarUrl?: string; + }>(req); + if (!body) return badRequest("Invalid body"); + + const db = getDb(); + const newDisplayName = (body.displayName ?? "").trim(); + + if (newDisplayName) { + const [existing] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.displayName, newDisplayName)) + .limit(1); + if (existing && existing.email !== auth.email) { + return conflict("Display name already taken"); + } + } + + try { + await db + .update(users) + .set({ + displayName: newDisplayName || auth.displayName, + bio: (body.bio ?? "").trim(), + twitter: (body.twitter ?? "").trim(), + instagram: (body.instagram ?? "").trim(), + linkedin: (body.linkedin ?? "").trim(), + avatarUrl: (body.avatarUrl ?? "").trim(), + updatedAt: new Date().toISOString(), + }) + .where(eq(users.email, auth.email)); + } catch (e) { + if (String(e).includes("UNIQUE")) return conflict("Display name already taken"); + return serverError("Failed to update profile"); + } + + return ok({ message: "Profile updated successfully" }); +} diff --git a/cf-app/src/app/verifyEmail/route.ts b/cf-app/src/app/verifyEmail/route.ts new file mode 100644 index 00000000..901e7cf5 --- /dev/null +++ b/cf-app/src/app/verifyEmail/route.ts @@ -0,0 +1,49 @@ +import { and, eq } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { users } from "@/db/schema"; +import { signToken } from "@/lib/auth"; +import { userResponse } from "@/lib/users"; +import { badRequest, ok, readJson, serverError } from "@/lib/http"; + +// POST /verifyEmail — port of controllers.VerifyEmail +export async function POST(req: Request) { + const body = await readJson<{ email?: string; confirmationCode?: string }>(req); + if (!body?.email || !body?.confirmationCode) { + return badRequest("Invalid input"); + } + const db = getDb(); + + const [user] = await db + .select() + .from(users) + .where( + and( + eq(users.email, body.email), + eq(users.verificationCode, body.confirmationCode), + ), + ) + .limit(1); + if (!user) return badRequest("Invalid email or verification code"); + + const ageMs = Date.now() - new Date(user.createdAt ?? 0).getTime(); + if (ageMs > 24 * 60 * 60 * 1000) { + return badRequest("Verification code expired. Please sign up again."); + } + + const now = new Date().toISOString(); + await db + .update(users) + .set({ isVerified: true, verificationCode: null, updatedAt: now }) + .where(eq(users.id, user.id)); + + try { + const token = await signToken(user.email); + return ok({ + message: "Email verification successful. You are now logged in.", + accessToken: token, + user: userResponse({ ...user, isVerified: true, updatedAt: now }), + }); + } catch (e) { + return serverError("Failed to generate token", { message: String(e) }); + } +} diff --git a/cf-app/src/app/verifyToken/route.ts b/cf-app/src/app/verifyToken/route.ts new file mode 100644 index 00000000..217cacdb --- /dev/null +++ b/cf-app/src/app/verifyToken/route.ts @@ -0,0 +1,30 @@ +import { requireUser } from "@/lib/auth"; +import { ok } from "@/lib/http"; + +// POST /verifyToken — port of controllers.VerifyToken +export async function POST(req: Request) { + const auth = await requireUser(req); + if (auth instanceof Response) return auth; + + return ok({ + message: "Token is valid", + user: { + id: auth.id, + email: auth.email, + displayName: auth.displayName, + nickname: auth.nickname, + bio: auth.bio, + rating: auth.rating, + rd: auth.rd, + volatility: auth.volatility, + lastRatingUpdate: auth.lastRatingUpdate ?? "", + avatarUrl: auth.avatarUrl, + twitter: auth.twitter, + instagram: auth.instagram, + linkedin: auth.linkedin, + isVerified: auth.isVerified, + createdAt: auth.createdAt ?? "", + updatedAt: auth.updatedAt ?? "", + }, + }); +} diff --git a/cf-app/src/db/client.ts b/cf-app/src/db/client.ts new file mode 100644 index 00000000..a8b12ba6 --- /dev/null +++ b/cf-app/src/db/client.ts @@ -0,0 +1,18 @@ +import { drizzle } from "drizzle-orm/d1"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import * as schema from "./schema"; + +/** + * Drizzle client bound to the request's D1 instance. + * + * Replaces `db.MongoDatabase` / `db.GetCollection(...)` from the Go backend. + * There is no long-lived connection to manage — D1 is request-scoped, so call + * this inside each route handler rather than at module top level. + */ +export function getDb() { + const { env } = getCloudflareContext(); + return drizzle(env.DB, { schema }); +} + +export { schema }; +export type Db = ReturnType; diff --git a/cf-app/src/db/schema.ts b/cf-app/src/db/schema.ts new file mode 100644 index 00000000..858676a8 --- /dev/null +++ b/cf-app/src/db/schema.ts @@ -0,0 +1,327 @@ +/** + * D1 (SQLite) schema — the relational replacement for the Go backend's MongoDB. + * + * Strategy ("D1 + JSON columns"): columns that are filtered, sorted, joined, or + * counted on get real typed columns and indexes. Nested / loosely-structured + * sub-documents (debate turn arrays, AI evaluation blobs, per-format settings, + * team rosters, etc.) live in a single `data` TEXT column holding JSON, queried + * with `json_extract()` on the rare occasions that's needed. + * + * IDs: Mongo ObjectIDs become 24-char lowercase-hex strings (see lib/ids.ts + * `newId()`), so any hex ObjectID exported from Mongo migrates unchanged. + */ +import { sql } from "drizzle-orm"; +import { + index, + integer, + real, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; + +/** ISO-8601 string timestamp column with a default of "now". */ +const ts = (name: string) => + text(name).notNull().default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`); + +/** JSON blob column. Read/write via JSON.parse/stringify in the repo layer. */ +const json = (name: string) => text(name, { mode: "json" }); + +// --------------------------------------------------------------------------- +// users (Mongo: "users") +// --------------------------------------------------------------------------- +export const users = sqliteTable( + "users", + { + id: text("id").primaryKey(), + email: text("email").notNull(), + displayName: text("display_name"), + nickname: text("nickname"), + bio: text("bio").default(""), + + // Glicko-2 / rating fields — queried and sorted on the leaderboard. + rating: real("rating").notNull().default(1200), + rd: real("rd").notNull().default(350), + volatility: real("volatility").notNull().default(0.06), + lastRatingUpdate: text("last_rating_update"), + + avatarUrl: text("avatar_url"), + twitter: text("twitter"), + instagram: text("instagram"), + linkedin: text("linkedin"), + + password: text("password"), // bcrypt hash (nullable for Google-only accounts) + isVerified: integer("is_verified", { mode: "boolean" }).notNull().default(false), + verificationCode: text("verification_code"), + resetPasswordCode: text("reset_password_code"), + + score: integer("score").notNull().default(0), + badges: json("badges").$type().default(sql`'[]'`), + currentStreak: integer("current_streak").notNull().default(0), + lastActivityDate: text("last_activity_date"), + + createdAt: ts("created_at"), + updatedAt: ts("updated_at"), + }, + (t) => ({ + emailIdx: uniqueIndex("users_email_idx").on(t.email), + displayNameIdx: uniqueIndex("users_display_name_idx").on(t.displayName), + ratingIdx: index("users_rating_idx").on(t.rating), + }), +); + +// --------------------------------------------------------------------------- +// saved_debate_transcripts (Mongo: "saved_debate_transcripts") +// --------------------------------------------------------------------------- +export const savedDebateTranscripts = sqliteTable( + "saved_debate_transcripts", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + topic: text("topic"), + result: text("result"), // win | loss | draw | pending + opponent: text("opponent"), + debateType: text("debate_type"), + // full turn-by-turn transcript, scores, AI feedback + data: json("data"), + createdAt: ts("created_at"), + updatedAt: ts("updated_at"), + }, + (t) => ({ + userIdx: index("transcripts_user_idx").on(t.userId), + createdIdx: index("transcripts_created_idx").on(t.createdAt), + }), +); + +// --------------------------------------------------------------------------- +// debates_vs_bot (Mongo: "debates_vs_bot") +// --------------------------------------------------------------------------- +export const debatesVsBot = sqliteTable( + "debates_vs_bot", + { + id: text("id").primaryKey(), + email: text("email").notNull(), + userId: text("user_id"), + outcome: text("outcome"), + createdAt: integer("created_at").notNull(), // unix seconds, matches Go int64 + data: json("data"), + }, + (t) => ({ + emailIdx: index("dvb_email_idx").on(t.email), + createdIdx: index("dvb_created_idx").on(t.createdAt), + }), +); + +// --------------------------------------------------------------------------- +// debates (Mongo: "debates" — lightweight elo-history rows) +// --------------------------------------------------------------------------- +export const debates = sqliteTable( + "debates", + { + id: text("id").primaryKey(), + email: text("email").notNull(), + topic: text("topic"), + result: text("result"), + eloChange: real("elo_change").default(0), + rating: real("rating"), + date: ts("date"), + }, + (t) => ({ + emailIdx: index("debates_email_idx").on(t.email), + dateIdx: index("debates_date_idx").on(t.date), + }), +); + +// --------------------------------------------------------------------------- +// team_debates (Mongo: "team_debates") +// --------------------------------------------------------------------------- +export const teamDebates = sqliteTable( + "team_debates", + { + id: text("id").primaryKey(), + status: text("status"), // active | completed | ... + format: text("format"), + data: json("data"), // rosters, turn order, per-side scores + createdAt: ts("created_at"), + updatedAt: ts("updated_at"), + }, + (t) => ({ + statusIdx: index("team_debates_status_idx").on(t.status), + createdIdx: index("team_debates_created_idx").on(t.createdAt), + }), +); + +// --------------------------------------------------------------------------- +// Community: posts / comments / likes / follows +// --------------------------------------------------------------------------- +export const posts = sqliteTable( + "posts", + { + id: text("id").primaryKey(), + authorId: text("author_id").notNull(), + content: text("content").notNull(), + likeCount: integer("like_count").notNull().default(0), + data: json("data"), + createdAt: ts("created_at"), + }, + (t) => ({ + authorIdx: index("posts_author_idx").on(t.authorId), + createdIdx: index("posts_created_idx").on(t.createdAt), + likesIdx: index("posts_likes_idx").on(t.likeCount), + }), +); + +export const comments = sqliteTable( + "comments", + { + id: text("id").primaryKey(), + authorId: text("author_id").notNull(), + // a comment targets either a post or a transcript (mirrors the Go routes) + postId: text("post_id"), + transcriptId: text("transcript_id"), + content: text("content").notNull(), + createdAt: ts("created_at"), + }, + (t) => ({ + postIdx: index("comments_post_idx").on(t.postId), + transcriptIdx: index("comments_transcript_idx").on(t.transcriptId), + }), +); + +export const likes = sqliteTable( + "likes", + { + id: text("id").primaryKey(), + postId: text("post_id").notNull(), + userId: text("user_id").notNull(), + createdAt: ts("created_at"), + }, + (t) => ({ + uniq: uniqueIndex("likes_post_user_idx").on(t.postId, t.userId), + }), +); + +export const follows = sqliteTable( + "follows", + { + id: text("id").primaryKey(), + followerId: text("follower_id").notNull(), + followeeId: text("followee_id").notNull(), + createdAt: ts("created_at"), + }, + (t) => ({ + uniq: uniqueIndex("follows_pair_idx").on(t.followerId, t.followeeId), + followeeIdx: index("follows_followee_idx").on(t.followeeId), + }), +); + +// --------------------------------------------------------------------------- +// notifications (Mongo: "notifications") +// --------------------------------------------------------------------------- +export const notifications = sqliteTable( + "notifications", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + type: text("type"), + message: text("message"), + isRead: integer("is_read", { mode: "boolean" }).notNull().default(false), + data: json("data"), + createdAt: ts("created_at"), + }, + (t) => ({ + userIdx: index("notifications_user_idx").on(t.userId), + }), +); + +// --------------------------------------------------------------------------- +// rooms (Mongo: "rooms" — custom debate rooms) +// --------------------------------------------------------------------------- +export const rooms = sqliteTable( + "rooms", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + ownerId: text("owner_id").notNull(), + topic: text("topic"), + isPrivate: integer("is_private", { mode: "boolean" }).notNull().default(false), + participants: json("participants").$type().default(sql`'[]'`), + data: json("data"), + createdAt: ts("created_at"), + }, + (t) => ({ ownerIdx: index("rooms_owner_idx").on(t.ownerId) }), +); + +// --------------------------------------------------------------------------- +// teams (Mongo: "teams") +// --------------------------------------------------------------------------- +export const teams = sqliteTable("teams", { + id: text("id").primaryKey(), + name: text("name").notNull(), + ownerId: text("owner_id").notNull(), + members: json("members").$type().default(sql`'[]'`), + data: json("data"), + createdAt: ts("created_at"), +}); + +// --------------------------------------------------------------------------- +// ratings_history (rating-service audit rows) +// --------------------------------------------------------------------------- +export const ratingsHistory = sqliteTable( + "ratings_history", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + opponentId: text("opponent_id"), + outcome: text("outcome"), + topic: text("topic"), + ratingBefore: real("rating_before"), + ratingAfter: real("rating_after"), + createdAt: ts("created_at"), + }, + (t) => ({ userIdx: index("ratings_history_user_idx").on(t.userId) }), +); + +// --------------------------------------------------------------------------- +// admin_action_logs (Mongo: "admin_action_logs") +// --------------------------------------------------------------------------- +export const adminActionLogs = sqliteTable("admin_action_logs", { + id: text("id").primaryKey(), + adminId: text("admin_id").notNull(), + action: text("action").notNull(), + targetType: text("target_type"), + targetId: text("target_id"), + data: json("data"), + createdAt: ts("created_at"), +}); + +// --------------------------------------------------------------------------- +// permissions (replaces Casbin + casbin/mongodb-adapter) +// The Go app used an RBAC model `sub, obj, act`. That collapses to a flat +// grant table plus a role column on membership; check with a single SELECT. +// --------------------------------------------------------------------------- +export const roleGrants = sqliteTable( + "role_grants", + { + id: text("id").primaryKey(), + role: text("role").notNull(), // admin | moderator | user + resource: text("resource").notNull(), // debate | comment | ... + action: text("action").notNull(), // delete | update | ... + }, + (t) => ({ + uniq: uniqueIndex("role_grants_idx").on(t.role, t.resource, t.action), + }), +); + +export const userRoles = sqliteTable( + "user_roles", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + role: text("role").notNull(), + }, + (t) => ({ uniq: uniqueIndex("user_roles_idx").on(t.userId, t.role) }), +); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; diff --git a/cf-app/src/durable-objects/DebateRoom.ts b/cf-app/src/durable-objects/DebateRoom.ts new file mode 100644 index 00000000..ef902ad6 --- /dev/null +++ b/cf-app/src/durable-objects/DebateRoom.ts @@ -0,0 +1,215 @@ +/** + * DebateRoom — one instance per live debate (id = debateID). + * + * Replaces the Go WebSocket layer that Workers genuinely cannot host on plain + * fetch: + * websocket/websocket.go (hub, broadcast, turn state) + * websocket/debate_spectator.go (spectator join, polls, reactions) + * internal/debate/* (phase timers, poll store, rate limiting) + * services/team_turn_service.go (turn clock) + * + * Why a DO and not KV: a debate needs a single authoritative in-memory copy of + * "whose turn is it, how many seconds are left, who has voted" plus a real + * timer. DOs give you exactly one instance, transactional `state.storage`, and + * `state.storage.setAlarm()` for the clock. + * + * Client: `new WebSocket("wss:///ws/debate/?token=")`. + * The Worker (src/worker/index.ts) authenticates, then forwards the upgrade here. + */ + +type Role = "debater" | "spectator"; +type Phase = "lobby" | "opening" | "cross" | "closing" | "voting" | "ended"; + +interface Session { + ws: WebSocket; + userId: string; + role: Role; +} + +interface RoomState { + phase: Phase; + turnUserId: string | null; + turnEndsAt: number | null; // epoch ms + debaters: string[]; // userIds, in speaking order + format: string; +} + +const PHASE_SECONDS: Record = { + lobby: 0, + opening: 120, + cross: 90, + closing: 90, + voting: 60, + ended: 0, +}; + +export class DebateRoom implements DurableObject { + private sessions = new Set(); + private room: RoomState = { + phase: "lobby", + turnUserId: null, + turnEndsAt: null, + debaters: [], + format: "standard", + }; + + constructor( + private state: DurableObjectState, + private env: CloudflareEnv, + ) { + this.state.blockConcurrencyWhile(async () => { + const saved = await this.state.storage.get("room"); + if (saved) this.room = saved; + }); + } + + async fetch(req: Request): Promise { + const url = new URL(req.url); + + // REST sub-endpoints used by the cron sweep / route handlers. + if (url.pathname.endsWith("/init") && req.method === "POST") { + const body = (await req.json()) as { debaters: string[]; format?: string }; + this.room.debaters = body.debaters; + this.room.format = body.format ?? "standard"; + await this.persist(); + return Response.json({ ok: true }); + } + if (url.pathname.endsWith("/state")) { + return Response.json(this.room); + } + + // WebSocket upgrade. + if (req.headers.get("Upgrade") !== "websocket") { + return new Response("expected websocket", { status: 426 }); + } + const userId = url.searchParams.get("uid") ?? ""; + const role: Role = this.room.debaters.includes(userId) ? "debater" : "spectator"; + + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + this.accept(server, userId, role); + return new Response(null, { status: 101, webSocket: client }); + } + + private accept(ws: WebSocket, userId: string, role: Role) { + ws.accept(); + const session: Session = { ws, userId, role }; + this.sessions.add(session); + + ws.send(JSON.stringify({ type: "welcome", role, room: this.room })); + this.broadcast({ type: "presence", count: this.sessions.size }, session); + + ws.addEventListener("message", (evt) => this.onMessage(session, evt)); + const bye = () => { + this.sessions.delete(session); + this.broadcast({ type: "presence", count: this.sessions.size }); + }; + ws.addEventListener("close", bye); + ws.addEventListener("error", bye); + } + + private async onMessage(session: Session, evt: MessageEvent) { + let msg: { type: string; [k: string]: unknown }; + try { + msg = JSON.parse(typeof evt.data === "string" ? evt.data : ""); + } catch { + return; + } + + switch (msg.type) { + // A debater submits their argument for the current turn. + case "argument": { + if (session.role !== "debater" || session.userId !== this.room.turnUserId) { + return session.ws.send(JSON.stringify({ type: "error", error: "not your turn" })); + } + this.broadcast({ + type: "argument", + userId: session.userId, + text: String(msg.text ?? ""), + phase: this.room.phase, + }); + await this.advanceTurn(); + break; + } + + // Host starts the debate / moves to the next phase. + case "start": + case "next-phase": { + if (session.role !== "debater") return; + await this.nextPhase(); + break; + } + + // Spectator reaction / chat — relayed, rate limiting handled at the edge. + case "reaction": + case "chat": { + this.broadcast({ + type: msg.type, + userId: session.userId, + value: msg.value ?? msg.text ?? "", + }); + break; + } + } + } + + // --- phase / turn machine ------------------------------------------------ + private async nextPhase() { + const order: Phase[] = ["lobby", "opening", "cross", "closing", "voting", "ended"]; + const idx = order.indexOf(this.room.phase); + this.room.phase = order[Math.min(idx + 1, order.length - 1)]; + this.room.turnUserId = this.room.debaters[0] ?? null; + await this.startTurnTimer(); + this.broadcast({ type: "phase", room: this.room }); + if (this.room.phase === "ended") await this.finish(); + } + + private async advanceTurn() { + const i = this.room.debaters.indexOf(this.room.turnUserId ?? ""); + const next = this.room.debaters[i + 1]; + if (next) { + this.room.turnUserId = next; + await this.startTurnTimer(); + this.broadcast({ type: "turn", room: this.room }); + } else { + await this.nextPhase(); + } + } + + private async startTurnTimer() { + const secs = PHASE_SECONDS[this.room.phase]; + this.room.turnEndsAt = secs ? Date.now() + secs * 1000 : null; + await this.persist(); + if (this.room.turnEndsAt) await this.state.storage.setAlarm(this.room.turnEndsAt); + } + + // Fired by the DO runtime when the turn clock runs out. + async alarm() { + if (!this.room.turnEndsAt || Date.now() < this.room.turnEndsAt - 500) return; + this.broadcast({ type: "timeout", userId: this.room.turnUserId }); + await this.advanceTurn(); + } + + private async finish() { + this.broadcast({ type: "ended", room: this.room }); + // TODO: POST results to /debate/result equivalent, persist transcript, + // trigger rating update (services.RatingService). + } + + // --- helpers ---------------------------------------------------------- + private persist() { + return this.state.storage.put("room", this.room); + } + + private broadcast(data: unknown, except?: Session) { + const payload = JSON.stringify(data); + for (const s of this.sessions) { + if (s === except) continue; + try { + s.ws.send(payload); + } catch { + this.sessions.delete(s); + } + } + } +} diff --git a/cf-app/src/lib/auth.ts b/cf-app/src/lib/auth.ts new file mode 100644 index 00000000..ff4ae000 --- /dev/null +++ b/cf-app/src/lib/auth.ts @@ -0,0 +1,69 @@ +import { SignJWT, jwtVerify, errors as joseErrors } from "jose"; +import { eq } from "drizzle-orm"; +import { getDb } from "@/db/client"; +import { users, type User } from "@/db/schema"; +import { env, jwtExpiryMinutes } from "./env"; +import { unauthorized } from "./http"; + +/** + * JWT — HS256, claims `{ sub: , iat, exp }`, identical to the Go + * backend's `generateJWT`. Set JWT_SECRET to the SAME value as the Go service + * and tokens issued by either side validate on the other, so the frontend can + * be cut over route-by-route. + */ +function secret(): Uint8Array { + return new TextEncoder().encode(env().JWT_SECRET); +} + +export async function signToken(email: string): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub: email }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(now + jwtExpiryMinutes() * 60) + .sign(secret()); +} + +export type Claims = { sub: string; iat: number; exp: number }; + +export async function verifyToken(token: string): Promise { + const { payload } = await jwtVerify(token, secret(), { algorithms: ["HS256"] }); + if (typeof payload.sub !== "string" || !payload.sub) { + throw new joseErrors.JWTInvalid("missing sub claim"); + } + return payload as unknown as Claims; +} + +function bearer(req: Request): string | null { + const h = req.headers.get("authorization") ?? ""; + const [scheme, value] = h.split(" "); + return scheme === "Bearer" && value ? value : null; +} + +/** + * Equivalent of `middlewares.AuthMiddleware`: validates the bearer token and + * loads the user row. On failure it *returns* a Response (401) — call sites do + * `const auth = await requireUser(req); if (auth instanceof Response) return auth;` + */ +export async function requireUser(req: Request): Promise { + const token = bearer(req); + if (!token) return unauthorized("Authorization header is required"); + + let claims: Claims; + try { + claims = await verifyToken(token); + } catch (e) { + const msg = + e instanceof joseErrors.JWTExpired ? "Token is expired" : "Invalid token"; + return unauthorized(msg); + } + + const db = getDb(); + const [user] = await db + .select() + .from(users) + .where(eq(users.email, claims.sub)) + .limit(1); + if (!user) return unauthorized("User not found"); + return user; +} diff --git a/cf-app/src/lib/cloudflare-env.d.ts b/cf-app/src/lib/cloudflare-env.d.ts new file mode 100644 index 00000000..4167aa5d --- /dev/null +++ b/cf-app/src/lib/cloudflare-env.d.ts @@ -0,0 +1,28 @@ +/** + * Ambient types for the Cloudflare bindings. Regenerate the accurate version any + * time wrangler.toml changes with `npm run cf-typegen`. + */ +interface CloudflareEnv { + ASSETS: Fetcher; + + // D1 — replaces MongoDB + DB: D1Database; + + // KV — replaces Redis (TTL / ephemeral state) + KV: KVNamespace; + + // Durable Object — live-debate WebSocket rooms + DEBATE_ROOM: DurableObjectNamespace; + + // vars + JWT_EXPIRY_MINUTES: string; + APP_BASE_URL: string; + EMAIL_FROM: string; + EMAIL_PROVIDER: "resend" | "mailchannels" | "console"; + GOOGLE_OAUTH_CLIENT_ID: string; + + // secrets + JWT_SECRET: string; + GEMINI_API_KEY: string; + RESEND_API_KEY: string; +} diff --git a/cf-app/src/lib/email.ts b/cf-app/src/lib/email.ts new file mode 100644 index 00000000..73806b46 --- /dev/null +++ b/cf-app/src/lib/email.ts @@ -0,0 +1,69 @@ +import { env } from "./env"; + +/** + * Transactional email — replaces `utils/email.go` (net/smtp). Workers cannot + * open raw SMTP sockets, so this goes over HTTPS. Pick a provider with + * EMAIL_PROVIDER: + * - "resend" -> Resend HTTP API (needs RESEND_API_KEY) + * - "mailchannels" -> MailChannels (needs a verified domain + DNS records) + * - "console" -> log only, for local dev + */ +type Mail = { to: string; subject: string; html: string }; + +async function sendResend(m: Mail) { + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + authorization: `Bearer ${env().RESEND_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ from: env().EMAIL_FROM, ...m }), + }); + if (!res.ok) throw new Error(`Resend ${res.status}: ${await res.text()}`); +} + +async function sendMailChannels(m: Mail) { + const res = await fetch("https://api.mailchannels.net/tx/v1/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: m.to }] }], + from: parseFrom(env().EMAIL_FROM), + subject: m.subject, + content: [{ type: "text/html", value: m.html }], + }), + }); + if (!res.ok) throw new Error(`MailChannels ${res.status}: ${await res.text()}`); +} + +function parseFrom(s: string) { + const m = s.match(/^(.*?)\s*<(.+?)>$/); + return m ? { name: m[1], email: m[2] } : { email: s }; +} + +export async function sendEmail(m: Mail): Promise { + switch (env().EMAIL_PROVIDER) { + case "resend": + return sendResend(m); + case "mailchannels": + return sendMailChannels(m); + default: + console.log("[email:console]", m.to, "|", m.subject, "\n", m.html); + } +} + +export function sendVerificationEmail(to: string, code: string) { + return sendEmail({ + to, + subject: "Verify Your DebateAI Account", + html: `

Welcome to DebateAI!

Your verification code is: ${code}

This code will expire in 24 hours.

`, + }); +} + +export function sendPasswordResetEmail(to: string, code: string) { + return sendEmail({ + to, + subject: "DebateAI Password Reset", + html: `

Your password reset code is: ${code}

`, + }); +} diff --git a/cf-app/src/lib/env.ts b/cf-app/src/lib/env.ts new file mode 100644 index 00000000..c5c51e0f --- /dev/null +++ b/cf-app/src/lib/env.ts @@ -0,0 +1,21 @@ +import { getCloudflareContext } from "@opennextjs/cloudflare"; + +/** + * Typed accessor for env bindings + vars + secrets. + * Replaces `config.LoadConfig("./config/config.prod.yml")` — there is no config + * file at runtime on Workers; everything comes from wrangler.toml `[vars]`, + * `wrangler secret put`, or `.dev.vars` locally. + */ +export function env(): CloudflareEnv { + return getCloudflareContext().env as unknown as CloudflareEnv; +} + +/** Optional execution context (waitUntil, passThroughOnException). */ +export function ctx() { + return getCloudflareContext().ctx; +} + +export function jwtExpiryMinutes(): number { + const n = Number(env().JWT_EXPIRY_MINUTES); + return Number.isFinite(n) && n > 0 ? n : 1440; +} diff --git a/cf-app/src/lib/gemini.ts b/cf-app/src/lib/gemini.ts new file mode 100644 index 00000000..73bce65f --- /dev/null +++ b/cf-app/src/lib/gemini.ts @@ -0,0 +1,49 @@ +import { env } from "./env"; + +/** + * Gemini via the REST API — replaces the `google.golang.org/genai` Go SDK + * (services/gemini.go, services/ai.go, services/coach.go, ...). Plain `fetch`, + * which is all Workers supports; no SDK needed. + * + * If GEMINI_API_KEY is unset the Go backend "runs but AI features are disabled". + * Same here: callers should treat `GeminiDisabledError` as a soft failure. + */ +export class GeminiDisabledError extends Error { + constructor() { + super("Gemini API key not configured"); + } +} + +const MODEL = "gemini-2.0-flash"; +const BASE = "https://generativelanguage.googleapis.com/v1beta"; + +export async function geminiGenerate( + prompt: string, + opts: { system?: string; json?: boolean; temperature?: number } = {}, +): Promise { + const key = env().GEMINI_API_KEY; + if (!key) throw new GeminiDisabledError(); + + const res = await fetch(`${BASE}/models/${MODEL}:generateContent?key=${key}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: prompt }] }], + ...(opts.system + ? { systemInstruction: { parts: [{ text: opts.system }] } } + : {}), + generationConfig: { + temperature: opts.temperature ?? 0.7, + ...(opts.json ? { responseMimeType: "application/json" } : {}), + }, + }), + }); + + if (!res.ok) { + throw new Error(`Gemini ${res.status}: ${await res.text()}`); + } + const data = (await res.json()) as { + candidates?: { content?: { parts?: { text?: string }[] } }[]; + }; + return data.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; +} diff --git a/cf-app/src/lib/google.ts b/cf-app/src/lib/google.ts new file mode 100644 index 00000000..9f87a14c --- /dev/null +++ b/cf-app/src/lib/google.ts @@ -0,0 +1,27 @@ +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { env } from "./env"; + +/** + * Verify a Google ID token — replaces `google.golang.org/api/idtoken.Validate`. + * Checks signature against Google's JWKS, issuer, audience (our OAuth client id) + * and expiry. + */ +const JWKS = createRemoteJWKSet( + new URL("https://www.googleapis.com/oauth2/v3/certs"), +); + +export type GooglePayload = { + email?: string; + email_verified?: boolean; + name?: string; + picture?: string; + sub: string; +}; + +export async function verifyGoogleIdToken(idToken: string): Promise { + const { payload } = await jwtVerify(idToken, JWKS, { + issuer: ["https://accounts.google.com", "accounts.google.com"], + audience: env().GOOGLE_OAUTH_CLIENT_ID, + }); + return payload as GooglePayload; +} diff --git a/cf-app/src/lib/http.ts b/cf-app/src/lib/http.ts new file mode 100644 index 00000000..dbab7a35 --- /dev/null +++ b/cf-app/src/lib/http.ts @@ -0,0 +1,28 @@ +/** Small helpers mirroring the Go handlers' `c.JSON(status, gin.H{...})`. */ + +export function json(body: unknown, status = 200, headers?: HeadersInit) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +export const ok = (body: unknown) => json(body, 200); +export const created = (body: unknown) => json(body, 201); +export const badRequest = (error: string, extra?: object) => + json({ error, ...extra }, 400); +export const unauthorized = (error = "Unauthorized") => json({ error }, 401); +export const forbidden = (error = "forbidden") => json({ error }, 403); +export const notFound = (error = "Not found") => json({ error }, 404); +export const conflict = (error: string) => json({ error }, 409); +export const serverError = (error = "Internal server error", extra?: object) => + json({ error, ...extra }, 500); + +/** Parse a JSON body, returning `null` on malformed input (like ShouldBindJSON). */ +export async function readJson(req: Request): Promise { + try { + return (await req.json()) as T; + } catch { + return null; + } +} diff --git a/cf-app/src/lib/ids.ts b/cf-app/src/lib/ids.ts new file mode 100644 index 00000000..cdb52d5c --- /dev/null +++ b/cf-app/src/lib/ids.ts @@ -0,0 +1,33 @@ +/** + * ID generation compatible with exported Mongo ObjectIDs. + * + * An ObjectID is 12 bytes -> 24 lowercase hex chars: 4-byte timestamp, + * 5-byte random, 3-byte counter. We reproduce the shape so old IDs and new IDs + * are indistinguishable and sortable-by-creation, without pulling in the + * mongodb driver. + */ +let counter = Math.floor(Math.random() * 0xffffff); +const machine = crypto.getRandomValues(new Uint8Array(5)); + +export function newId(): string { + const now = Math.floor(Date.now() / 1000); + counter = (counter + 1) % 0xffffff; + + const bytes = new Uint8Array(12); + bytes[0] = (now >> 24) & 0xff; + bytes[1] = (now >> 16) & 0xff; + bytes[2] = (now >> 8) & 0xff; + bytes[3] = now & 0xff; + bytes.set(machine, 4); + bytes[9] = (counter >> 16) & 0xff; + bytes[10] = (counter >> 8) & 0xff; + bytes[11] = counter & 0xff; + + return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** 6-digit numeric code — replaces utils.GenerateRandomCode(6). */ +export function numericCode(length = 6): string { + const d = crypto.getRandomValues(new Uint8Array(length)); + return [...d].map((n) => (n % 10).toString()).join(""); +} diff --git a/cf-app/src/lib/kv.ts b/cf-app/src/lib/kv.ts new file mode 100644 index 00000000..4dd0d5e0 --- /dev/null +++ b/cf-app/src/lib/kv.ts @@ -0,0 +1,111 @@ +import { env } from "./env"; + +/** + * KV replaces Redis for all of the Go backend's *ephemeral / TTL* state: + * + * Go (internal/debate + services/matchmaking) ─────► KV key shape + * ───────────────────────────────────────────── ───────────────────────── + * matchmaking pool map mm:pool: (TTL) + * rate:question:: rl:q:: (TTL) + * rate:reaction:: rl:r:: (TTL) + * debate::poll::counts poll:::counts + * debate::poll::voters (SET) poll:::v: + * + * Caveats vs Redis: + * - KV is eventually consistent and has no atomic INCR. Rate limits are + * therefore best-effort (fine for abuse mitigation, not for billing). + * - For anything that must be strongly consistent within a live debate + * (authoritative vote tallies, turn order), use the DebateRoom Durable + * Object instead — it has transactional per-object storage. + */ +function kv(): KVNamespace { + return env().KV; +} + +// ─── Matchmaking pool ─────────────────────────────────────────────────────── +export type PoolEntry = { + userId: string; + username: string; + elo: number; + minElo: number; + maxElo: number; + joinedAt: number; // epoch ms + lastActivity: number; + startedMatchmaking: boolean; +}; + +const POOL_PREFIX = "mm:pool:"; +const POOL_TTL = 120; // seconds; refreshed on every heartbeat + +export const matchmaking = { + async upsert(entry: PoolEntry) { + await kv().put(POOL_PREFIX + entry.userId, JSON.stringify(entry), { + expirationTtl: POOL_TTL, + }); + }, + async remove(userId: string) { + await kv().delete(POOL_PREFIX + userId); + }, + async get(userId: string): Promise { + return kv().get(POOL_PREFIX + userId, "json"); + }, + async list(): Promise { + const out: PoolEntry[] = []; + let cursor: string | undefined; + do { + const page = await kv().list({ prefix: POOL_PREFIX, cursor }); + for (const k of page.keys) { + const v = await kv().get(k.name, "json"); + if (v) out.push(v); + } + cursor = page.list_complete ? undefined : page.cursor; + } while (cursor); + return out; + }, +}; + +// ─── Rate limiting (best-effort) ─────────────────────────────────────────── +async function bumpCounter(key: string, max: number, windowSec: number) { + const current = Number((await kv().get(key)) ?? 0); + if (current >= max) return false; + await kv().put(key, String(current + 1), { + expirationTtl: current === 0 ? windowSec : undefined, + }); + return true; +} + +export const rateLimit = { + question: (debateID: string, hash: string, max = 1, windowSec = 15) => + bumpCounter(`rl:q:${debateID}:${hash}`, max, windowSec), + reaction: (debateID: string, hash: string, max = 5, windowSec = 10) => + bumpCounter(`rl:r:${debateID}:${hash}`, max, windowSec), +}; + +// ─── Live-debate polls (snapshot cache; authority = DebateRoom DO) ───────── +export type PollSnapshot = { + pollId: string; + question: string; + options: string[]; + counts: Record; +}; + +export const polls = { + key: (debateID: string, pollID: string) => `poll:${debateID}:${pollID}:counts`, + voterKey: (debateID: string, pollID: string, hash: string) => + `poll:${debateID}:${pollID}:v:${hash}`, + + async snapshot(debateID: string, pollID: string): Promise { + return kv().get(polls.key(debateID, pollID), "json"); + }, + async putSnapshot(debateID: string, s: PollSnapshot) { + await kv().put(polls.key(debateID, s.pollId), JSON.stringify(s)); + }, + async hasVoted(debateID: string, pollID: string, hash: string) { + return (await kv().get(polls.voterKey(debateID, pollID, hash))) !== null; + }, + async markVoted(debateID: string, pollID: string, hash: string) { + await kv().put(polls.voterKey(debateID, pollID, hash), "1", { + expirationTtl: 60 * 60 * 6, + }); + }, +}; diff --git a/cf-app/src/lib/password.ts b/cf-app/src/lib/password.ts new file mode 100644 index 00000000..a6870b17 --- /dev/null +++ b/cf-app/src/lib/password.ts @@ -0,0 +1,20 @@ +import bcrypt from "bcryptjs"; + +/** + * Password hashing — kept on bcrypt so every hash exported from the Mongo + * `users.password` field verifies unchanged. bcryptjs is pure-JS and runs on + * Workers; cost 10 (bcrypt.DefaultCost, matching golang.org/x/crypto/bcrypt). + * + * Note: hashing ~cost 10 is a few hundred ms of CPU on the isolate. That's fine + * for signup/login volume. If it ever matters, migrate opportunistically to + * WebCrypto PBKDF2/scrypt on next successful login. + */ +const COST = 10; + +export function hashPassword(plain: string): Promise { + return bcrypt.hash(plain, COST); +} + +export function verifyPassword(plain: string, hash: string): Promise { + return bcrypt.compare(plain, hash); +} diff --git a/cf-app/src/lib/users.ts b/cf-app/src/lib/users.ts new file mode 100644 index 00000000..0f146b38 --- /dev/null +++ b/cf-app/src/lib/users.ts @@ -0,0 +1,48 @@ +import type { User } from "@/db/schema"; + +/** extractNameFromEmail — username before '@'. */ +export function nameFromEmail(email: string): string { + const i = email.indexOf("@"); + return i > 0 ? email.slice(0, i) : email; +} + +function sanitizeFloat(v: number | null | undefined, fallback: number): number { + return typeof v === "number" && Number.isFinite(v) ? v : fallback; +} + +/** normalizeUserStats — returns patched fields when rating data is missing/NaN. */ +export function normalizeUserStats(u: User): Partial | null { + const patch: Partial = {}; + if (!Number.isFinite(u.rating)) patch.rating = 1200; + if (!Number.isFinite(u.rd)) patch.rd = 350; + if (!Number.isFinite(u.volatility) || u.volatility <= 0) patch.volatility = 0.06; + if (!u.lastRatingUpdate) patch.lastRatingUpdate = new Date().toISOString(); + if (Object.keys(patch).length === 0) return null; + patch.updatedAt = new Date().toISOString(); + return patch; +} + +/** buildUserResponse — the shape the frontend already expects from the Go API. */ +export function userResponse(u: User) { + return { + id: u.id, + email: u.email, + displayName: u.displayName, + nickname: u.nickname, + bio: u.bio, + rating: sanitizeFloat(u.rating, 1200), + rd: sanitizeFloat(u.rd, 350), + volatility: sanitizeFloat(u.volatility, 0.06), + lastRatingUpdate: u.lastRatingUpdate ?? "", + avatarUrl: u.avatarUrl, + twitter: u.twitter, + instagram: u.instagram, + linkedin: u.linkedin, + isVerified: u.isVerified, + createdAt: u.createdAt ?? "", + updatedAt: u.updatedAt ?? "", + }; +} + +export const DEFAULT_AVATAR = (seed: string) => + `https://api.dicebear.com/9.x/adventurer/svg?seed=${encodeURIComponent(seed)}`; diff --git a/cf-app/src/worker/index.ts b/cf-app/src/worker/index.ts new file mode 100644 index 00000000..4806df18 --- /dev/null +++ b/cf-app/src/worker/index.ts @@ -0,0 +1,60 @@ +/** + * Custom Worker entry that wraps the OpenNext handler so we can add the two + * things Next.js route handlers can't express on Cloudflare: + * + * 1. WebSocket upgrades -> routed to the DebateRoom Durable Object + * (GET /ws/debate/:debateID, /ws/matchmaking is HTTP-polled instead) + * 2. Cron `scheduled()` -> the matchmaking sweep + stale-pool GC that used + * to be Go background goroutines (services.periodicMatchmaking / + * cleanupInactiveUsers, websocket.WatchForNewRooms) + * + * Build order (see package.json `deploy`): + * opennextjs-cloudflare build # emits .open-next/worker.js + * wrangler deploy # bundles THIS file, which imports it + */ +// @ts-expect-error - generated at build time by `opennextjs-cloudflare build` +import openNext from "../../.open-next/worker.js"; +import { verifyToken } from "@/lib/auth"; +import { runMatchmakingSweep } from "./matchmaking-sweep"; + +export { DebateRoom } from "@/durable-objects/DebateRoom"; + +export default { + async fetch(request: Request, env: CloudflareEnv, ctx: ExecutionContext) { + const url = new URL(request.url); + + // wss:///ws/debate/?token= + const m = url.pathname.match(/^\/ws\/debate\/([^/]+)\/?$/); + if (m) { + if (request.headers.get("Upgrade") !== "websocket") { + return new Response("expected websocket", { status: 426 }); + } + const token = + url.searchParams.get("token") ?? + request.headers.get("sec-websocket-protocol") ?? + ""; + let sub: string; + try { + ({ sub } = await verifyToken(token)); + } catch { + return new Response("unauthorized", { status: 401 }); + } + + const debateID = m[1]; + const id = env.DEBATE_ROOM.idFromName(debateID); + const stub = env.DEBATE_ROOM.get(id); + + // forward to the DO with the authenticated user id attached + const fwd = new URL(request.url); + fwd.searchParams.set("uid", sub); + return stub.fetch(new Request(fwd, request)); + } + + // everything else -> Next.js (OpenNext) + return openNext.fetch(request, env, ctx); + }, + + async scheduled(_event: ScheduledEvent, env: CloudflareEnv, ctx: ExecutionContext) { + ctx.waitUntil(runMatchmakingSweep(env)); + }, +}; diff --git a/cf-app/src/worker/matchmaking-sweep.ts b/cf-app/src/worker/matchmaking-sweep.ts new file mode 100644 index 00000000..6eb6ef99 --- /dev/null +++ b/cf-app/src/worker/matchmaking-sweep.ts @@ -0,0 +1,49 @@ +import { matchmaking, type PoolEntry } from "@/lib/kv"; +import { newId } from "@/lib/ids"; + +/** + * Runs once a minute (wrangler.toml `[triggers] crons`). Replaces the Go + * `MatchmakingService.periodicMatchmaking` goroutine: + * - pair users whose Elo windows overlap + * - for each pair, create a DebateRoom DO and stamp `matchRoomId` on both + * KV entries so their next /matchmaking/heartbeat returns the room + * - stale entries expire on their own via the KV TTL (was cleanupInactiveUsers) + */ +export async function runMatchmakingSweep(env: CloudflareEnv): Promise { + const pool = (await matchmaking.list()) + .filter((e) => e.startedMatchmaking && !(e as Stamped).matchRoomId) + .sort((a, b) => a.joinedAt - b.joinedAt); + + const used = new Set(); + + for (let i = 0; i < pool.length; i++) { + const a = pool[i]; + if (used.has(a.userId)) continue; + + for (let j = i + 1; j < pool.length; j++) { + const b = pool[j]; + if (used.has(b.userId)) continue; + if (a.elo > b.maxElo || a.elo < b.minElo) continue; + if (b.elo > a.maxElo || b.elo < a.minElo) continue; + + const roomId = newId(); + const doId = env.DEBATE_ROOM.idFromName(roomId); + await env.DEBATE_ROOM.get(doId).fetch("https://do/init", { + method: "POST", + body: JSON.stringify({ debaters: [a.userId, b.userId], format: "standard" }), + }); + + await stamp(a, roomId); + await stamp(b, roomId); + used.add(a.userId); + used.add(b.userId); + break; + } + } +} + +type Stamped = PoolEntry & { matchRoomId?: string }; + +async function stamp(entry: PoolEntry, roomId: string) { + await matchmaking.upsert({ ...(entry as Stamped), matchRoomId: roomId } as PoolEntry); +} diff --git a/cf-app/tsconfig.json b/cf-app/tsconfig.json new file mode 100644 index 00000000..078adfe8 --- /dev/null +++ b/cf-app/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "preserve", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "types": ["@cloudflare/workers-types", "node"], + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src", "next-env.d.ts", ".next/types/**/*.ts", "*.ts", "*.mjs"], + "exclude": ["node_modules", ".open-next"] +} diff --git a/cf-app/wrangler.toml b/cf-app/wrangler.toml new file mode 100644 index 00000000..37b35dea --- /dev/null +++ b/cf-app/wrangler.toml @@ -0,0 +1,54 @@ +# Cloudflare Workers config for the DebateAI Next.js app (deployed via @opennextjs/cloudflare). +name = "debateai" +# Custom entry that wraps the OpenNext handler (WebSocket routing + cron). +# `opennextjs-cloudflare build` still generates .open-next/worker.js, which this imports. +main = "src/worker/index.ts" +compatibility_date = "2024-12-30" +compatibility_flags = ["nodejs_compat"] + +# OpenNext emits static assets here. +[assets] +directory = ".open-next/assets" +binding = "ASSETS" + +# --- D1: replaces MongoDB ------------------------------------------------------- +[[d1_databases]] +binding = "DB" +database_name = "debateai" +database_id = "REPLACE_WITH_D1_DATABASE_ID" # `wrangler d1 create debateai` +migrations_dir = "migrations" + +# --- KV: replaces Redis (ephemeral / TTL state) ------------------------------- +# matchmaking pool entries, rate-limit counters, verification + reset codes, +# live-debate poll snapshots, spectator vote sets. +[[kv_namespaces]] +binding = "KV" +id = "REPLACE_WITH_KV_NAMESPACE_ID" # `wrangler kv namespace create KV` + +# --- Durable Objects: the only piece Workers cannot do without -------------- +# Stateful WebSocket coordination + turn timers for live debates. +[[durable_objects.bindings]] +name = "DEBATE_ROOM" +class_name = "DebateRoom" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["DebateRoom"] + +# --- Cron: replaces the Go background goroutines ------------------------------ +# periodic matchmaking sweep + stale-pool cleanup (was services.periodicMatchmaking). +[triggers] +crons = ["* * * * *"] + +# --- Vars (non-secret). Secrets go in `wrangler secret put` / .dev.vars ------ +[vars] +JWT_EXPIRY_MINUTES = "1440" +APP_BASE_URL = "http://localhost:3000" +EMAIL_FROM = "DebateAI " +EMAIL_PROVIDER = "resend" # resend | mailchannels | console +GOOGLE_OAUTH_CLIENT_ID = "" # public; also fine to keep as a secret + +# Secrets (set with `wrangler secret put `), do NOT put values here: +# JWT_SECRET - HS256 signing key; use the SAME value as the Go backend to keep tokens interoperable +# GEMINI_API_KEY - Google Generative Language API key +# RESEND_API_KEY - only when EMAIL_PROVIDER=resend diff --git a/docs/REPOSITORY_GUIDE.md b/docs/REPOSITORY_GUIDE.md new file mode 100644 index 00000000..b99edc3a --- /dev/null +++ b/docs/REPOSITORY_GUIDE.md @@ -0,0 +1,356 @@ +# DebateAI Repository Guide + +This document is a map of the repository as it exists today. It explains where the major pieces live, how requests move through the system, and which files are the best starting points for common changes. + +## 1. What the project is + +DebateAI is a real-time debate platform with: + +- human-versus-human debates; +- human-versus-AI debates; +- text, speech, and browser media features; +- team debates and team matchmaking; +- saved transcripts, judging, ratings, leaderboards, and gamification; +- community posts, comments, likes, follows, notifications, and coaching tools; +- an administrator dashboard with analytics and moderation controls. + +The repository has two application processes: + +```text +Browser (React/Vite) + | + | HTTP JSON + WebSocket + v +Go/Gin backend ---- MongoDB (persistent application data) + | + +---- Redis (selected realtime/event features) + +---- Gemini/AI providers (bot judging and coaching) + +---- SMTP (verification and password-reset email) +``` + +The backend module is named `arguehub`, although the repository and product are named DebateAI. + +## 2. Top-level layout + +| Path | Purpose | +| --- | --- | +| `backend/` | Go API server, domain logic, persistence, authentication, WebSockets, and tests. | +| `frontend/` | React 18 single-page application built with Vite and TypeScript. | +| `docker-compose.yml` | Local multi-service environment for backend, frontend, MongoDB, and Redis. | +| `README.md` | Existing quick-start and contribution notes. | +| `backend/Dockerfile.dev` | Development image for the Go service. | +| `frontend/Dockerfile.dev` | Development image for the Vite service. | + +The root also contains `backend/main` and `backend/server`, which are checked-in compiled artifacts or launch artifacts. Source changes should normally be made under `backend/cmd/`, not in those files. + +## 3. Backend architecture + +### Architectural layers + +The backend is a Gin application organized by responsibility, but it is not a strict clean-architecture or repository pattern implementation: + +```text +HTTP/WebSocket transport + routes/ -> controllers/ or websocket/ + | + v + services/ and internal/debate/ + | + +-----------+-----------+ + v v + models/ + db/ AI, email, Redis + | + v + MongoDB +``` + +- **Transport layer:** `routes/` declares URLs and delegates; `websocket/` upgrades connections and handles message loops. +- **Request layer:** `controllers/` validates Gin input, reads authentication context, calls domain operations, and formats JSON responses. +- **Domain layer:** `services/` implements matchmaking, judging, rating, AI, teams, notifications, transcripts, and gamification behavior. +- **Infrastructure layer:** `db/`, `internal/debate/`, configuration, email utilities, and external AI clients connect the application to stateful or third-party systems. +- **Data contracts:** `models/` represents persisted documents; `structs/` represents request and transport payloads. + +The boundaries are practical rather than absolute. Controllers and services both perform MongoDB queries, and some service files contain standalone demos or compatibility helpers. Follow the existing owning file when making a narrow change, then check the corresponding model and frontend type. + +### HTTP request lifecycle + +For an authenticated JSON request, the normal path is: + +```text +Browser fetch() + -> Gin route group + -> AuthMiddleware + -> JWT validation + -> users collection lookup + -> user fields placed in gin.Context + -> route adapter + -> controller + -> service/domain logic + -> MongoDB/Redis/AI/email + -> JSON response +``` + +Public authentication endpoints skip `AuthMiddleware`. Admin endpoints use the separate admin authentication and Casbin authorization chain described below. + +### Startup and dependency initialization + +The main runtime entry point is [backend/cmd/server/main.go](../backend/cmd/server/main.go). Startup does the following: + +1. Loads `./config/config.prod.yml`. +2. Initializes the AI debate, coaching, and rating services. +3. Connects to MongoDB and ensures the unique display-name index. +4. Initializes Casbin RBAC using MongoDB-backed policies. +5. Attempts Redis initialization for Redis-backed realtime features. +6. Starts the background room watcher. +7. Sets the process-wide JWT secret, seeds debate data/test users, creates `uploads/`, and starts Gin. + +Route registration is in the same file. This is the best place to see the complete public, authenticated, admin, and WebSocket surface. + +### Configuration + +- [backend/config/config.go](../backend/config/config.go) defines the YAML configuration shape and environment-variable overrides. +- [backend/config/config.prod.sample.yml](../backend/config/config.prod.sample.yml) documents the expected production-style values. +- `DATABASE_URI`, `REDIS_ADDR`, `GEMINI_API_KEY`, `JWT_SECRET`, `GOOGLE_CLIENT_ID`, and `PORT` can override selected YAML values. +- The real `config.prod.yml` is expected locally and should not be committed. + +Configuration covers the HTTP port, MongoDB, Redis, Gemini/OpenAI-related values, JWT, SMTP, Google OAuth, and legacy Cognito fields. + +### Persistence and external services + +[backend/db/db.go](../backend/db/db.go) owns process-wide MongoDB and Redis clients. It provides MongoDB connection/index setup and a small set of debate-versus-bot persistence helpers. Most controllers and services use `db.MongoDatabase` directly and select their collections by name. + +Important MongoDB collections used across the code include: + +- `users`, `admins`; +- `debates`, `debates_vs_bot`; +- `debate_transcripts`, `debate_results`, `saved_debate_transcripts`; +- `teams`, `team_debates`; +- community collections for posts/comments/likes/follows; +- notifications, gamification records, and Casbin policy data. + +There is no single repository/DAO layer: persistence is intentionally close to controllers and services. When changing a data contract, inspect both the relevant model and every controller/service that queries its collection. + +Two Redis access patterns exist. `db/db.go` exposes a general `RedisClient`, while `backend/internal/debate/redis_client.go` owns a package-local Redis client used by the internal debate event infrastructure. This is important when debugging Redis behavior: initialization and consumers may not all use the same client variable. + +### Authentication and authorization + +- [backend/controllers/auth.go](../backend/controllers/auth.go) implements signup, email verification, login, Google login, and password recovery. +- [backend/utils/auth.go](../backend/utils/auth.go) provides password hashing, JWT creation/parsing, token helpers, and email-name extraction. +- [backend/middlewares/auth.go](../backend/middlewares/auth.go) validates `Authorization: Bearer `, loads the user by the JWT subject email, and places user details in Gin context. +- [backend/middlewares/rbac.go](../backend/middlewares/rbac.go) authenticates administrators and enforces Casbin resource/action policies. +- [backend/rbac_model.conf](../backend/rbac_model.conf) defines the Casbin model used by admin authorization. +- [backend/models/admin.go](../backend/models/admin.go) and [backend/models/user.go](../backend/models/user.go) define the persisted identity records. + +Normal user routes use the shared auth middleware. Admin routes use admin JWT validation plus role-based checks. The frontend stores the normal user token in local storage through [frontend/src/utils/auth.ts](../frontend/src/utils/auth.ts). + +The JWT subject is normally the user's email. The normal middleware therefore performs both cryptographic validation and a live user lookup on every protected request; a valid token alone is not sufficient if the user record cannot be found. WebSocket handlers independently extract and validate tokens because a WebSocket upgrade does not pass through every HTTP route-group middleware in the same way. + +## 4. Backend feature map + +### HTTP routing + +The `backend/routes/` package is a thin adapter from Gin routes to controller functions. The route files are the quickest API index: + +| File | Registered capability | +| --- | --- | +| [backend/routes/auth.go](../backend/routes/auth.go) | Signup, verification, login, Google login, password recovery, token verification, and debug matchmaking status. | +| [backend/routes/profile.go](../backend/routes/profile.go) | Profile fetch/update and display-name checks. | +| [backend/routes/leaderboard.go](../backend/routes/leaderboard.go) | Rating leaderboard. | +| [backend/routes/debate.go](../backend/routes/debate.go) | Human debate and room operations. | +| [backend/routes/debatevsbot.go](../backend/routes/debatevsbot.go) | AI debate creation, interaction, and result operations. | +| [backend/routes/rooms.go](../backend/routes/rooms.go) | Browsing, creating, joining, and inspecting custom rooms. | +| [backend/routes/transcriptroutes.go](../backend/routes/transcriptroutes.go) | Transcript submission, saved transcript CRUD, stats, and test transcript endpoints. | +| [backend/routes/team.go](../backend/routes/team.go) | Teams, team debates, team chat, and team matchmaking. | +| [backend/routes/community.go](../backend/routes/community.go) | Posts, comments, likes, and follow relationships. | +| [backend/routes/gamification.go](../backend/routes/gamification.go) | Badge and score updates plus the gamification leaderboard. | +| [backend/routes/coach.go](../backend/routes/coach.go) | Argument-strengthening and coaching endpoints. | +| [backend/routes/notification.go](../backend/routes/notification.go) | Notification listing, read state, and deletion. | +| [backend/routes/admin.go](../backend/routes/admin.go) | Admin login, analytics, moderation, and admin management endpoints. | + +The main server also registers WebSockets at `/ws`, `/ws/team`, `/ws/debate/:debateID`, `/ws/matchmaking`, and `/ws/gamification`. + +### Controllers and models + +Controllers translate HTTP input into application operations and JSON responses. They are grouped by domain in [backend/controllers/](../backend/controllers/): auth, profiles, debates, AI debates, teams, matchmaking, transcripts, community, gamification, notifications, leaderboard, analytics, and admin operations. + +The [backend/models/](../backend/models/) package is the persistence and API-domain vocabulary. Key records are: + +- `User`, `Admin`, and notification records for identity and platform activity; +- `Debate` and `DebateVsBot` for completed rating history and AI matches; +- `DebateTranscript`/`SavedDebateTranscript` and `DebateResult` for judging and replay; +- `Team` and `TeamDebate` for team membership and team matches; +- post/comment/gamification/coach models for the community and training features. + +Request/transport-only structs live in [backend/structs/](../backend/structs/), including authentication and WebSocket payload shapes. + +When tracing a feature, use this order: route registration -> controller handler -> service function -> model/collection access -> frontend service/page. This follows the direction of data flow and avoids treating a route adapter as the place where behavior is decided. + +### Debate and AI services + +- [backend/services/debatevsbot.go](../backend/services/debatevsbot.go) coordinates bot-debate state and persistence. +- [backend/services/ai.go](../backend/services/ai.go) contains AI-facing debate helpers. +- [backend/services/gemini.go](../backend/services/gemini.go) integrates Gemini generation. +- [backend/services/personalities.go](../backend/services/personalities.go) defines bot/personality choices. +- [backend/services/coach.go](../backend/services/coach.go) powers coaching interactions. +- [backend/services/pros_cons.go](../backend/services/pros_cons.go) supports the pros/cons exercise. + +### Judging, transcripts, and ratings + +[backend/services/transcriptservice.go](../backend/services/transcriptservice.go) accepts each side's transcript, waits until both sides are present, merges the transcript, judges it, stores the result, saves per-user transcript records, and updates ratings. It also prevents duplicate recent saves and returns a waiting response when only one side has submitted. + +[backend/services/rating_service.go](../backend/services/rating_service.go) wraps the local Glicko-2 implementation in [backend/rating/glicko2.go](../backend/rating/glicko2.go). It updates both users, records pre/post rating values and changes, sanitizes invalid metrics, and sends rating notifications. + +### Matchmaking and realtime state + +- [backend/services/matchmaking.go](../backend/services/matchmaking.go) manages one-on-one matchmaking pools and rating tolerance. +- [backend/services/team_matchmaking.go](../backend/services/team_matchmaking.go) matches teams. +- [backend/services/team_turn_service.go](../backend/services/team_turn_service.go) tracks team debate turns and speaking permissions. +- [backend/websocket/websocket.go](../backend/websocket/websocket.go) manages ordinary debate rooms, participants, typing/speaking state, turns, transcripts, and room broadcasts. +- [backend/websocket/handler.go](../backend/websocket/handler.go) and [backend/websocket/debate_spectator.go](../backend/websocket/debate_spectator.go) handle general room/debate spectator behavior. +- [backend/websocket/matchmaking.go](../backend/websocket/matchmaking.go) handles realtime matchmaking notifications. +- [backend/websocket/team_websocket.go](../backend/websocket/team_websocket.go) and [backend/websocket/team_debate_handler.go](../backend/websocket/team_debate_handler.go) handle team debate connections and messages. +- [backend/websocket/gamification.go](../backend/websocket/gamification.go) and [backend/websocket/gamification_handler.go](../backend/websocket/gamification_handler.go) publish gamification events. + +The `backend/internal/debate/` package contains Redis-backed infrastructure: event definitions, polling state, rate limiting, Redis setup, and stream consumption. It is an internal implementation detail of the backend rather than a public API. + +Realtime behavior has two distinct forms: + +1. **Room WebSockets** keep connection and debate state in process memory. Room maps are protected by mutexes, and each client has a write mutex so concurrent broadcasts do not interleave frames. +2. **Redis-backed events** support shared or asynchronous debate infrastructure such as rate limiting, polling, and stream consumption. Redis is optional at startup, so features depending on it may be unavailable while ordinary HTTP and MongoDB-backed behavior continues. + +Team rooms add a second in-memory room model with team membership, readiness maps, a turn manager, and token buckets. Before a team connection is upgraded, the handler validates the token, loads the debate, and confirms that the user belongs to one of its teams. + +## 5. Frontend architecture + +### Bootstrapping and providers + +- [frontend/src/main.tsx](../frontend/src/main.tsx) mounts React under `BrowserRouter`, `StrictMode`, and the application stylesheet. +- [frontend/src/App.tsx](../frontend/src/App.tsx) defines all routes and wraps them with `AuthProvider` and `ThemeProvider`. +- [frontend/src/context/authContext.tsx](../frontend/src/context/authContext.tsx) owns client authentication state and token/user lifecycle. +- [frontend/src/context/theme-provider.tsx](../frontend/src/context/theme-provider.tsx) owns theme selection. +- [frontend/src/index.css](../frontend/src/index.css) and [frontend/src/App.css](../frontend/src/App.css) contain global and application styling. + +`ProtectedRoute` in `App.tsx` redirects unauthenticated users to `/`. Public pages include home, authentication, legal pages, and admin login. Authenticated pages are arranged under `Layout` and include debate, profile, community, team, coaching, tournament, leaderboard, and support workflows. + +### Pages and user workflows + +The [frontend/src/Pages/](../frontend/src/Pages/) directory contains route-level screens: + +| Area | Main files | +| --- | --- | +| Entry/auth | `Home.tsx`, `Authentication.tsx`, `Authentication/forms.tsx` | +| Debate selection/play | `StartDebate.tsx`, `BotSelection.tsx`, `Game.tsx`, `DebateRoom.tsx`, `OnlineDebateRoom.tsx`, `ViewDebate.tsx` | +| Team play | `TeamBuilder.tsx`, `TeamDebateRoom.tsx` | +| Progress | `Profile.tsx`, `Leaderboard.tsx`, `MatchLogs.tsx` | +| Coaching | `CoachPage.tsx`, `StrengthenArgument.tsx`, `ProsConsChallenge.tsx` | +| Community | `CommunityFeed.tsx` | +| Tournaments | `TournamentHub.tsx`, `TournamentDetails.tsx`, `TournamentBracketPage.tsx` | +| Platform/admin | `Admin/AdminSignup.tsx`, `Admin/AdminDashboard.tsx`, `SupportOpenSource.tsx` | +| Legal/accessibility testing | `About.tsx`, `PrivacyPolicy.tsx`, `TermsOfService.tsx`, `SpeechTest.tsx` | + +### Components, state, and integrations + +- [frontend/src/components/](../frontend/src/components/) contains shared layout, navigation, debate controls, rooms, matchmaking, transcripts, profile UI, community UI, team chat, and reusable UI primitives. +- [frontend/src/components/ui/](../frontend/src/components/ui/) contains Radix/Tailwind-style primitives such as buttons, dialogs, forms, tabs, tables, progress, charts, and toasts. +- [frontend/src/services/](../frontend/src/services/) contains fetch-based API clients for auth-adjacent profile work, leaderboards, admin, notifications, teams, transcripts, gamification, and versus-bot debates. +- [frontend/src/hooks/useDebateWS.ts](../frontend/src/hooks/useDebateWS.ts) is the main client hook for debate WebSocket behavior; `useUser.ts` and toast hooks provide shared client behavior. +- [frontend/src/atoms/debateAtoms.ts](../frontend/src/atoms/debateAtoms.ts), [frontend/src/state/userAtom.ts](../frontend/src/state/userAtom.ts), and [frontend/src/state/commentsAtom.ts](../frontend/src/state/commentsAtom.ts) hold Jotai state for debates, users, and comments. +- [frontend/src/types/](../frontend/src/types/) contains user, Google, and browser speech-recognition types. +- [frontend/src/utils/speechTest.ts](../frontend/src/utils/speechTest.ts) checks browser speech-recognition and microphone support. +- [frontend/src/assets/](../frontend/src/assets/) and `frontend/public/images/` hold local visual assets. + +The client uses `VITE_BASE_URL` for the backend origin, stores the user JWT under the `token` local-storage key, and constructs WebSocket URLs from the configured HTTP origin where needed. + +## 6. Important end-to-end flows + +### Login and protected API call + +1. The authentication page sends credentials or a Google ID token. +2. The auth controller validates input, persists/loads the user, and returns a JWT. +3. The frontend stores the JWT with `setAuthToken`. +4. API services send it as a Bearer token. +5. `AuthMiddleware` validates the token and loads the current user into Gin context. + +### Human debate + +1. The user chooses or joins a room through the debate/room endpoints. +2. The debate page opens `/ws` or the debate-specific WebSocket. +3. The WebSocket room tracks clients, roles, readiness, turn state, text, typing, speech, and spectator state. +4. Each side submits transcripts through the transcript endpoints or room flow. +5. `SubmitTranscripts` waits for both roles, judges the merged transcript, saves results, and updates Glicko-2 ratings. + +### AI debate + +The bot-debate page calls the `/vsbot` route group. The bot service selects/configures the AI personality, sends prompts through the AI/Gemini integration, tracks the match, and persists the resulting debate record. Transcript and rating views reuse the common saved-transcript and leaderboard features where applicable. + +### Team debate + +Teams are created and managed through `/teams`. Team matchmaking uses `/matchmaking`, team debates use `/team-debates`, and the browser connects to `/ws/team`. The backend verifies team membership before creating the room, then coordinates team readiness, roles, turns, token buckets, chat, and speech/media signaling. + +### Community and gamification + +Community screens use the posts/comments/likes/follows service endpoints. Debate outcomes and user actions feed gamification score/badge operations and WebSocket notifications. Leaderboard and notification pages read those resulting records through their service clients. + +## 7. Development and verification + +### Local processes + +The documented manual setup is: + +```bash +cd backend +cp config/config.prod.sample.yml config/config.prod.yml +go run cmd/server/main.go +``` + +In another terminal: + +```bash +cd frontend +npm install +npm run dev +``` + +The default local frontend URL is `http://localhost:5173`; the backend defaults to port `1313` when configured that way. + +### Docker Compose + +[docker-compose.yml](../docker-compose.yml) starts: + +- backend on port `1313`; +- frontend on port `5173`; +- MongoDB on `27017` with a named data volume; +- Redis on `6379` with a named data volume and health check. + +Compose supplies the backend with `DATABASE_URI=mongodb://mongo:27017/debateai`, `REDIS_ADDR=redis:6379`, and `CONFIG_PATH=./config/config.prod.yml`. Secrets still come from the backend/frontend `.env` files. + +### Tests and checks + +- Go tests: `cd backend && go test ./...` +- Frontend typecheck/build: `cd frontend && npm run build` +- Frontend lint: `cd frontend && npm run lint` +- Backend matchmaking coverage is in [backend/services/matchmaking_test.go](../backend/services/matchmaking_test.go). +- Backend WebSocket coverage is in [backend/websocket/websocket_test.go](../backend/websocket/websocket_test.go). +- [backend/cmd/test_judge/main.go](../backend/cmd/test_judge/main.go) and [backend/test_server.go](../backend/test_server.go) are executable debugging/test harnesses, not the production server. + +## 8. Where to start a change + +| Change | Start here | +| --- | --- | +| Add an HTTP endpoint | Add a controller in `backend/controllers/`, register it in `backend/routes/`, then add/update a frontend service. | +| Change authentication | `backend/controllers/auth.go`, `backend/middlewares/auth.go`, `backend/utils/auth.go`, and `frontend/src/context/authContext.tsx`. | +| Change a debate screen | The matching page in `frontend/src/Pages/`, then its room components and `useDebateWS.ts`. | +| Change realtime behavior | Matching files under `backend/websocket/`, then the frontend WebSocket hook/service. | +| Change transcript judging | `backend/services/transcriptservice.go`, transcript controller/routes, and `frontend/src/services/transcriptService.ts`. | +| Change rating behavior | `backend/services/rating_service.go` and `backend/rating/glicko2.go`. | +| Change teams | `backend/models/team.go`, team controllers/routes/services/WebSockets, and `frontend/src/services/teamService.ts` or `teamDebateService.ts`. | +| Change admin permissions | `backend/middlewares/rbac.go`, `backend/rbac_model.conf`, admin routes/controllers, and admin frontend pages/services. | +| Change global styling/layout | `frontend/src/components/Layout.tsx`, `frontend/src/App.css`, and `frontend/src/index.css`. | + +## 9. Operational caveats + +- Startup currently seeds debate data and test users from `cmd/server/main.go`; verify the target environment before treating startup as side-effect free. +- WebSocket origin checking is permissive in the current implementation and should be reviewed before production exposure. +- Several debug/test endpoints and harnesses are present in the source tree; do not assume every route is intended for public production use. +- The codebase uses direct MongoDB collection access, so schema changes require coordinated updates across models, queries, and frontend response types. +- The frontend and backend use a mixture of `/api/...` and root-level paths. When adding an endpoint, follow the existing route group and its matching client service carefully. \ No newline at end of file