cm_whatsapp_bot_v1/docs/superpowers/plans/2026-05-09-web-app.md
yiekheng 24e61f4cdd docs(plan): add Task 9b — Postgres-only cache, rate-limit, search
Operational rule: cache, queue, search, and rate-limiting all use Postgres
— no Redis or external systems.

New Task 9b adds:
- pg_trgm extension + GIN trigram indexes on whatsapp_groups.name and
  reminders.name for fuzzy search
- BRIN indexes on reminder_runs.fired_at and audit_log.created_at for
  cheap time-series scans
- Common-filter B-tree indexes on reminders.status and (account_id,
  scheduled_at)
- cache_entries table + cacheGet / cacheSet / cacheGetOrSet helpers
- rate_limit_buckets table + checkRateLimit (atomic UPSERT, sliding window)
- search.ts with trigramMatch / trigramRank Drizzle SQL fragments
- Vitest unit tests for cache and rate-limit helpers

Also rewrites Task 12 (rate-limit middleware) to enforce limits inside
Server Actions where DB access exists, rather than edge middleware where
it doesn't.
2026-05-09 22:28:47 +08:00

2854 lines
91 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)
- B+ — Postgres extensions + indexes + cache/rate-limit tables (Task 9b)
- 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)
**Operational rule:** Cache, queue, search, and rate-limiting all use Postgres — no Redis, no external systems. Specifically:
- Queue: `pg-boss` (already in plan 2)
- IPC: Postgres `LISTEN/NOTIFY` (Task 1, 11)
- Cache: a Drizzle-backed `cache_entries` table with TTL + a tiny `getOrSet` helper (Task 9b)
- Rate limit: a `rate_limit_buckets` table with sliding-window UPSERT (Task 9b → consumed by Task 12)
- Search: pg_trgm extension + GIN indexes for fuzzy `name` lookup; uses `name % query` with similarity ranking (Task 9b → consumed by Tasks 14, 15)
- Time-series scans (`reminder_runs.fired_at`, `audit_log.created_at`): BRIN indexes for cheap append-mostly scanning
---
## 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 B+ — Postgres optimization (cache / rate-limit / search)
## Task 9b: pg_trgm + indexes + cache/rate-limit tables + helpers
**Files:**
- Modify: `packages/db/src/schema.ts` (add `cacheEntries`, `rateLimitBuckets` tables)
- Generate: `packages/db/migrations/0002_*.sql` (extension + indexes + tables)
- Create: `apps/web/src/lib/cache.ts`
- Create: `apps/web/src/lib/rate-limit.ts`
- Create: `apps/web/src/lib/search.ts`
- Create: `apps/web/src/lib/cache.test.ts`
- Create: `apps/web/src/lib/rate-limit.test.ts`
### Step 1: Add tables to `packages/db/src/schema.ts`
Append at the end of the file:
```typescript
export const cacheEntries = pgTable("cache_entries", {
key: text("key").primaryKey(),
value: jsonb("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
});
export const rateLimitBuckets = pgTable("rate_limit_buckets", {
key: text("key").primaryKey(),
windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
count: integer("count").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
});
```
### Step 2: Generate the migration
```bash
NO_SUDO=1 scripts/db.sh generate
```
This creates `packages/db/migrations/0002_*.sql` with the two new tables.
### Step 3: Edit the generated migration to add the extension + indexes
Open the new `0002_*.sql` and add at the **top**:
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
```
And at the **bottom** (after the auto-generated CREATE TABLE statements):
```sql
-- Trigram fuzzy search indexes (pg_trgm)
CREATE INDEX IF NOT EXISTS whatsapp_groups_name_trgm
ON whatsapp_groups USING gin (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS reminders_name_trgm
ON reminders USING gin (name gin_trgm_ops);
-- Common-filter B-tree indexes
CREATE INDEX IF NOT EXISTS reminders_status_idx
ON reminders (status);
CREATE INDEX IF NOT EXISTS reminders_account_scheduled_idx
ON reminders (account_id, scheduled_at DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS reminder_runs_reminder_fired_idx
ON reminder_runs (reminder_id, fired_at DESC);
-- BRIN indexes for append-mostly time-series columns
CREATE INDEX IF NOT EXISTS reminder_runs_fired_at_brin
ON reminder_runs USING brin (fired_at);
CREATE INDEX IF NOT EXISTS audit_log_created_at_brin
ON audit_log USING brin (created_at);
-- Expiry indexes for the new utility tables
CREATE INDEX IF NOT EXISTS cache_entries_expires_idx
ON cache_entries (expires_at);
CREATE INDEX IF NOT EXISTS rate_limit_buckets_expires_idx
ON rate_limit_buckets (expires_at);
```
### Step 4: Apply the migration
```bash
NO_SUDO=1 scripts/db.sh migrate
```
Expected: "Migrations applied." Verify the extension:
```bash
NO_SUDO=1 scripts/dev.sh exec sh -c 'cd packages/db && pnpm exec tsx -e "
(async () => {
const { Pool } = await import(\"pg\");
const p = new Pool({ connectionString: process.env.DATABASE_URL });
const r = await p.query(\"SELECT extname FROM pg_extension WHERE extname=\\\$1\", [\"pg_trgm\"]);
console.log(\"pg_trgm:\", r.rows);
await p.end();
})();
"'
```
Expected: `pg_trgm: [ { extname: 'pg_trgm' } ]`.
### Step 5: Write failing test for the cache helper
Create `apps/web/src/lib/cache.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from "vitest";
import { cacheGet, cacheSet, cacheGetOrSet, cacheDelete } from "./cache.js";
import { db } from "./db.js";
import { cacheEntries } from "@cmbot/db";
import { sql } from "drizzle-orm";
describe("cache helpers", () => {
beforeEach(async () => {
await db.delete(cacheEntries).where(sql`true`);
});
it("set + get round-trip with TTL", async () => {
await cacheSet("k1", { hello: "world" }, 60);
const v = await cacheGet<{ hello: string }>("k1");
expect(v).toEqual({ hello: "world" });
});
it("getOrSet computes once, then returns cached", async () => {
let calls = 0;
const compute = async () => {
calls++;
return { stamp: 42 };
};
const a = await cacheGetOrSet("k2", 60, compute);
const b = await cacheGetOrSet("k2", 60, compute);
expect(a).toEqual({ stamp: 42 });
expect(b).toEqual({ stamp: 42 });
expect(calls).toBe(1);
});
it("expired entries are skipped", async () => {
await cacheSet("k3", { stale: true }, -1); // already expired
const v = await cacheGet("k3");
expect(v).toBeNull();
});
it("delete removes the entry", async () => {
await cacheSet("k4", { x: 1 }, 60);
await cacheDelete("k4");
expect(await cacheGet("k4")).toBeNull();
});
});
```
### Step 6: Run failing test
```bash
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web test
```
Expected: 4 tests fail (`./cache.js` not found).
### Step 7: Implement `apps/web/src/lib/cache.ts`
```typescript
import "server-only";
import { sql, eq } from "drizzle-orm";
import { cacheEntries } from "@cmbot/db";
import { db } from "./db";
export async function cacheGet<T>(key: string): Promise<T | null> {
const row = await db.query.cacheEntries.findFirst({
where: (c, { eq, and, gt }) => and(eq(c.key, key), gt(c.expiresAt, new Date())),
});
return (row?.value ?? null) as T | null;
}
export async function cacheSet(key: string, value: unknown, ttlSeconds: number): Promise<void> {
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
// UPSERT — atomic single-row write, no race
await db
.insert(cacheEntries)
.values({ key, value: value as never, expiresAt })
.onConflictDoUpdate({
target: cacheEntries.key,
set: { value: value as never, expiresAt },
});
}
export async function cacheDelete(key: string): Promise<void> {
await db.delete(cacheEntries).where(eq(cacheEntries.key, key));
}
export async function cacheGetOrSet<T>(
key: string,
ttlSeconds: number,
compute: () => Promise<T>,
): Promise<T> {
const hit = await cacheGet<T>(key);
if (hit !== null) return hit;
const fresh = await compute();
await cacheSet(key, fresh, ttlSeconds);
return fresh;
}
/** Lazy expiry sweeper — call periodically (e.g. on bot startup). */
export async function cacheSweep(): Promise<{ removed: number }> {
const r = await db.execute(sql`DELETE FROM cache_entries WHERE expires_at < now() RETURNING key`);
return { removed: r.rowCount ?? 0 };
}
```
### Step 8: Run cache tests (expect pass)
```bash
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web test -- cache
```
Expected: 4 tests pass.
### Step 9: Write failing test for the rate-limit helper
Create `apps/web/src/lib/rate-limit.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from "vitest";
import { checkRateLimit } from "./rate-limit.js";
import { db } from "./db.js";
import { rateLimitBuckets } from "@cmbot/db";
import { sql } from "drizzle-orm";
describe("checkRateLimit", () => {
beforeEach(async () => {
await db.delete(rateLimitBuckets).where(sql`true`);
});
it("allows requests under the limit", async () => {
for (let i = 0; i < 5; i++) {
const r = await checkRateLimit("test:1", { max: 5, windowSec: 10 });
expect(r.limited).toBe(false);
}
});
it("blocks the (max+1)th request within the window", async () => {
for (let i = 0; i < 3; i++) {
await checkRateLimit("test:2", { max: 3, windowSec: 10 });
}
const r = await checkRateLimit("test:2", { max: 3, windowSec: 10 });
expect(r.limited).toBe(true);
expect(r.count).toBeGreaterThan(3);
});
it("isolates buckets by key", async () => {
await checkRateLimit("a", { max: 1, windowSec: 10 });
await checkRateLimit("a", { max: 1, windowSec: 10 });
const aLimited = await checkRateLimit("a", { max: 1, windowSec: 10 });
const bFresh = await checkRateLimit("b", { max: 1, windowSec: 10 });
expect(aLimited.limited).toBe(true);
expect(bFresh.limited).toBe(false);
});
});
```
### Step 10: Run failing test
```bash
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web test -- rate-limit
```
### Step 11: Implement `apps/web/src/lib/rate-limit.ts`
```typescript
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "./db";
export type RateLimitOptions = { max: number; windowSec: number };
export type RateLimitResult = { limited: boolean; count: number };
/**
* Sliding-window rate limit using a single atomic Postgres UPSERT.
*
* Returns `{ limited: count > max, count }`. The UPSERT resets the
* window when the existing row is older than `windowSec`, otherwise
* increments the count in place.
*/
export async function checkRateLimit(
key: string,
opts: RateLimitOptions,
): Promise<RateLimitResult> {
const { windowSec } = opts;
const result = await db.execute(sql`
INSERT INTO rate_limit_buckets (key, window_start, count, expires_at)
VALUES (${key}, now(), 1, now() + (${windowSec} * interval '1 second'))
ON CONFLICT (key) DO UPDATE
SET count = CASE
WHEN rate_limit_buckets.window_start < now() - (${windowSec} * interval '1 second')
THEN 1
ELSE rate_limit_buckets.count + 1
END,
window_start = CASE
WHEN rate_limit_buckets.window_start < now() - (${windowSec} * interval '1 second')
THEN now()
ELSE rate_limit_buckets.window_start
END,
expires_at = now() + (${windowSec} * interval '1 second')
RETURNING count;
`);
const count = Number((result.rows[0] as { count: number }).count);
return { limited: count > opts.max, count };
}
export async function rateLimitSweep(): Promise<{ removed: number }> {
const r = await db.execute(sql`DELETE FROM rate_limit_buckets WHERE expires_at < now()`);
return { removed: r.rowCount ?? 0 };
}
```
### Step 12: Run rate-limit tests (expect pass)
```bash
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web test -- rate-limit
```
Expected: 3 tests pass.
### Step 13: Implement `apps/web/src/lib/search.ts` (no test — thin wrapper)
```typescript
import "server-only";
import { sql, type SQL } from "drizzle-orm";
/**
* Build a Drizzle WHERE fragment that fuzzy-matches `column` against `query`
* using pg_trgm's similarity operator. Returns true (no filter) when query is
* empty so callers can compose unconditionally.
*
* Caller must ensure a `gin_trgm_ops` index exists on the column.
*/
export function trigramMatch(column: SQL, query: string | null | undefined): SQL {
const q = (query ?? "").trim();
if (!q) return sql`true`;
// `%` is the trigram similarity operator. Threshold is GUC-controlled
// (`pg_trgm.similarity_threshold`, default 0.3).
return sql`${column} % ${q}`;
}
/** Order-by fragment that ranks rows by similarity descending. */
export function trigramRank(column: SQL, query: string | null | undefined): SQL {
const q = (query ?? "").trim();
if (!q) return sql`1`;
return sql`similarity(${column}, ${q}) DESC`;
}
```
### Step 14: Run all web tests + commit
```bash
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web test
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck
git add packages/db apps/web/src/lib pnpm-lock.yaml
git -c commit.gpgsign=false commit -m "feat(db,web): pg_trgm + indexes + Postgres-backed cache and rate-limit"
```
Subsequent tasks rely on these helpers:
- **Task 12 (rate-limit middleware)** — uses `checkRateLimit('ip:' + ip, { max: 30, windowSec: 10 })` instead of the in-memory Map shown earlier.
- **Task 14 (groups list)** — query uses `trigramMatch(whatsappGroups.name, query)` for fuzzy filter.
- **Task 15 (reminders list)** — same pattern for `reminders.name`.
---
# 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`**
NOTE: Edge runtime middleware can't connect directly to Postgres (no `pg` driver). The path-blocking still happens here at the edge, but the rate-limit check is delegated to a tiny **Node-runtime** route handler that does the DB hit. Middleware sets a header so the handler knows which IP to bill. Most requests don't even reach the rate-limit check because they're page navigations, not API calls.
```typescript
import { NextRequest, NextResponse } from "next/server";
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 });
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"],
};
```
NOTE: rate limiting is enforced **inside Server Actions and route handlers** (Node runtime, has DB access) using the `checkRateLimit` helper from Task 9b. Each Server Action begins with:
```typescript
import { checkRateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";
async function rateLimit() {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const r = await checkRateLimit(`ip:${ip}`, { max: 30, windowSec: 10 });
if (r.limited) throw new Error("Too many requests");
}
```
Tasks 17 onward call `await rateLimit()` as the first line of every server action. This is more reliable than edge-side limiting and uses Postgres as the source of truth (no Redis).
- [ ] **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.