# 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.