From 4b859bc44ac3de28787f24aae52105bf2e7cef05 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 22:25:43 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20add=20plan=203=20=E2=80=94=20Telegram-f?= =?UTF-8?q?ree=20web=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-state of plan 3: operator installs the web app as a PWA on their phone, uses it for everything (pairing with live QR in browser, browsing groups, sending tests, scheduling reminders). Telegram bot is fully removed. Architecture: bot container shrinks (no grammy, no menus); a new ipc/command-consumer.ts listens to Postgres LISTEN bot.command and dispatches to existing Baileys/sender/sync logic. New apps/web is Next.js 16 with Server Components (reads), Server Actions (mutations), SSE for live updates, and @serwist/next for PWA. 24 tasks across 8 phases (A: Telegram removal, B: web skeleton, C: foundation, D: read pages, E: mutations, F: reminder wizard, G: PWA, H: verify + push). UI components delegated to frontend-design skill during execution. --- docs/superpowers/plans/2026-05-09-web-app.md | 2486 ++++++++++++++++++ 1 file changed, 2486 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-09-web-app.md diff --git a/docs/superpowers/plans/2026-05-09-web-app.md b/docs/superpowers/plans/2026-05-09-web-app.md new file mode 100644 index 0000000..ba4a853 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-web-app.md @@ -0,0 +1,2486 @@ +# Web App (Telegram-Free Pivot) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking. UI component implementation tasks (Phases D–F) should be executed with assistance from the **frontend-design:frontend-design** skill — invoke it when a task says "use frontend-design". + +**Goal:** Replace the Telegram bot with a Next.js PWA at `wabot.04080616.xyz`. Operator installs the web app on their phone and uses it for everything: pairing accounts (live QR in browser), browsing groups, sending test messages, scheduling reminders. Telegram code is fully removed. + +**Architecture:** `apps/bot` shrinks (no more `grammy`/menus/wizard); a new `ipc/command-consumer.ts` listens to Postgres `LISTEN bot.command` and dispatches to existing Baileys/sender/sync logic. New `apps/web` is a Next.js 16 App Router app with Server Components for reads, Server Actions for mutations, an SSE endpoint for live updates, and `@serwist/next` for PWA. + +**Tech Stack:** Next.js 16, React 19, TypeScript, Tailwind CSS v4, shadcn/ui, react-hook-form + zod, Drizzle, `pg` (for LISTEN), `@serwist/next`, Geist font, Sonner toast. + +**Spec reference:** `docs/superpowers/specs/2026-05-09-web-app-design.md` + +**Phase guide:** +- A — Telegram removal (Tasks 1-4) +- B — Web app skeleton (Tasks 5-9) +- C — Foundation: layout, SSE, middleware (Tasks 10-12) +- D — Read-only pages (Tasks 13-16) +- E — Mutations: pair, unpair, send-test (Tasks 17-19) +- F — Reminder wizard (Tasks 20-21) +- G — PWA (Task 22) +- H — Verify + push (Tasks 23-24) + +--- + +## File structure produced by this plan + +``` +apps/ +├── bot/ +│ └── src/ +│ ├── ipc/ +│ │ ├── notify.ts (NEW) typed pgNotify wrapper +│ │ ├── command-consumer.ts (NEW) replaces telegram/bot.ts +│ │ ├── pair-handler.ts (NEW) pair flow without Telegram +│ │ ├── unpair-handler.ts (NEW) +│ │ ├── send-test-handler.ts (NEW) +│ │ └── sync-groups-handler.ts (NEW) +│ ├── telegram/ (DELETED — entire directory) +│ └── index.ts (MODIFIED) +│ +└── web/ (NEW) + ├── package.json + ├── tsconfig.json + ├── next.config.ts + ├── tailwind.config.ts + ├── postcss.config.mjs + ├── components.json shadcn-ui config + ├── public/ + │ ├── icon-192.png + │ ├── icon-512.png + │ └── apple-touch-icon.png + └── src/ + ├── env.ts zod env validation + ├── middleware.ts rate limit + 404 for /api/* except events + ├── lib/ + │ ├── db.ts server-only Drizzle client + │ ├── operator.ts getSeededOperator() helper + │ ├── notify.ts pgNotify('bot.command', ...) helper + │ └── logger.ts pino on server + ├── hooks/ + │ └── use-events.ts SSE hook + ├── actions/ Server Actions + │ ├── accounts.ts pair, unpair, sync-groups + │ ├── groups.ts send-test + │ ├── reminders.ts create, delete, edit + │ └── media.ts upload + ├── components/ + │ ├── app-shell.tsx responsive layout (bottom nav / sidebar) + │ ├── theme-provider.tsx + │ ├── ui/ shadcn components installed here + │ └── (feature-specific components) + ├── app/ + │ ├── layout.tsx root layout, theme provider, app shell + │ ├── page.tsx dashboard + │ ├── accounts/ + │ │ ├── page.tsx list + │ │ ├── new/page.tsx pair (live QR) + │ │ └── [id]/ + │ │ ├── page.tsx detail + │ │ ├── pairing/page.tsx (live QR sub-route) + │ │ └── groups/page.tsx + │ ├── groups/[id]/page.tsx detail + send-test + │ ├── reminders/ + │ │ ├── page.tsx list + │ │ ├── new/page.tsx wizard (URL-state, ?step=1..5) + │ │ └── [id]/page.tsx detail + history + delete + │ ├── settings/page.tsx + │ ├── manifest.webmanifest/route.ts (PWA) + │ └── api/ + │ ├── events/route.ts SSE + │ └── health/route.ts + └── pwa/ + └── sw.ts serwist service worker entry + +docker/web.Dockerfile (REPLACED — currently a placeholder) +docker-compose.base.yml (MODIFIED — add web service) +docker-compose.dev.yml (MODIFIED — add web overrides) +docs/superpowers/specs/manual-test-web.md (NEW manual runbook) +``` + +--- + +# Phase A — Telegram removal + +## Task 1: Add IPC notify helper + command consumer skeleton in `bot` + +**Files:** +- Create: `apps/bot/src/ipc/notify.ts` +- Create: `apps/bot/src/ipc/command-consumer.ts` (skeleton) + +- [ ] **Step 1: Create `apps/bot/src/ipc/notify.ts`** + +```typescript +import { sql } from "drizzle-orm"; +import { db } from "../db.js"; +import { logger } from "../logger.js"; + +export type WebEvent = + | { type: "session.qr"; accountId: string; qrPng: string /* base64 */ } + | { type: "session.connected"; accountId: string; phoneNumber: string | null } + | { type: "session.disconnected"; accountId: string } + | { type: "session.timeout"; accountId: string } + | { type: "groups.synced"; accountId: string; count: number } + | { type: "reminder.fired"; reminderId: string; runId: string; status: string } + | { type: "reminder.failed"; reminderId: string; error: string }; + +export async function pgNotifyWeb(event: WebEvent): Promise { + const json = JSON.stringify(event); + // pg_notify takes a literal channel name as 1st arg. + await db.execute(sql`SELECT pg_notify('web.event', ${json})`); + logger.debug({ event: event.type }, "ipc: web.event published"); +} +``` + +- [ ] **Step 2: Create `apps/bot/src/ipc/command-consumer.ts` (skeleton)** + +```typescript +import { Client } from "pg"; +import { logger } from "../logger.js"; +import { env } from "../env.js"; + +export type BotCommand = + | { type: "account.start_pairing"; accountId: string } + | { type: "account.unpair"; accountId: string } + | { type: "account.sync_groups"; accountId: string } + | { type: "group.send_test"; groupId: string; text: string }; + +type Handler = (cmd: BotCommand) => Promise; +const handlers: { [K in BotCommand["type"]]?: (cmd: Extract) => Promise } = {}; + +export function registerHandler( + type: T, + fn: (cmd: Extract) => Promise, +): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (handlers as any)[type] = fn; +} + +export async function startCommandConsumer(): Promise<() => Promise> { + const client = new Client({ connectionString: env.DATABASE_URL }); + await client.connect(); + await client.query("LISTEN \"bot.command\""); + + client.on("notification", (msg) => { + if (msg.channel !== "bot.command" || !msg.payload) return; + let cmd: BotCommand; + try { + cmd = JSON.parse(msg.payload) as BotCommand; + } catch (err) { + logger.warn({ err, payload: msg.payload }, "ipc: bad command payload"); + return; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fn: Handler | undefined = (handlers as any)[cmd.type]; + if (!fn) { + logger.warn({ cmd }, "ipc: no handler for command type"); + return; + } + fn(cmd).catch((err) => logger.error({ err, cmd }, "ipc: handler failed")); + }); + + client.on("error", (err) => logger.error({ err }, "ipc: consumer client error")); + logger.info("ipc: command consumer started"); + + return async () => { + try { + await client.query("UNLISTEN \"bot.command\""); + } catch (err) { + logger.warn({ err }, "ipc: UNLISTEN failed (continuing shutdown)"); + } + await client.end(); + logger.info("ipc: command consumer stopped"); + }; +} +``` + +- [ ] **Step 3: Typecheck** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/bot/src/ipc +git -c commit.gpgsign=false commit -m "feat(bot): add IPC notify helper + command consumer skeleton" +``` + +--- + +## Task 2: Move pair / unpair / send-test / sync-groups handlers into `ipc/` + +**Files:** +- Create: `apps/bot/src/ipc/pair-handler.ts` +- Create: `apps/bot/src/ipc/unpair-handler.ts` +- Create: `apps/bot/src/ipc/send-test-handler.ts` +- Create: `apps/bot/src/ipc/sync-groups-handler.ts` + +- [ ] **Step 1: Create `apps/bot/src/ipc/pair-handler.ts`** + +```typescript +import { eq, and, lt } from "drizzle-orm"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { whatsappAccounts } from "@cmbot/db"; +import { db } from "../db.js"; +import { env } from "../env.js"; +import { logger } from "../logger.js"; +import { sessionManager } from "../whatsapp/session-manager.js"; +import { renderQrPng } from "../whatsapp/qr-renderer.js"; +import { syncGroupsForAccount } from "../whatsapp/group-sync.js"; +import { writeAuditLog } from "../audit.js"; +import { pgNotifyWeb } from "./notify.js"; + +const PAIR_TIMEOUT_MS = 5 * 60 * 1000; +const offByAccount = new Map void>(); +const lastQrPayload = new Map(); +const pairTimeouts = new Map(); + +async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> { + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq }) => eq(a.id, accountId), + }); + if (!account || account.status !== "pending") { + return { existed: false, label: account?.label ?? null }; + } + const off = offByAccount.get(accountId); + if (off) { + off(); + offByAccount.delete(accountId); + } + const t = pairTimeouts.get(accountId); + if (t) { + clearTimeout(t); + pairTimeouts.delete(accountId); + } + lastQrPayload.delete(accountId); + if (sessionManager.hasSession(accountId)) { + await sessionManager.stop(accountId); + } + await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true }); + await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId)); + return { existed: true, label: account.label }; +} + +export async function handleStartPairing(accountId: string): Promise { + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq }) => eq(a.id, accountId), + }); + if (!account) { + logger.warn({ accountId }, "pair: account row missing"); + return; + } + + // Wire up event listener for this account + const off = sessionManager.on(async (id, _state, event) => { + if (id !== accountId) return; + try { + if (event.type === "qr") { + if (lastQrPayload.get(id) === event.payload) return; + lastQrPayload.set(id, event.payload); + const png = await renderQrPng(event.payload); + await pgNotifyWeb({ + type: "session.qr", + accountId: id, + qrPng: png.toString("base64"), + }); + } else if (event.type === "open") { + const t = pairTimeouts.get(id); + if (t) { + clearTimeout(t); + pairTimeouts.delete(id); + } + lastQrPayload.delete(id); + offByAccount.delete(id); + const session = sessionManager.getSession(id); + let synced = 0; + if (session) { + const r = await syncGroupsForAccount(id, session.socket); + synced = r.synced; + } + await writeAuditLog(db, { + operatorId: account.operatorId, + source: "web", + action: "account.paired", + targetType: "whatsapp_account", + targetId: id, + payload: { label: account.label }, + }); + await pgNotifyWeb({ + type: "session.connected", + accountId: id, + phoneNumber: event.phoneNumber ?? null, + }); + await pgNotifyWeb({ + type: "groups.synced", + accountId: id, + count: synced, + }); + off(); + } else if (event.type === "close" && event.loggedOut) { + const t = pairTimeouts.get(id); + if (t) { + clearTimeout(t); + pairTimeouts.delete(id); + } + lastQrPayload.delete(id); + offByAccount.delete(id); + await pgNotifyWeb({ type: "session.timeout", accountId: id }); + off(); + } + } catch (err) { + logger.error({ err, accountId: id }, "pair: handler error"); + } + }); + offByAccount.set(accountId, off); + + try { + await sessionManager.start(accountId); + } catch (err) { + logger.error({ err, accountId }, "pair: start failed"); + off(); + offByAccount.delete(accountId); + await pgNotifyWeb({ type: "session.timeout", accountId }); + return; + } + + const timeoutId = setTimeout(() => { + void (async () => { + try { + const r = await abandonPair(accountId); + if (r.existed) { + await pgNotifyWeb({ type: "session.timeout", accountId }); + } + } catch (err) { + logger.error({ err, accountId }, "pair: timeout cleanup failed"); + } + })(); + }, PAIR_TIMEOUT_MS); + pairTimeouts.set(accountId, timeoutId); +} + +/** Sweep stale pending accounts on bot startup. Same as before. */ +export async function sweepStalePendingAccounts(): Promise { + const cutoff = new Date(Date.now() - 60 * 60 * 1000); + const stale = await db + .select({ id: whatsappAccounts.id, label: whatsappAccounts.label }) + .from(whatsappAccounts) + .where(and(eq(whatsappAccounts.status, "pending"), lt(whatsappAccounts.createdAt, cutoff))); + for (const row of stale) { + await rm(join(env.SESSIONS_DIR, row.id), { recursive: true, force: true }); + await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, row.id)); + logger.info({ accountId: row.id, label: row.label }, "sweep: removed stale pending account"); + } +} +``` + +- [ ] **Step 2: Create `apps/bot/src/ipc/unpair-handler.ts`** + +```typescript +import { eq } from "drizzle-orm"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { whatsappAccounts } from "@cmbot/db"; +import { db } from "../db.js"; +import { env } from "../env.js"; +import { sessionManager } from "../whatsapp/session-manager.js"; +import { writeAuditLog } from "../audit.js"; +import { pgNotifyWeb } from "./notify.js"; +import { logger } from "../logger.js"; + +export async function handleUnpair(accountId: string): Promise { + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq }) => eq(a.id, accountId), + }); + if (!account) { + logger.warn({ accountId }, "unpair: account row missing"); + return; + } + await sessionManager.stop(accountId); + await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true }); + await db + .update(whatsappAccounts) + .set({ status: "logged_out", phoneNumber: null }) + .where(eq(whatsappAccounts.id, accountId)); + await writeAuditLog(db, { + operatorId: account.operatorId, + source: "web", + action: "account.unpaired", + targetType: "whatsapp_account", + targetId: accountId, + payload: { label: account.label }, + }); + await pgNotifyWeb({ type: "session.disconnected", accountId }); +} +``` + +- [ ] **Step 3: Create `apps/bot/src/ipc/sync-groups-handler.ts`** + +```typescript +import { whatsappAccounts } from "@cmbot/db"; +import { db } from "../db.js"; +import { sessionManager } from "../whatsapp/session-manager.js"; +import { syncGroupsForAccount } from "../whatsapp/group-sync.js"; +import { pgNotifyWeb } from "./notify.js"; +import { logger } from "../logger.js"; + +export async function handleSyncGroups(accountId: string): Promise { + const session = sessionManager.getSession(accountId); + if (!session) { + logger.warn({ accountId }, "sync-groups: account not connected"); + return; + } + const result = await syncGroupsForAccount(accountId, session.socket); + await pgNotifyWeb({ type: "groups.synced", accountId, count: result.synced }); +} +``` + +- [ ] **Step 4: Create `apps/bot/src/ipc/send-test-handler.ts`** + +```typescript +import { sessionManager } from "../whatsapp/session-manager.js"; +import { sendTextToGroup } from "../whatsapp/sender.js"; +import { writeAuditLog } from "../audit.js"; +import { db } from "../db.js"; +import { logger } from "../logger.js"; + +export async function handleSendTest(groupId: string, text: string): Promise { + const group = await db.query.whatsappGroups.findFirst({ + where: (g, { eq }) => eq(g.id, groupId), + }); + if (!group) { + logger.warn({ groupId }, "send-test: group missing"); + return; + } + const session = sessionManager.getSession(group.accountId); + if (!session) { + logger.warn({ groupId, accountId: group.accountId }, "send-test: account not connected"); + return; + } + try { + const result = await sendTextToGroup(session.socket, group.waGroupJid, text); + await writeAuditLog(db, { + operatorId: null, + source: "web", + action: "group.send_test", + targetType: "whatsapp_group", + targetId: groupId, + payload: { groupName: group.name, length: text.length, waMessageId: result.messageId ?? null }, + }); + } catch (err) { + logger.error({ err, groupId }, "send-test: failed"); + } +} +``` + +- [ ] **Step 5: Wire handlers into `command-consumer.ts`** + +Replace the bottom of `apps/bot/src/ipc/command-consumer.ts` (the `startCommandConsumer` function) with the version that registers handlers on startup. Add this BEFORE `startCommandConsumer`: + +```typescript +import { handleStartPairing } from "./pair-handler.js"; +import { handleUnpair } from "./unpair-handler.js"; +import { handleSyncGroups } from "./sync-groups-handler.js"; +import { handleSendTest } from "./send-test-handler.js"; +``` + +Then at the bottom, after the existing `startCommandConsumer` function, append: + +```typescript +export function registerDefaultHandlers(): void { + registerHandler("account.start_pairing", async (cmd) => { + await handleStartPairing(cmd.accountId); + }); + registerHandler("account.unpair", async (cmd) => { + await handleUnpair(cmd.accountId); + }); + registerHandler("account.sync_groups", async (cmd) => { + await handleSyncGroups(cmd.accountId); + }); + registerHandler("group.send_test", async (cmd) => { + await handleSendTest(cmd.groupId, cmd.text); + }); +} +``` + +- [ ] **Step 6: Typecheck** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/bot/src/ipc +git -c commit.gpgsign=false commit -m "feat(bot): add IPC handlers for pair / unpair / sync / send-test" +``` + +--- + +## Task 3: Replace `apps/bot/src/index.ts` to use IPC consumer instead of Telegram + +**Files:** +- Modify: `apps/bot/src/index.ts` + +- [ ] **Step 1: Replace `apps/bot/src/index.ts` with** + +```typescript +import { logger } from "./logger.js"; +import { pool } from "./db.js"; +import { startHealthServer, setSessionCountsProvider } from "./health.js"; +import { sessionManager } from "./whatsapp/session-manager.js"; +import { startBoss, stopBoss } from "./scheduler/pgboss-client.js"; +import { registerReminderJobs } from "./scheduler/reminder-jobs.js"; +import { + startCommandConsumer, + registerDefaultHandlers, +} from "./ipc/command-consumer.js"; +import { sweepStalePendingAccounts } from "./ipc/pair-handler.js"; + +async function main(): Promise { + logger.info("bot starting"); + const health = startHealthServer(); + setSessionCountsProvider(() => sessionManager.getCounts()); + + const boss = await startBoss(); + await registerReminderJobs(boss); + + registerDefaultHandlers(); + const stopConsumer = await startCommandConsumer(); + + await sweepStalePendingAccounts(); + await sessionManager.resumeFromDb(); + + const shutdown = async (signal: string): Promise => { + logger.info({ signal }, "shutting down"); + await stopConsumer(); + await sessionManager.stopAll(); + await stopBoss(); + health.close(); + await pool.end(); + process.exit(0); + }; + + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + + logger.info("bot ready"); +} + +main().catch((err) => { + logger.fatal({ err }, "bot failed to start"); + process.exit(1); +}); +``` + +- [ ] **Step 2: Typecheck** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +``` + +Expected: errors about deleted Telegram imports (we delete the Telegram code in Task 4). + +- [ ] **Step 3: Don't commit yet** — Task 4 finishes the deletion. The repo is in a transient state. + +--- + +## Task 4: Delete `apps/bot/src/telegram/` and remove `grammy` + Telegram env keys + +**Files:** +- Delete: `apps/bot/src/telegram/` (entire directory) +- Delete: `apps/bot/src/media/ingest.ts` (Telegram-side download — web uploads media now) +- Modify: `apps/bot/src/env.ts` (remove TELEGRAM_* keys) +- Modify: `apps/bot/package.json` (remove `grammy`) +- Modify: `envs/.env.example` (remove TELEGRAM_* keys) +- Modify: `.env.development` (remove TELEGRAM_* keys) +- Modify: `apps/bot/src/whatsapp/qr-renderer.ts` (no change — but ensure it stays since the bot still renders QR for the web path) + +- [ ] **Step 1: Delete the Telegram directory** + +```bash +rm -rf apps/bot/src/telegram +rm -f apps/bot/src/media/ingest.ts +``` + +(`apps/bot/src/media/` directory becomes empty — leave the dir or delete it; both are fine.) + +- [ ] **Step 2: Update `apps/bot/src/env.ts`** — remove `TELEGRAM_BOT_TOKEN`, `TELEGRAM_OPERATOR_WHITELIST`, `TELEGRAM_QR_CHAT_ID` + +```typescript +import { z } from "zod"; + +const numberFromString = z.string().regex(/^\d+$/).transform((s) => Number(s)); + +const envSchema = z.object({ + DATABASE_URL: z.string().url(), + DATA_DIR: z.string().min(1), + SESSIONS_DIR: z.string().min(1), + MEDIA_DIR: z.string().min(1), + BOT_HEALTH_PORT: numberFromString, + BOT_LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), +}); + +export type Env = z.infer; + +export function parseEnv(input: Record): Env { + return envSchema.parse(input); +} + +export const env = parseEnv(process.env); +``` + +- [ ] **Step 3: Update `apps/bot/src/env.test.ts`** to remove deleted keys from the valid fixture + +```typescript +import { describe, expect, it } from "vitest"; +import { parseEnv } from "./env.js"; + +const valid = { + DATABASE_URL: "postgres://u:p@h:5432/db", + DATA_DIR: "/data", + SESSIONS_DIR: "/data/sessions", + MEDIA_DIR: "/data/media", + BOT_HEALTH_PORT: "8081", + BOT_LOG_LEVEL: "info", +}; + +describe("parseEnv", () => { + it("parses a valid env", () => { + const env = parseEnv(valid); + expect(env.BOT_HEALTH_PORT).toBe(8081); + }); + + it("rejects missing DATABASE_URL", () => { + const { DATABASE_URL: _, ...rest } = valid; + expect(() => parseEnv(rest)).toThrow(); + }); + + it("rejects malformed port", () => { + expect(() => parseEnv({ ...valid, BOT_HEALTH_PORT: "notanumber" })).toThrow(); + }); +}); +``` + +- [ ] **Step 4: Remove `grammy` from `apps/bot/package.json` deps** + +Edit `apps/bot/package.json`, delete the `"grammy": "^1.31.0",` line. + +- [ ] **Step 5: Run install to update lockfile** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm install +``` + +- [ ] **Step 6: Update env files** — remove `TELEGRAM_BOT_TOKEN`, `TELEGRAM_OPERATOR_WHITELIST`, `TELEGRAM_QR_CHAT_ID`, `SEED_OPERATOR_TELEGRAM_ID`, `SEED_OPERATOR_NAME` from both `envs/.env.example` and `.env.development`. Keep `WEB_PORT` and `AUTH_SECRET` (we'll use them in Phase B). + +`.env.development` after edit (replace the keys you remove with web-relevant ones — `WEB_PORT` already there): + +```bash +DATABASE_URL=postgres://waBot:cJe3SGjHHAitNBE4@192.168.0.210:5432/wabot +DATA_DIR=/data +SESSIONS_DIR=/data/sessions +MEDIA_DIR=/data/media +BOT_HEALTH_PORT=8081 +BOT_LOG_LEVEL=debug +WEB_PORT=3000 +AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c +``` + +`envs/.env.example` similarly cleaned. + +- [ ] **Step 7: Update `docker-compose.base.yml`** — remove the `TELEGRAM_*`, `SEED_*` env entries from the `tools` service environment block. Keep DATABASE_URL etc. + +- [ ] **Step 8: Update `docker-compose.dev.yml`** for the bot service — remove `TELEGRAM_BOT_TOKEN`, `TELEGRAM_OPERATOR_WHITELIST`, `TELEGRAM_QR_CHAT_ID` from the `bot` service's `environment:` block. + +- [ ] **Step 9: Typecheck and run tests** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot test +``` + +Expected: typecheck clean, tests pass (env tests pass, deleted Telegram tests gone). + +- [ ] **Step 10: Restart bot and verify clean startup** + +```bash +NO_SUDO=1 scripts/dev.sh restart-bot +sleep 6 +docker compose --env-file .env.development -f docker-compose.base.yml -f docker-compose.dev.yml logs --tail=15 bot +``` + +Expected log lines (no Telegram references): +``` +bot starting +health server listening +pg-boss started +reminder.fire: handler registered +ipc: command consumer started +bot ready +``` + +- [ ] **Step 11: Commit** + +```bash +git add -A +git -c commit.gpgsign=false commit -m "feat(bot): remove Telegram code; switch to IPC consumer" +``` + +--- + +# Phase B — Web app skeleton + +## Task 5: Initialize `apps/web` Next.js project + +**Files:** +- Create: `apps/web/package.json` +- Create: `apps/web/tsconfig.json` +- Create: `apps/web/next.config.ts` +- Create: `apps/web/postcss.config.mjs` +- Create: `apps/web/src/app/layout.tsx` +- Create: `apps/web/src/app/page.tsx` +- Create: `apps/web/src/app/globals.css` +- Create: `apps/web/src/env.ts` + +- [ ] **Step 1: Create `apps/web/package.json`** + +```json +{ + "name": "@cmbot/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --hostname 0.0.0.0", + "build": "next build", + "start": "next start --hostname 0.0.0.0", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@cmbot/db": "workspace:*", + "@cmbot/shared": "workspace:*", + "next": "^16.0.0", + "pg": "^8.13.0", + "pino": "^9.5.0", + "pino-pretty": "^11.3.0", + "qrcode": "^1.5.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "@types/node": "^22.7.0", + "@types/pg": "^8.11.10", + "@types/qrcode": "^1.5.5", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.5.0" + } +} +``` + +- [ ] **Step 2: Create `apps/web/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "preserve", + "incremental": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules"] +} +``` + +- [ ] **Step 3: Create `apps/web/next.config.ts`** + +```typescript +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + reactStrictMode: true, + // Standalone output for the production Docker image + output: "standalone", + // Trust the workspace packages so Drizzle/luxon don't break in RSC + transpilePackages: ["@cmbot/db", "@cmbot/shared"], + experimental: { + typedRoutes: true, + }, +}; + +export default nextConfig; +``` + +- [ ] **Step 4: Create `apps/web/postcss.config.mjs`** + +```javascript +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; +``` + +- [ ] **Step 5: Create `apps/web/src/app/globals.css`** + +```css +@import "tailwindcss"; + +@theme { + --font-sans: "Geist", system-ui, sans-serif; +} + +:root { + color-scheme: light dark; +} + +body { + @apply bg-background text-foreground; +} +``` + +- [ ] **Step 6: Create `apps/web/src/app/layout.tsx`** + +```typescript +import type { Metadata, Viewport } from "next"; +import { GeistSans } from "geist/font/sans"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "cm WhatsApp Bot", + description: "Self-hosted WhatsApp reminder bot", + applicationName: "cm WhatsApp Bot", + appleWebApp: { + capable: true, + title: "cm WA Bot", + statusBarStyle: "default", + }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" }, + ], +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +- [ ] **Step 7: Create `apps/web/src/app/page.tsx`** + +```typescript +export default function Page() { + return ( +
+

cm WhatsApp Bot

+

+ Web app skeleton — wired up. Real dashboard arrives in Task 13. +

+
+ ); +} +``` + +- [ ] **Step 8: Create `apps/web/src/env.ts`** + +```typescript +import { z } from "zod"; + +const envSchema = z.object({ + DATABASE_URL: z.string().url(), + DATA_DIR: z.string().min(1).default("/data"), + MEDIA_DIR: z.string().min(1).default("/data/media"), + WEB_PORT: z.string().regex(/^\d+$/).transform((s) => Number(s)).default("3000"), +}); + +export type Env = z.infer; +export const env = envSchema.parse(process.env); +``` + +- [ ] **Step 9: Add `geist` dependency** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm add geist --filter @cmbot/web +``` + +- [ ] **Step 10: Install workspace deps** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm install +``` + +Expected: `next`, `react`, `tailwindcss` etc. all resolved. + +- [ ] **Step 11: Commit** + +```bash +git add apps/web pnpm-lock.yaml +git -c commit.gpgsign=false commit -m "feat(web): scaffold Next.js 16 app with Tailwind 4 + Geist" +``` + +--- + +## Task 6: shadcn/ui setup + base components + +**Files:** +- Create: `apps/web/components.json` +- Create: `apps/web/src/components/ui/` (populated by shadcn CLI) + +- [ ] **Step 1: Create `apps/web/components.json`** + +```json +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} +``` + +- [ ] **Step 2: Run shadcn init via the tools container** + +```bash +NO_SUDO=1 scripts/dev.sh exec sh -c "cd apps/web && pnpm dlx shadcn@latest init --yes" +``` + +This creates `src/lib/utils.ts`, updates `globals.css` with shadcn theme tokens, and installs `tailwindcss-animate`, `class-variance-authority`, `clsx`, `tailwind-merge`, `lucide-react`. + +- [ ] **Step 3: Install base shadcn components** + +```bash +NO_SUDO=1 scripts/dev.sh exec sh -c "cd apps/web && pnpm dlx shadcn@latest add button card form input textarea label dialog sheet dropdown-menu sonner skeleton tabs badge separator avatar table --yes" +``` + +- [ ] **Step 4: Typecheck** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/web pnpm-lock.yaml +git -c commit.gpgsign=false commit -m "feat(web): shadcn/ui init + base components" +``` + +--- + +## Task 7: Drizzle client, operator helper, notify helper + +**Files:** +- Create: `apps/web/src/lib/db.ts` +- Create: `apps/web/src/lib/operator.ts` +- Create: `apps/web/src/lib/notify.ts` +- Create: `apps/web/src/lib/logger.ts` + +- [ ] **Step 1: Create `apps/web/src/lib/db.ts`** + +```typescript +import "server-only"; +import { createClient, type DB } from "@cmbot/db"; +import { env } from "@/env"; + +const { db, pool } = createClient(env.DATABASE_URL); + +export { db, pool }; +export type { DB }; +``` + +- [ ] **Step 2: Create `apps/web/src/lib/operator.ts`** + +```typescript +import "server-only"; +import { db } from "./db"; + +/** + * Returns the single seeded operator row. Since the app has no auth, + * every action is attributed to this operator. + */ +export async function getSeededOperator() { + const op = await db.query.operators.findFirst({ + orderBy: (o, { asc }) => [asc(o.createdAt)], + }); + if (!op) { + throw new Error("No operator row seeded. Run scripts/db.sh seed."); + } + return op; +} +``` + +- [ ] **Step 3: Create `apps/web/src/lib/notify.ts`** + +```typescript +import "server-only"; +import { sql } from "drizzle-orm"; +import { db } from "./db"; + +export type BotCommand = + | { type: "account.start_pairing"; accountId: string } + | { type: "account.unpair"; accountId: string } + | { type: "account.sync_groups"; accountId: string } + | { type: "group.send_test"; groupId: string; text: string }; + +export async function pgNotifyBot(cmd: BotCommand): Promise { + const json = JSON.stringify(cmd); + await db.execute(sql`SELECT pg_notify('bot.command', ${json})`); +} +``` + +- [ ] **Step 4: Create `apps/web/src/lib/logger.ts`** + +```typescript +import "server-only"; +import pino from "pino"; + +export const logger = pino({ + level: process.env.NODE_ENV === "production" ? "info" : "debug", + ...(process.env.NODE_ENV !== "production" + ? { transport: { target: "pino-pretty", options: { colorize: true } } } + : {}), +}); +``` + +- [ ] **Step 5: Add `server-only` package** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm add server-only --filter @cmbot/web +``` + +- [ ] **Step 6: Typecheck** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/web pnpm-lock.yaml +git -c commit.gpgsign=false commit -m "feat(web): db client, operator helper, IPC notify, logger" +``` + +--- + +## Task 8: Web Dockerfile + compose service + +**Files:** +- Replace: `docker/web.Dockerfile` +- Modify: `docker-compose.base.yml` +- Modify: `docker-compose.dev.yml` + +- [ ] **Step 1: Replace `docker/web.Dockerfile`** with the multi-stage build + +```dockerfile +FROM node:22-alpine AS base +RUN npm install -g pnpm@9.12.0 +WORKDIR /app + +FROM base AS deps +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY apps/web/package.json apps/web/ +COPY packages/db/package.json packages/db/ +COPY packages/shared/package.json packages/shared/ +RUN pnpm install --frozen-lockfile + +FROM base AS build +COPY --from=deps /app/node_modules /app/node_modules +COPY --from=deps /app/apps/web/node_modules /app/apps/web/node_modules +COPY --from=deps /app/packages/db/node_modules /app/packages/db/node_modules +COPY --from=deps /app/packages/shared/node_modules /app/packages/shared/node_modules +COPY tsconfig.base.json turbo.json ./ +COPY apps/web apps/web +COPY packages/db packages/db +COPY packages/shared packages/shared +RUN pnpm --filter @cmbot/shared build && \ + pnpm --filter @cmbot/db build && \ + pnpm --filter @cmbot/web build + +FROM base AS runtime +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +COPY --from=build /app/apps/web/.next/standalone ./ +COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=build /app/apps/web/public ./apps/web/public +EXPOSE 3000 +CMD ["node", "apps/web/server.js"] +``` + +- [ ] **Step 2: Add `web` service to `docker-compose.base.yml`** (insert before `networks:` block) + +```yaml + web: + build: + context: . + dockerfile: docker/web.Dockerfile + image: cm-whatsapp-web:local + container_name: cmbot-web + restart: unless-stopped + environment: + DATABASE_URL: ${DATABASE_URL} + DATA_DIR: ${DATA_DIR} + MEDIA_DIR: ${MEDIA_DIR} + WEB_PORT: ${WEB_PORT} + networks: + - cmbot +``` + +- [ ] **Step 3: Add web override to `docker-compose.dev.yml`** + +```yaml + web: + build: + context: . + dockerfile: docker/web.Dockerfile + target: build + image: cm-whatsapp-web:dev + container_name: cmbot-web + user: "${HOST_UID:-1000}:${HOST_GID:-1000}" + working_dir: /app + command: ["pnpm", "--filter", "@cmbot/web", "dev"] + restart: unless-stopped + volumes: + - .:/app + - ./dev-data:/data + ports: + - "127.0.0.1:3000:3000" + environment: + NODE_ENV: development + DATABASE_URL: ${DATABASE_URL} + DATA_DIR: ${DATA_DIR} + MEDIA_DIR: ${MEDIA_DIR} + WEB_PORT: ${WEB_PORT} + depends_on: + - tools +``` + +- [ ] **Step 4: Commit** + +```bash +git add docker/web.Dockerfile docker-compose.base.yml docker-compose.dev.yml +git -c commit.gpgsign=false commit -m "chore: add web Dockerfile and dev compose service" +``` + +--- + +## Task 9: VERIFY web container serves the placeholder page + +This task has no source changes — just brings up the new `web` service and confirms it works. + +- [ ] **Step 1: Bring up the stack** + +```bash +NO_SUDO=1 scripts/dev.sh up +``` + +Expected: tools, bot, and web all start. First-time web build pulls Next.js + React (1-2 min). + +- [ ] **Step 2: Tail web logs** + +```bash +docker compose --env-file .env.development -f docker-compose.base.yml -f docker-compose.dev.yml logs --tail=30 web +``` + +Expected: Next.js dev server logs, ending with `Ready in ` and `Local: http://localhost:3000`. + +- [ ] **Step 3: curl the placeholder page** + +```bash +curl -s http://localhost:3000 | grep -o "cm WhatsApp Bot" +``` + +Expected: `cm WhatsApp Bot` (one match — the H1). + +(No commit — verification only.) + +--- + +# Phase C — Foundation: layout, SSE, middleware + +## Task 10: App shell layout (responsive nav) + +**Files:** +- Create: `apps/web/src/components/app-shell.tsx` +- Create: `apps/web/src/components/theme-provider.tsx` +- Modify: `apps/web/src/app/layout.tsx` + +This task uses the **frontend-design:frontend-design** skill — when you reach this task, dispatch frontend-design with the brief: "Build a responsive app shell for a self-hosted reminder dashboard. Mobile (<640px): top app bar with title + back button + bottom nav with 4 tabs (Dashboard 🏠, Accounts 📒, Reminders 📅, Settings ⚙). Desktop (≥640px): collapsible left sidebar with same nav. Use shadcn Sheet for mobile drawer if needed. Light + dark theme via CSS variables. Tabs: Dashboard `/`, Accounts `/accounts`, Reminders `/reminders`, Settings `/settings`." + +- [ ] **Step 1: Use frontend-design to build `apps/web/src/components/app-shell.tsx` and `theme-provider.tsx`** + +After the skill produces files, ensure they meet the requirements above. The shell should accept `children` as the main content area and render the active route name in the app bar. + +- [ ] **Step 2: Wire shell into `apps/web/src/app/layout.tsx`** + +```typescript +import type { Metadata, Viewport } from "next"; +import { GeistSans } from "geist/font/sans"; +import { ThemeProvider } from "@/components/theme-provider"; +import { AppShell } from "@/components/app-shell"; +import { Toaster } from "@/components/ui/sonner"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "cm WhatsApp Bot", + description: "Self-hosted WhatsApp reminder bot", + applicationName: "cm WhatsApp Bot", + appleWebApp: { capable: true, title: "cm WA Bot", statusBarStyle: "default" }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" }, + ], +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + + ); +} +``` + +- [ ] **Step 3: Typecheck** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src +git -c commit.gpgsign=false commit -m "feat(web): app shell with responsive nav + theme provider" +``` + +--- + +## Task 11: SSE endpoint + useEvents hook + +**Files:** +- Create: `apps/web/src/app/api/events/route.ts` +- Create: `apps/web/src/hooks/use-events.ts` + +- [ ] **Step 1: Create `apps/web/src/app/api/events/route.ts`** + +```typescript +import { NextRequest } from "next/server"; +import { Client } from "pg"; +import { env } from "@/env"; +import { logger } from "@/lib/logger"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(_req: NextRequest) { + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + const client = new Client({ connectionString: env.DATABASE_URL }); + let closed = false; + + const send = (event: string, data: unknown) => { + if (closed) return; + try { + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), + ); + } catch (err) { + logger.warn({ err }, "sse: enqueue failed"); + } + }; + + try { + await client.connect(); + await client.query('LISTEN "web.event"'); + } catch (err) { + logger.error({ err }, "sse: failed to start listener"); + controller.close(); + return; + } + + client.on("notification", (msg) => { + if (msg.channel !== "web.event" || !msg.payload) return; + try { + const parsed = JSON.parse(msg.payload) as { type: string }; + send(parsed.type, parsed); + } catch (err) { + logger.warn({ err, payload: msg.payload }, "sse: bad payload"); + } + }); + + client.on("error", (err) => { + logger.error({ err }, "sse: pg client error"); + }); + + // Keep-alive ping every 25 seconds + const ping = setInterval(() => send("ping", { ts: Date.now() }), 25_000); + + // Initial hello + send("hello", { ts: Date.now() }); + + // Cleanup on client disconnect — wired via the abort signal below + const cleanup = async () => { + if (closed) return; + closed = true; + clearInterval(ping); + try { + await client.query('UNLISTEN "web.event"'); + } catch { + // ignore + } + await client.end().catch(() => undefined); + try { + controller.close(); + } catch { + // ignore + } + }; + + // Tie cleanup to the request being aborted + _req.signal.addEventListener("abort", () => void cleanup()); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} +``` + +- [ ] **Step 2: Create `apps/web/src/hooks/use-events.ts`** + +```typescript +"use client"; + +import { useEffect } from "react"; + +export type WebEventMap = { + hello: { ts: number }; + ping: { ts: number }; + "session.qr": { accountId: string; qrPng: string }; + "session.connected": { accountId: string; phoneNumber: string | null }; + "session.disconnected": { accountId: string }; + "session.timeout": { accountId: string }; + "groups.synced": { accountId: string; count: number }; + "reminder.fired": { reminderId: string; runId: string; status: string }; + "reminder.failed": { reminderId: string; error: string }; +}; + +type Handlers = { [K in keyof WebEventMap]?: (data: WebEventMap[K]) => void }; + +export function useEvents(handlers: Handlers): void { + useEffect(() => { + const es = new EventSource("/api/events"); + const wired: { type: string; fn: (e: MessageEvent) => void }[] = []; + for (const type of Object.keys(handlers) as (keyof WebEventMap)[]) { + const h = handlers[type]; + if (!h) continue; + const fn = (e: MessageEvent) => { + try { + (h as (data: unknown) => void)(JSON.parse(e.data)); + } catch { + // ignore malformed + } + }; + es.addEventListener(type, fn); + wired.push({ type, fn }); + } + return () => { + for (const { type, fn } of wired) { + es.removeEventListener(type, fn); + } + es.close(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} +``` + +- [ ] **Step 3: Typecheck** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +``` + +- [ ] **Step 4: Verify the SSE endpoint streams hello + ping** + +```bash +curl -N http://localhost:3000/api/events 2>&1 | head -8 +``` + +Expected output (Ctrl-C to stop after a few lines): +``` +event: hello +data: {"ts":1778...} + +event: ping +data: {"ts":1778...} +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src +git -c commit.gpgsign=false commit -m "feat(web): SSE endpoint + useEvents hook" +``` + +--- + +## Task 12: Rate-limit middleware + /api/* deny rule + +**Files:** +- Create: `apps/web/src/middleware.ts` + +- [ ] **Step 1: Create `apps/web/src/middleware.ts`** + +```typescript +import { NextRequest, NextResponse } from "next/server"; + +const WINDOW_MS = 10_000; +const MAX_REQUESTS = 30; + +const buckets = new Map(); + +function isRateLimited(ip: string): boolean { + const now = Date.now(); + const bucket = buckets.get(ip); + if (!bucket || now - bucket.windowStart > WINDOW_MS) { + buckets.set(ip, { count: 1, windowStart: now }); + return false; + } + bucket.count += 1; + return bucket.count > MAX_REQUESTS; +} + +export function middleware(req: NextRequest) { + const path = req.nextUrl.pathname; + + // Block all /api/* except /api/events and /api/health + if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health") { + return new NextResponse("Not Found", { status: 404 }); + } + + // Rate limit by source IP. SSE endpoints are exempt (long-lived connection). + if (path === "/api/events") return NextResponse.next(); + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + if (isRateLimited(ip)) { + return new NextResponse("Too Many Requests", { status: 429 }); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"], +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/web/src/middleware.ts +git -c commit.gpgsign=false commit -m "feat(web): rate-limit middleware + /api deny rule" +``` + +--- + +# Phase D — Read-only pages + +## Task 13: Dashboard + accounts list + account detail (frontend-design) + +**Files:** +- Modify: `apps/web/src/app/page.tsx` (dashboard) +- Create: `apps/web/src/app/accounts/page.tsx` +- Create: `apps/web/src/app/accounts/[id]/page.tsx` +- Create: `apps/web/src/components/account-status-badge.tsx` + +This task uses the **frontend-design:frontend-design** skill. + +- [ ] **Step 1: Define data fetchers** — create `apps/web/src/lib/queries.ts` + +```typescript +import "server-only"; +import { sql } from "drizzle-orm"; +import { db } from "./db"; + +export async function getDashboardStats(operatorId: string) { + const accounts = await db.query.whatsappAccounts.findMany({ + where: (a, { eq }) => eq(a.operatorId, operatorId), + }); + const reminders = await db.query.reminders.findMany({ + where: (_, { sql: s }) => s`status = 'active'`, + }); + const recentRuns = await db.execute(sql` + SELECT rr.id, rr.status, rr.fired_at, r.name + FROM reminder_runs rr + JOIN reminders r ON r.id = rr.reminder_id + JOIN whatsapp_accounts wa ON wa.id = r.account_id + WHERE wa.operator_id = ${operatorId} + ORDER BY rr.fired_at DESC + LIMIT 10 + `); + return { + connectedAccounts: accounts.filter((a) => a.status === "connected").length, + totalAccounts: accounts.length, + activeReminders: reminders.length, + recentRuns: recentRuns.rows as never[], + }; +} + +export async function listAccounts(operatorId: string) { + return db.query.whatsappAccounts.findMany({ + where: (a, { eq }) => eq(a.operatorId, operatorId), + orderBy: (a, { asc }) => [asc(a.label)], + }); +} + +export async function getAccount(operatorId: string, accountId: string) { + return db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)), + }); +} +``` + +- [ ] **Step 2: Use frontend-design to build the three pages** + +Brief for the skill: +> Build three Server-Component pages for a self-hosted reminder dashboard: +> +> 1. **`/` (Dashboard)** — calls `getDashboardStats`. Render 3 stat cards (Accounts connected / Active reminders / Recent runs) and a Recent Activity table (last 10 reminder runs with status pills). +> 2. **`/accounts` (Accounts list)** — calls `listAccounts`. Header with "Pair New Account" button (links to `/accounts/new`). Each account is a Card with label + phone + status badge + "Open" link to detail. +> 3. **`/accounts/[id]` (Account detail)** — calls `getAccount`. Show label, phone, status, paired-at date, and three action links: "View Groups" (`/accounts/[id]/groups`), "Sync Groups Now" (server action to be added in Task 17), "Unpair" (server action — destructive button with confirm Dialog). +> +> Status badge component (``) is shared: colors green / amber / red / neutral. +> +> Use shadcn Card, Button, Dialog. Mobile-first, but use grid layouts on `sm:` breakpoint. + +- [ ] **Step 3: Typecheck** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +``` + +- [ ] **Step 4: Browse manually** + +Visit `http://localhost:3000/`, `http://localhost:3000/accounts`, `http://localhost:3000/accounts/`. Should render without errors and show your existing test account from the bot. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src +git -c commit.gpgsign=false commit -m "feat(web): dashboard + accounts list + account detail" +``` + +--- + +## Task 14: Groups list + group detail pages (frontend-design) + +**Files:** +- Create: `apps/web/src/app/accounts/[id]/groups/page.tsx` +- Create: `apps/web/src/app/groups/[id]/page.tsx` +- Add to: `apps/web/src/lib/queries.ts` + +- [ ] **Step 1: Add queries** to `lib/queries.ts` + +```typescript +export async function listGroupsForAccount(operatorId: string, accountId: string) { + const account = await getAccount(operatorId, accountId); + if (!account) return null; + const groups = await db.query.whatsappGroups.findMany({ + where: (g, { eq }) => eq(g.accountId, accountId), + orderBy: (g, { asc }) => [asc(g.name)], + }); + return { account, groups }; +} + +export async function getGroup(operatorId: string, groupId: string) { + const group = await db.query.whatsappGroups.findFirst({ + where: (g, { eq }) => eq(g.id, groupId), + }); + if (!group) return null; + const account = await getAccount(operatorId, group.accountId); + if (!account) return null; + return { group, account }; +} +``` + +- [ ] **Step 2: Use frontend-design to build the two pages** + +Brief: +> Two pages: +> +> 1. **`/accounts/[id]/groups`** — uses `listGroupsForAccount`. Search box at top filters group list client-side. Each group is a row with name + member count + chevron link to `/groups/[groupId]`. "Refresh" button next to header (server action stub for Task 17). +> 2. **`/groups/[id]`** — uses `getGroup`. Hero showing group name, member count, account label. Send-Test text area + "Send Test" button (server action in Task 18). Below: "Use this group in a reminder" link to `/reminders/new?groupId=...`. + +- [ ] **Step 3: Typecheck + browse** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src +git -c commit.gpgsign=false commit -m "feat(web): groups list + group detail pages" +``` + +--- + +## Task 15: Reminders list + reminder detail (frontend-design) + +**Files:** +- Create: `apps/web/src/app/reminders/page.tsx` +- Create: `apps/web/src/app/reminders/[id]/page.tsx` +- Add to: `apps/web/src/lib/queries.ts` + +- [ ] **Step 1: Add queries** (re-export the existing crud helpers from bot's `reminders/crud.ts`? No — duplicate the bare query in the web's `queries.ts` instead, since the bot's helper imports `bot/db`): + +Add to `apps/web/src/lib/queries.ts`: + +```typescript +export async function listReminders(operatorId: string) { + const rows = await db.execute(sql` + SELECT r.*, wa.label as account_label, + (SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count + FROM reminders r + JOIN whatsapp_accounts wa ON wa.id = r.account_id + WHERE wa.operator_id = ${operatorId} + ORDER BY r.scheduled_at DESC NULLS LAST, r.created_at DESC + LIMIT 100 + `); + return rows.rows as never[]; +} + +export async function getReminderWithRuns(reminderId: string) { + const reminder = await db.query.reminders.findFirst({ + where: (r, { eq }) => eq(r.id, reminderId), + }); + if (!reminder) return null; + const targets = await db.query.reminderTargets.findMany({ + where: (t, { eq }) => eq(t.reminderId, reminderId), + }); + const messages = await db.query.reminderMessages.findMany({ + where: (m, { eq }) => eq(m.reminderId, reminderId), + orderBy: (m, { asc }) => [asc(m.position)], + }); + const runs = await db.query.reminderRuns.findMany({ + where: (rr, { eq }) => eq(rr.reminderId, reminderId), + orderBy: (rr, { desc }) => [desc(rr.firedAt)], + limit: 20, + }); + return { reminder, targets, messages, runs }; +} +``` + +- [ ] **Step 2: Use frontend-design to build the two pages** + +Brief: +> Two pages for a reminder dashboard: +> +> 1. **`/reminders`** — uses `listReminders`. "New Reminder" button at top right links to `/reminders/new`. Table-style list (or cards on mobile) with columns: Name, Account, When (formatted in operator's timezone), Status (pill), Group count. Click row → detail. +> 2. **`/reminders/[id]`** — uses `getReminderWithRuns`. Hero with name + when + status. Body section showing message text or "(media)" placeholder. Targets list (group names). Run history table: fired_at, status, latency. "Delete" button (destructive, server action in Task 19). + +- [ ] **Step 3: Typecheck + commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +git add apps/web/src +git -c commit.gpgsign=false commit -m "feat(web): reminders list + detail pages" +``` + +--- + +## Task 16: Settings page (frontend-design) + +**Files:** +- Create: `apps/web/src/app/settings/page.tsx` + +- [ ] **Step 1: Use frontend-design to build the page** + +Brief: +> Settings page for a single-operator self-hosted dashboard: +> - Read-only display of operator: display name, telegram_user_id (legacy field, label "Operator ID" for clarity), default_timezone. +> - Theme toggle (light / dark / system) — wires to the existing ThemeProvider. +> - Build/version line at the bottom. +> +> No server action yet (editing comes later). Plain Server Component reading from `getSeededOperator()`. + +- [ ] **Step 2: Typecheck + commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +git add apps/web/src +git -c commit.gpgsign=false commit -m "feat(web): settings page" +``` + +--- + +# Phase E — Mutations + +## Task 17: Pair / unpair / sync-groups server actions + +**Files:** +- Create: `apps/web/src/actions/accounts.ts` + +- [ ] **Step 1: Create `apps/web/src/actions/accounts.ts`** + +```typescript +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { whatsappAccounts } from "@cmbot/db"; +import { db } from "@/lib/db"; +import { getSeededOperator } from "@/lib/operator"; +import { pgNotifyBot } from "@/lib/notify"; + +const pairSchema = z.object({ + label: z.string().min(1).max(60), +}); + +export async function pairAccountAction(_prev: unknown, formData: FormData) { + const parsed = pairSchema.safeParse({ label: formData.get("label") }); + if (!parsed.success) { + return { ok: false as const, error: parsed.error.issues[0]?.message ?? "Invalid label" }; + } + const op = await getSeededOperator(); + + // Reject if a connected account already has this label + const existing = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.operatorId, op.id), eq(a.label, parsed.data.label)), + }); + if (existing && existing.status === "connected") { + return { ok: false as const, error: `"${parsed.data.label}" is already connected. Unpair first.` }; + } + + let accountId = existing?.id; + if (!accountId) { + const [created] = await db + .insert(whatsappAccounts) + .values({ operatorId: op.id, label: parsed.data.label, status: "pending" }) + .returning({ id: whatsappAccounts.id }); + accountId = created!.id; + } + + await pgNotifyBot({ type: "account.start_pairing", accountId }); + revalidatePath("/accounts"); + redirect(`/accounts/${accountId}/pairing`); +} + +export async function unpairAccountAction(accountId: string) { + const op = await getSeededOperator(); + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), + }); + if (!account) return { ok: false as const, error: "Not found" }; + await pgNotifyBot({ type: "account.unpair", accountId }); + // Optimistic UI update — actual state flip happens via SSE when bot finishes + await db + .update(whatsappAccounts) + .set({ status: "logged_out", phoneNumber: null }) + .where(eq(whatsappAccounts.id, accountId)); + revalidatePath("/accounts"); + revalidatePath(`/accounts/${accountId}`); + return { ok: true as const }; +} + +export async function syncGroupsAction(accountId: string) { + const op = await getSeededOperator(); + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), + }); + if (!account) return { ok: false as const, error: "Not found" }; + await pgNotifyBot({ type: "account.sync_groups", accountId }); + return { ok: true as const }; +} +``` + +- [ ] **Step 2: Wire `pairAccountAction` into `/accounts/new` page** — create `apps/web/src/app/accounts/new/page.tsx` + +Brief for **frontend-design**: +> Build the "Pair New Account" form. Single text field (Label, max 60 chars), submit button "Start Pairing", cancel link to `/accounts`. On submit, the form posts to `pairAccountAction`. On error, show field error. On success, the action redirects to `/accounts/[id]/pairing`. Use shadcn Form + react-hook-form + zod with the same `pairSchema`. + +- [ ] **Step 3: Wire `unpairAccountAction` and `syncGroupsAction` into the account detail page** + +Update `/accounts/[id]/page.tsx` — add an Unpair Dialog (with confirm) that posts to `unpairAccountAction`, and a Sync button that posts to `syncGroupsAction` and shows a toast. + +- [ ] **Step 4: Build the live QR pairing page** — `apps/web/src/app/accounts/[id]/pairing/page.tsx` + +Brief for **frontend-design**: +> Build the "Pair Account — Waiting for QR" page. Server Component reads the account row by id; renders a client island that: +> 1. Shows a QR placeholder shimmer. +> 2. Subscribes via `useEvents` to `session.qr` (replace shimmer with ``), `session.connected` (show success state + redirect to `/accounts/[id]` after 3s), `session.timeout` (show "Timed out" with "Try Again" button linking to `/accounts/new`). +> 3. Includes a 30-second countdown ring that resets on each new QR. +> +> Use shadcn Skeleton, Button, Card. + +- [ ] **Step 5: Typecheck + commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +git add apps/web/src +git -c commit.gpgsign=false commit -m "feat(web): pair / unpair / sync-groups actions + live QR page" +``` + +--- + +## Task 18: Send-test action + +**Files:** +- Create: `apps/web/src/actions/groups.ts` + +- [ ] **Step 1: Create `apps/web/src/actions/groups.ts`** + +```typescript +"use server"; + +import { z } from "zod"; +import { eq, and } from "drizzle-orm"; +import { whatsappAccounts } from "@cmbot/db"; +import { db } from "@/lib/db"; +import { getSeededOperator } from "@/lib/operator"; +import { pgNotifyBot } from "@/lib/notify"; + +const sendTestSchema = z.object({ + groupId: z.string().uuid(), + text: z.string().min(1).max(4000), +}); + +export async function sendTestAction(_prev: unknown, formData: FormData) { + const parsed = sendTestSchema.safeParse({ + groupId: formData.get("groupId"), + text: formData.get("text"), + }); + if (!parsed.success) { + return { ok: false as const, error: parsed.error.issues[0]?.message ?? "Invalid input" }; + } + const op = await getSeededOperator(); + const group = await db.query.whatsappGroups.findFirst({ + where: (g, { eq }) => eq(g.id, parsed.data.groupId), + }); + if (!group) return { ok: false as const, error: "Group not found" }; + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, op.id)), + }); + if (!account) return { ok: false as const, error: "Group not yours" }; + if (account.status !== "connected") { + return { ok: false as const, error: "Account not connected" }; + } + + await pgNotifyBot({ type: "group.send_test", groupId: parsed.data.groupId, text: parsed.data.text }); + return { ok: true as const, message: `Sending to ${group.name}…` }; +} +``` + +- [ ] **Step 2: Wire into `/groups/[id]/page.tsx`** + +Already-built page (from Task 14) gains a form bound to `sendTestAction`. On success, show a toast "Sending to …". On error, show a toast with the message. + +- [ ] **Step 3: Typecheck + commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +git add apps/web/src +git -c commit.gpgsign=false commit -m "feat(web): send-test server action wired into group detail" +``` + +--- + +## Task 19: Reminder delete action + +**Files:** +- Create: `apps/web/src/actions/reminders.ts` (just the delete piece for this task — create + edit come in Phase F) + +- [ ] **Step 1: Create `apps/web/src/actions/reminders.ts`** + +```typescript +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { eq } from "drizzle-orm"; +import { reminders } from "@cmbot/db"; +import { db } from "@/lib/db"; +import { getSeededOperator } from "@/lib/operator"; +import { writeAuditLog } from "@cmbot/shared/audit"; // see note below + +export async function deleteReminderAction(reminderId: string) { + const op = await getSeededOperator(); + const reminder = await db.query.reminders.findFirst({ + where: (r, { eq }) => eq(r.id, reminderId), + }); + if (!reminder) return { ok: false as const, error: "Not found" }; + // Verify operator owns the reminder via the account's operator_id + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, op.id)), + }); + if (!account) return { ok: false as const, error: "Not yours" }; + + await db.delete(reminders).where(eq(reminders.id, reminderId)); + revalidatePath("/reminders"); + redirect("/reminders"); +} +``` + +NOTE: `@cmbot/shared/audit` doesn't exist — `writeAuditLog` lives in `apps/bot/src/audit.ts` and we don't want to share it (web → bot module). Drop the import; the audit_log row is written by the bot's IPC handler when it processes commands. For pure-DB deletes (like this one) skip audit logging in v1 — easy to add later by porting `writeAuditLog` to `packages/shared` if needed. + +Replace the import line and the (now unused) reference in the action with: just delete the reminder. The pg-boss job for that reminder will fire and find the row gone (soft cancel from plan 2). + +- [ ] **Step 2: Final action body** + +```typescript +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { eq } from "drizzle-orm"; +import { reminders } from "@cmbot/db"; +import { db } from "@/lib/db"; +import { getSeededOperator } from "@/lib/operator"; + +export async function deleteReminderAction(reminderId: string) { + const op = await getSeededOperator(); + const reminder = await db.query.reminders.findFirst({ + where: (r, { eq }) => eq(r.id, reminderId), + }); + if (!reminder) return { ok: false as const, error: "Not found" }; + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, op.id)), + }); + if (!account) return { ok: false as const, error: "Not yours" }; + + await db.delete(reminders).where(eq(reminders.id, reminderId)); + revalidatePath("/reminders"); + redirect("/reminders"); +} +``` + +- [ ] **Step 3: Wire into `/reminders/[id]/page.tsx`** — Delete button → confirm Dialog → server action. + +- [ ] **Step 4: Typecheck + commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +git add apps/web/src +git -c commit.gpgsign=false commit -m "feat(web): delete reminder action + UI" +``` + +--- + +# Phase F — Reminder wizard + +## Task 20: Reminder create action + media upload action + +**Files:** +- Modify: `apps/web/src/actions/reminders.ts` (add createReminderAction) +- Create: `apps/web/src/actions/media.ts` + +- [ ] **Step 1: Add `createReminderAction` to `apps/web/src/actions/reminders.ts`** + +```typescript +import { reminderTargets, reminderMessages } from "@cmbot/db"; +import { z } from "zod"; +import { DateTime } from "luxon"; +import PgBoss from "pg-boss"; +import { env } from "@/env"; +import { DEFAULT_TIMEZONE } from "@cmbot/shared"; + +const createReminderSchema = z.object({ + accountId: z.string().uuid(), + groupIds: z.array(z.string().uuid()).min(1), + text: z.string().nullable().optional(), + mediaId: z.string().uuid().nullable().optional(), + caption: z.string().nullable().optional(), + scheduledAtIso: z.string().datetime(), + timezone: z.string().default(DEFAULT_TIMEZONE), +}); + +export async function createReminderAction(input: z.infer) { + const op = await getSeededOperator(); + const parsed = createReminderSchema.safeParse(input); + if (!parsed.success) { + return { ok: false as const, error: parsed.error.issues[0]?.message ?? "Invalid input" }; + } + const { accountId, groupIds, text, mediaId, caption, scheduledAtIso, timezone } = parsed.data; + + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), + }); + if (!account) return { ok: false as const, error: "Account not yours" }; + + const scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate(); + if (scheduledAt.getTime() <= Date.now()) { + return { ok: false as const, error: "Time is in the past" }; + } + + const reminderId = await db.transaction(async (tx) => { + const [rem] = await tx + .insert(reminders) + .values({ + accountId, + name: (text ?? caption ?? "Reminder").slice(0, 50), + scheduleKind: "one_off", + scheduledAt, + timezone, + status: "active", + createdBy: op.id, + }) + .returning({ id: reminders.id }); + + await tx.insert(reminderTargets).values( + groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })), + ); + + if (text && !mediaId) { + await tx.insert(reminderMessages).values({ + reminderId: rem!.id, + position: 0, + kind: "text", + textContent: text, + mediaId: null, + }); + } else if (mediaId) { + await tx.insert(reminderMessages).values({ + reminderId: rem!.id, + position: 0, + kind: "media", + textContent: caption ?? text ?? null, + mediaId, + }); + } + return rem!.id; + }); + + // Schedule via pg-boss directly from web. We don't go through the bot + // because pg-boss reads its own queue table; the bot's worker picks up. + const boss = new PgBoss({ connectionString: env.DATABASE_URL, schema: "pgboss" }); + await boss.start(); + await boss.send( + "reminder.fire", + { reminderId }, + { + startAfter: scheduledAt, + retryLimit: 3, + retryDelay: 30, + retryBackoff: true, + singletonKey: `reminder:${reminderId}`, + }, + ); + await boss.stop({ graceful: false }); + + revalidatePath("/reminders"); + return { ok: true as const, reminderId }; +} +``` + +NOTE: Using `pg-boss` from web means an extra connection per create. Alternative: send a `bot.command` like `reminder.schedule` and let the bot do the pg-boss send. That's cleaner — let me change to that approach. + +- [ ] **Step 2: Switch to IPC-based scheduling** + +Replace the pg-boss block in `createReminderAction` with: + +```typescript +import { pgNotifyBot } from "@/lib/notify"; + +// (instead of pg-boss start + send + stop) +await pgNotifyBot({ type: "reminder.schedule", reminderId, scheduledAtIso }); +``` + +And add `reminder.schedule` to the `BotCommand` union in **both** `apps/web/src/lib/notify.ts` and `apps/bot/src/ipc/command-consumer.ts`. + +- [ ] **Step 3: Add a bot handler for `reminder.schedule`** + +Create `apps/bot/src/ipc/schedule-reminder-handler.ts`: + +```typescript +import { getBoss } from "../scheduler/pgboss-client.js"; +import { scheduleReminderFire } from "../scheduler/reminder-jobs.js"; + +export async function handleScheduleReminder(reminderId: string, scheduledAtIso: string): Promise { + await scheduleReminderFire(getBoss(), reminderId, new Date(scheduledAtIso)); +} +``` + +Register in `apps/bot/src/ipc/command-consumer.ts` `registerDefaultHandlers()`: + +```typescript +registerHandler("reminder.schedule", async (cmd) => { + await handleScheduleReminder(cmd.reminderId, cmd.scheduledAtIso); +}); +``` + +- [ ] **Step 4: Create `apps/web/src/actions/media.ts`** + +```typescript +"use server"; + +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { createHash } from "node:crypto"; +import { mediaFiles } from "@cmbot/db"; +import { newMediaPath, absoluteMediaPath } from "@cmbot/shared"; +import { db } from "@/lib/db"; +import { env } from "@/env"; +import { getSeededOperator } from "@/lib/operator"; + +const MAX_BYTES = 50 * 1024 * 1024; + +export async function uploadMediaAction(formData: FormData) { + const file = formData.get("file"); + if (!(file instanceof File)) return { ok: false as const, error: "No file" }; + if (file.size > MAX_BYTES) return { ok: false as const, error: "File too large (>50MB)" }; + const op = await getSeededOperator(); + + const buffer = Buffer.from(await file.arrayBuffer()); + const sha256 = createHash("sha256").update(buffer).digest("hex"); + const storagePath = newMediaPath(file.name); + const absolute = absoluteMediaPath(storagePath, env.MEDIA_DIR); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, buffer); + + const [row] = await db + .insert(mediaFiles) + .values({ + operatorId: op.id, + filenameOriginal: file.name, + mimeType: file.type || "application/octet-stream", + sizeBytes: buffer.byteLength, + sha256, + storagePath, + }) + .returning({ id: mediaFiles.id }); + + return { ok: true as const, mediaId: row!.id, filename: file.name, mimeType: file.type }; +} +``` + +- [ ] **Step 5: Typecheck + commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck +git add apps/web/src apps/bot/src +git -c commit.gpgsign=false commit -m "feat(web,bot): create-reminder + media-upload + reminder.schedule IPC" +``` + +--- + +## Task 21: Reminder wizard UI (5 steps with URL state) + +**Files:** +- Create: `apps/web/src/app/reminders/new/page.tsx` (URL-state dispatcher) +- Create: `apps/web/src/components/reminder-wizard/` (step components) + +This task uses **frontend-design:frontend-design**. + +- [ ] **Step 1: Use frontend-design with this brief** + +> Build a 5-step reminder wizard at `/reminders/new`. URL state via `?step=1..5&accountId=...&groupIds=a,b&text=...&mediaId=...&scheduledAt=...`. Each step is a Server Component that: +> +> - **Step 1 — Account.** List paired accounts as cards (radio-style). On select, navigate to `/reminders/new?step=2&accountId=...`. +> - **Step 2 — Groups.** Fetch groups for the chosen account. Multi-select with search. "Continue" button → step 3 with `&groupIds=` comma-joined. +> - **Step 3 — Compose.** Textarea for body, drag-drop file upload (calls `uploadMediaAction`), thumbnail preview with remove button. "Continue" → step 4 (preserve text + mediaId in URL). +> - **Step 4 — When.** `` defaulting to "now + 1 hour". Quick-pick chips: Now, Tomorrow 9 AM, Next Mon 9 AM. "Continue" → step 5 with `&scheduledAt=...`. +> - **Step 5 — Review.** Summary card with all chosen values + "Edit account / groups / body / time" links back to those steps. "Schedule" button posts to `createReminderAction` and redirects to `/reminders/[id]`. +> +> Visual: stepper progress at the top showing "1 — Account · 2 — Groups · 3 — Compose · 4 — When · 5 — Review" with the current step highlighted. Use shadcn Form, Card, Button, Input, Textarea, Badge. + +- [ ] **Step 2: Typecheck + commit** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck +git add apps/web/src +git -c commit.gpgsign=false commit -m "feat(web): reminder wizard (5 steps with URL state)" +``` + +--- + +# Phase G — PWA + +## Task 22: @serwist/next setup + manifest + icons + +**Files:** +- Create: `apps/web/src/app/manifest.webmanifest/route.ts` +- Create: `apps/web/src/pwa/sw.ts` +- Modify: `apps/web/next.config.ts` +- Add icon files: `apps/web/public/icon-192.png`, `apps/web/public/icon-512.png`, `apps/web/public/apple-touch-icon.png` + +- [ ] **Step 1: Install serwist** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm add @serwist/next serwist --filter @cmbot/web +``` + +- [ ] **Step 2: Create `apps/web/src/app/manifest.webmanifest/route.ts`** + +```typescript +import { NextResponse } from "next/server"; + +export function GET() { + return NextResponse.json({ + name: "cm WhatsApp Bot", + short_name: "cm WA Bot", + description: "Self-hosted WhatsApp reminder bot", + start_url: "/", + display: "standalone", + background_color: "#0a0a0a", + theme_color: "#0a0a0a", + icons: [ + { src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" }, + { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" }, + ], + }); +} +``` + +- [ ] **Step 3: Create `apps/web/src/pwa/sw.ts`** + +```typescript +import { defaultCache } from "@serwist/next/worker"; +import { Serwist } from "serwist"; + +declare const self: ServiceWorkerGlobalScope & { + __SW_MANIFEST: (string | { url: string; revision: string | null })[]; +}; + +const serwist = new Serwist({ + precacheEntries: self.__SW_MANIFEST, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching: defaultCache, +}); + +serwist.addEventListeners(); +``` + +- [ ] **Step 4: Wire serwist into `apps/web/next.config.ts`** + +Replace contents: + +```typescript +import type { NextConfig } from "next"; +import withSerwistInit from "@serwist/next"; + +const withSerwist = withSerwistInit({ + swSrc: "src/pwa/sw.ts", + swDest: "public/sw.js", + cacheOnNavigation: true, + reloadOnOnline: true, + disable: process.env.NODE_ENV !== "production", +}); + +const nextConfig: NextConfig = { + reactStrictMode: true, + output: "standalone", + transpilePackages: ["@cmbot/db", "@cmbot/shared"], + experimental: { typedRoutes: true }, +}; + +export default withSerwist(nextConfig); +``` + +- [ ] **Step 5: Generate icons** (you may use any PNG generator or design tool — for now, generate simple placeholder PNGs using imagemagick inside the tools container) + +```bash +NO_SUDO=1 scripts/dev.sh exec sh -c " +apk add --no-cache imagemagick >/dev/null 2>&1 || true +# Tools container is alpine — imagemagick may not be available. Skip if it errors. +convert -size 512x512 xc:'#0a0a0a' -fill white -gravity center -pointsize 280 -annotate 0 'cm' /workspace/apps/web/public/icon-512.png 2>/dev/null && echo 'icon-512 generated' || echo 'imagemagick unavailable; create icons manually' +convert /workspace/apps/web/public/icon-512.png -resize 192x192 /workspace/apps/web/public/icon-192.png 2>/dev/null +convert /workspace/apps/web/public/icon-512.png -resize 180x180 /workspace/apps/web/public/apple-touch-icon.png 2>/dev/null +" +``` + +Fallback if imagemagick isn't there: drop in any 512×512 and 192×192 PNG you have. Branding can come later. + +- [ ] **Step 6: Build the production bundle to verify serwist hooks correctly** + +```bash +NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web build +``` + +Expected: `[Serwist] Service worker generated at .../public/sw.js`. No errors. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web pnpm-lock.yaml +git -c commit.gpgsign=false commit -m "feat(web): PWA via @serwist/next + manifest + icons" +``` + +--- + +# Phase H — Verify + push + +## Task 23: Manual end-to-end runbook + execution + +**Files:** +- Create: `docs/superpowers/specs/manual-test-web.md` + +- [ ] **Step 1: Create `docs/superpowers/specs/manual-test-web.md`** + +```markdown +# Manual test: Web app end-to-end + +Run after every major web change. + +## Prerequisites +- `scripts/dev.sh up` is running. +- Browser open on `http://localhost:3000`. + +## Smoke +1. Visit `/` — see dashboard with stat cards. No errors in browser console. +2. Visit `/accounts` — see existing paired accounts. +3. Tap "Pair New Account" → form → enter "Web Test 1" → "Start Pairing". +4. Should redirect to `/accounts//pairing`. Within ~5s see a QR. +5. Scan with WhatsApp on a test phone. +6. See "✅ Connected as +60xxx" + auto-redirect to account detail. +7. Visit `/accounts//groups` — see groups. +8. Tap a group → see detail, send a test message → toast "Sending…". +9. WhatsApp should receive the message in ~5s. + +## Reminder +1. `/reminders` → "New Reminder". +2. Step 1 — pick account, Step 2 — pick a group, Step 3 — type "Test", Step 4 — Now, Step 5 — Schedule. +3. Within ~30s, message arrives in WhatsApp group. +4. `/reminders/` shows status `ended` and one run row in history. + +## PWA install +1. Phone Chrome → menu → Install App. +2. Open from home screen — fullscreen, no browser chrome. + +## Sign-off +- [ ] All steps passed +- [ ] No console errors +- [ ] Tester / date +``` + +- [ ] **Step 2: Run the runbook** end-to-end against your dev environment. + +- [ ] **Step 3: Commit** + +```bash +git add docs/superpowers/specs/manual-test-web.md +git -c commit.gpgsign=false commit -m "docs: manual web app end-to-end test runbook" +``` + +--- + +## Task 24: README update + push + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update `README.md`** — replace status section to reflect web-first. + +Find the "Status" section and replace with: + +```markdown +## Status + +**Plans 1, 2, and 3 complete.** Web app at `wabot.04080616.xyz` is the primary control surface. Telegram bot has been fully removed. + +What's working today: + +- Self-hosted Next.js 16 PWA (installable on phone home screen). +- Pair WhatsApp accounts with live QR shown directly in the browser. +- Browse paired accounts and their groups; send test messages. +- Schedule one-off reminders via 5-step wizard with multi-group targets and media attachments. +- Auto-reconnect on transient drops; restart-survival via Baileys session persistence. +- All actions audited; reminder run history queryable from the UI. +``` + +- [ ] **Step 2: Push to Gitea** + +```bash +git add README.md +git -c commit.gpgsign=false commit -m "docs: update README for web-first pivot" +git push origin master +``` + +Expected: push succeeds. + +--- + +## Plan 3 done — what's working + +- Telegram code fully removed; bot codebase shrunk by ~700 lines. +- Web app running at `wabot.04080616.xyz` with all daily-ops flows. +- Pair / groups / reminders / settings pages. +- Live QR pairing in-browser via SSE. +- Reminder wizard with media attachment. +- Server Actions for all mutations; no public REST API for state changes. +- PWA installable on phone. + +## Deferred + +- **Recurring reminders (RRULE)** — schema is ready; UI presets needed. +- **Standalone media library** browser. +- **Edit reminder** (only delete + recreate for now). +- **E2E browser tests** (Playwright). +- **Auth** (passkeys / email-password) — bring back if URL exposure becomes a concern.