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