cm_whatsapp_bot_v1/docs/superpowers/plans/2026-05-09-web-app.md
yiekheng 4b859bc44a docs: add plan 3 — Telegram-free web app
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.
2026-05-09 22:25:43 +08:00

2487 lines
79 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 DF) 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<void> {
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<void>;
const handlers: { [K in BotCommand["type"]]?: (cmd: Extract<BotCommand, { type: K }>) => Promise<void> } = {};
export function registerHandler<T extends BotCommand["type"]>(
type: T,
fn: (cmd: Extract<BotCommand, { type: T }>) => Promise<void>,
): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(handlers as any)[type] = fn;
}
export async function startCommandConsumer(): Promise<() => Promise<void>> {
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<string, () => void>();
const lastQrPayload = new Map<string, string>();
const pairTimeouts = new Map<string, NodeJS.Timeout>();
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> => {
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<typeof envSchema>;
export function parseEnv(input: Record<string, string | undefined>): 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 (
<html lang="en" className={GeistSans.className}>
<body>{children}</body>
</html>
);
}
```
- [ ] **Step 7: Create `apps/web/src/app/page.tsx`**
```typescript
export default function Page() {
return (
<main className="p-8">
<h1 className="text-2xl font-semibold">cm WhatsApp Bot</h1>
<p className="text-muted-foreground">
Web app skeleton wired up. Real dashboard arrives in Task 13.
</p>
</main>
);
}
```
- [ ] **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<typeof envSchema>;
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<void> {
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 <ms>` 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 (
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
<body>
<ThemeProvider>
<AppShell>{children}</AppShell>
<Toaster richColors position="top-right" />
</ThemeProvider>
</body>
</html>
);
}
```
- [ ] **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<string, { count: number; windowStart: number }>();
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 (`<AccountStatusBadge status={...} />`) 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/<existing-id>`. 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 `<img src="data:image/png;base64,..." />`), `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 <group>…". 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<typeof createReminderSchema>) {
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<void> {
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.** `<input type="datetime-local">` 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/<id>/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/<id>/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/<id>` 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.