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.
79 KiB
Web App (Telegram-Free Pivot) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (
- [ ]) syntax for tracking. UI component implementation tasks (Phases D–F) should be executed with assistance from the frontend-design:frontend-design skill — invoke it when a task says "use frontend-design".
Goal: Replace the Telegram bot with a Next.js PWA at wabot.04080616.xyz. Operator installs the web app on their phone and uses it for everything: pairing accounts (live QR in browser), browsing groups, sending test messages, scheduling reminders. Telegram code is fully removed.
Architecture: apps/bot shrinks (no more grammy/menus/wizard); a new ipc/command-consumer.ts listens to Postgres LISTEN bot.command and dispatches to existing Baileys/sender/sync logic. New apps/web is a Next.js 16 App Router app with Server Components for reads, Server Actions for mutations, an SSE endpoint for live updates, and @serwist/next for PWA.
Tech Stack: Next.js 16, React 19, TypeScript, Tailwind CSS v4, shadcn/ui, react-hook-form + zod, Drizzle, pg (for LISTEN), @serwist/next, Geist font, Sonner toast.
Spec reference: docs/superpowers/specs/2026-05-09-web-app-design.md
Phase guide:
- A — Telegram removal (Tasks 1-4)
- B — Web app skeleton (Tasks 5-9)
- C — Foundation: layout, SSE, middleware (Tasks 10-12)
- D — Read-only pages (Tasks 13-16)
- E — Mutations: pair, unpair, send-test (Tasks 17-19)
- F — Reminder wizard (Tasks 20-21)
- G — PWA (Task 22)
- H — Verify + push (Tasks 23-24)
File structure produced by this plan
apps/
├── bot/
│ └── src/
│ ├── ipc/
│ │ ├── notify.ts (NEW) typed pgNotify wrapper
│ │ ├── command-consumer.ts (NEW) replaces telegram/bot.ts
│ │ ├── pair-handler.ts (NEW) pair flow without Telegram
│ │ ├── unpair-handler.ts (NEW)
│ │ ├── send-test-handler.ts (NEW)
│ │ └── sync-groups-handler.ts (NEW)
│ ├── telegram/ (DELETED — entire directory)
│ └── index.ts (MODIFIED)
│
└── web/ (NEW)
├── package.json
├── tsconfig.json
├── next.config.ts
├── tailwind.config.ts
├── postcss.config.mjs
├── components.json shadcn-ui config
├── public/
│ ├── icon-192.png
│ ├── icon-512.png
│ └── apple-touch-icon.png
└── src/
├── env.ts zod env validation
├── middleware.ts rate limit + 404 for /api/* except events
├── lib/
│ ├── db.ts server-only Drizzle client
│ ├── operator.ts getSeededOperator() helper
│ ├── notify.ts pgNotify('bot.command', ...) helper
│ └── logger.ts pino on server
├── hooks/
│ └── use-events.ts SSE hook
├── actions/ Server Actions
│ ├── accounts.ts pair, unpair, sync-groups
│ ├── groups.ts send-test
│ ├── reminders.ts create, delete, edit
│ └── media.ts upload
├── components/
│ ├── app-shell.tsx responsive layout (bottom nav / sidebar)
│ ├── theme-provider.tsx
│ ├── ui/ shadcn components installed here
│ └── (feature-specific components)
├── app/
│ ├── layout.tsx root layout, theme provider, app shell
│ ├── page.tsx dashboard
│ ├── accounts/
│ │ ├── page.tsx list
│ │ ├── new/page.tsx pair (live QR)
│ │ └── [id]/
│ │ ├── page.tsx detail
│ │ ├── pairing/page.tsx (live QR sub-route)
│ │ └── groups/page.tsx
│ ├── groups/[id]/page.tsx detail + send-test
│ ├── reminders/
│ │ ├── page.tsx list
│ │ ├── new/page.tsx wizard (URL-state, ?step=1..5)
│ │ └── [id]/page.tsx detail + history + delete
│ ├── settings/page.tsx
│ ├── manifest.webmanifest/route.ts (PWA)
│ └── api/
│ ├── events/route.ts SSE
│ └── health/route.ts
└── pwa/
└── sw.ts serwist service worker entry
docker/web.Dockerfile (REPLACED — currently a placeholder)
docker-compose.base.yml (MODIFIED — add web service)
docker-compose.dev.yml (MODIFIED — add web overrides)
docs/superpowers/specs/manual-test-web.md (NEW manual runbook)
Phase A — Telegram removal
Task 1: Add IPC notify helper + command consumer skeleton in bot
Files:
-
Create:
apps/bot/src/ipc/notify.ts -
Create:
apps/bot/src/ipc/command-consumer.ts(skeleton) -
Step 1: Create
apps/bot/src/ipc/notify.ts
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)
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
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck
Expected: no errors.
- Step 4: Commit
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
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
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
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
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:
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:
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
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/bot typecheck
- Step 7: Commit
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.tswith
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
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(removegrammy) -
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
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— removeTELEGRAM_BOT_TOKEN,TELEGRAM_OPERATOR_WHITELIST,TELEGRAM_QR_CHAT_ID
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.tsto remove deleted keys from the valid fixture
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
grammyfromapps/bot/package.jsondeps
Edit apps/bot/package.json, delete the "grammy": "^1.31.0", line.
- Step 5: Run install to update lockfile
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_NAMEfrom bothenvs/.env.exampleand.env.development. KeepWEB_PORTandAUTH_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):
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 theTELEGRAM_*,SEED_*env entries from thetoolsservice environment block. Keep DATABASE_URL etc. -
Step 8: Update
docker-compose.dev.ymlfor the bot service — removeTELEGRAM_BOT_TOKEN,TELEGRAM_OPERATOR_WHITELIST,TELEGRAM_QR_CHAT_IDfrom thebotservice'senvironment:block. -
Step 9: Typecheck and run tests
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
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
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
{
"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
{
"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
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
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
- Step 5: Create
apps/web/src/app/globals.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
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
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
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
geistdependency
NO_SUDO=1 scripts/dev.sh pnpm add geist --filter @cmbot/web
- Step 10: Install workspace deps
NO_SUDO=1 scripts/dev.sh pnpm install
Expected: next, react, tailwindcss etc. all resolved.
- Step 11: Commit
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
{
"$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
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
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
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck
- Step 5: Commit
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
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
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
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
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-onlypackage
NO_SUDO=1 scripts/dev.sh pnpm add server-only --filter @cmbot/web
- Step 6: Typecheck
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck
- Step 7: Commit
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.Dockerfilewith the multi-stage build
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
webservice todocker-compose.base.yml(insert beforenetworks:block)
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
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
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
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
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
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.tsxandtheme-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
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
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck
- Step 4: Commit
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
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
"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
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck
- Step 4: Verify the SSE endpoint streams hello + ping
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
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
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
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
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:
/(Dashboard) — callsgetDashboardStats. Render 3 stat cards (Accounts connected / Active reminders / Recent runs) and a Recent Activity table (last 10 reminder runs with status pills)./accounts(Accounts list) — callslistAccounts. Header with "Pair New Account" button (links to/accounts/new). Each account is a Card with label + phone + status badge + "Open" link to detail./accounts/[id](Account detail) — callsgetAccount. 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
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
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
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:
/accounts/[id]/groups— useslistGroupsForAccount. 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)./groups/[id]— usesgetGroup. 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
NO_SUDO=1 scripts/dev.sh pnpm --filter @cmbot/web typecheck
- Step 4: Commit
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'squeries.tsinstead, since the bot's helper importsbot/db):
Add to apps/web/src/lib/queries.ts:
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:
/reminders— useslistReminders. "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./reminders/[id]— usesgetReminderWithRuns. 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
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
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
"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
pairAccountActioninto/accounts/newpage — createapps/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 topairAccountAction. On error, show field error. On success, the action redirects to/accounts/[id]/pairing. Use shadcn Form + react-hook-form + zod with the samepairSchema.
- Step 3: Wire
unpairAccountActionandsyncGroupsActioninto 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:
- Shows a QR placeholder shimmer.
- Subscribes via
useEventstosession.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).- Includes a 30-second countdown ring that resets on each new QR.
Use shadcn Skeleton, Button, Card.
- Step 5: Typecheck + commit
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
"use server";
import { z } from "zod";
import { eq, and } from "drizzle-orm";
import { whatsappAccounts } from "@cmbot/db";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { pgNotifyBot } from "@/lib/notify";
const sendTestSchema = z.object({
groupId: z.string().uuid(),
text: z.string().min(1).max(4000),
});
export async function sendTestAction(_prev: unknown, formData: FormData) {
const parsed = sendTestSchema.safeParse({
groupId: formData.get("groupId"),
text: formData.get("text"),
});
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
const op = await getSeededOperator();
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, parsed.data.groupId),
});
if (!group) return { ok: false as const, error: "Group not found" };
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, op.id)),
});
if (!account) return { ok: false as const, error: "Group not yours" };
if (account.status !== "connected") {
return { ok: false as const, error: "Account not connected" };
}
await pgNotifyBot({ type: "group.send_test", groupId: parsed.data.groupId, text: parsed.data.text });
return { ok: true as const, message: `Sending to ${group.name}…` };
}
- Step 2: Wire into
/groups/[id]/page.tsx
Already-built page (from Task 14) gains a form bound to sendTestAction. On success, show a toast "Sending to …". On error, show a toast with the message.
- Step 3: Typecheck + commit
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
"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
"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
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
createReminderActiontoapps/web/src/actions/reminders.ts
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:
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:
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():
registerHandler("reminder.schedule", async (cmd) => {
await handleScheduleReminder(cmd.reminderId, cmd.scheduledAtIso);
});
- Step 4: Create
apps/web/src/actions/media.ts
"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
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
createReminderActionand 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
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
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
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
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:
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)
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
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
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
# 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
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:
## 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
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.xyzwith 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.