After live-testing the Telegram bot we hit limits that don't go away with more menu polish (Markdown fragility, callback_data limits, no native date pickers, awkward media UX). Pivot to a Next.js PWA installable on the operator's phone; remove Telegram entirely. Spec covers: service topology with bot codebase shrunk, no-auth access stance with rate limiting + reverse-proxy gating, Server Actions replacing public REST mutation endpoints, SSE for live updates, the new web-side pair flow with live QR display, multi-step reminder wizard backed by URL state, mobile-first shadcn/ui visual layer, PWA service worker via @serwist/next, and a step-by-step plan to delete the existing Telegram code first. Inherits all confirmed values from the 2026-05-03 master spec.
17 KiB
Web App Design — Telegram-Free Pivot
Status: Draft
Date: 2026-05-09
Supersedes: Sections of 2026-05-03-whatsapp-bot-design.md that describe Telegram as the primary control surface.
1. Why this exists
After live-testing the Telegram bot we hit limits that don't go away with more menu polish:
- Markdown parsing is fragile; user content breaks rendering.
- callback_data is capped at 64 bytes; complex flows need stateful workarounds.
- No native date/time picker; we rebuilt a year/month/day grid by hand.
- Media UX (uploading photos, previewing video) is awkward in chat.
- Keyboard navigation through deep menus is slow for daily use.
The operator wants to install the controls as a Progressive Web App on his phone so they look and feel native. This document describes the migration: the Telegram bot is removed entirely and replaced by a Next.js PWA.
2. Stakeholders & access
- Operator (brother): sole end-user. Uses the PWA daily.
- Developer (you): builds, deploys, occasionally debugs.
- No login. The web app is reachable only at
https://wabot.04080616.xyz. Whoever resolves that hostname and reaches port 443 has full control. Single seededoperatorsrow in Postgres represents the brother for audit purposes; the app does not authenticate the request — it trusts the network perimeter (aaPanel reverse proxy + HTTPS).
This is an explicit trade-off. Risk: a leaked URL = full access. Mitigation: rotate by changing the subdomain. Defense in depth via rate limiting + strict referer checks below.
3. Tech stack
- Next.js 16 (App Router), TypeScript end-to-end.
- Tailwind CSS v4 + shadcn/ui components (latest registry).
- Geist font via
next/font. - react-hook-form + zod for forms (same zod schemas validated client-side and re-validated in server actions).
@serwist/nextfor PWA service worker.- Drizzle ORM (already in
packages/db). pgforLISTENin the SSE endpoint.
No new database tables; all reads/writes hit the existing schema from 2026-05-03-whatsapp-bot-design.md §9.
4. Service topology
┌─────────────────────────────────────────────────────┐
│ Home Docker server │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ web │ │ bot │ │
│ │ Next.js 16 │◄───────►│ Node.js │ │
│ │ PWA + UI │ via │ Baileys │ │
│ │ Server │ Postgres│ pg-boss │ │
│ │ Components │ (LISTEN/│ sender │ │
│ │ + Server │ NOTIFY)│ ipc │ │
│ │ Actions │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ │ shared volume: │ /data/sessions │
│ │ /data/media │ │
│ │ │ │
│ └────────────┬───────────┘ │
│ │ │
│ ▼ │
│ aaPanel reverse proxy ─► wabot.04080616.xyz │
└─────────────────────────────────────────────────────┘
│
▼
Postgres at 192.168.0.210
Container responsibilities (post-pivot)
| Container | Role |
|---|---|
web |
All UI (Server Components for reads; Server Actions for mutations); SSE endpoint for live events; PWA service worker; QR PNG rendering for pair flow. |
bot |
Baileys WhatsApp sessions; pg-boss scheduler; fire-reminder; sender; group sync; IPC consumer that listens to bot.command Postgres notifications and dispatches to the right module. No more Telegram code. |
Web ↔ bot channel
Same as before — Postgres LISTEN/NOTIFY. Web writes a row + pgNotify('bot.command', ...); bot consumes; bot writes results back + pgNotify('web.event', ...); web's SSE endpoint relays to the open browser.
5. Access stance (no auth)
- No login screen, no session cookies, no CSRF tokens (server actions handle their own).
- aaPanel is the only ingress. Bot's
:8081health port is unreachable from outside. - Rate limit at Next.js middleware: 30 requests / 10 sec per source IP. Anything more = 429.
- Origin/Referer check on Server Actions: Next.js 16 enforces this by default for actions; we leave it on.
- Rotate the subdomain if the URL ever leaks.
- Audit log is still written for every action, with
operator_idset to the single seeded operator row.
6. Routes
/ Dashboard (overview cards)
/accounts Accounts list
/accounts/new Pair new account (live QR)
/accounts/[id] Account detail
/accounts/[id]/groups Groups list (paginated/searchable)
/accounts/[id]/pairing Live pair flow page (QR + status)
/groups/[id] Group detail + send test
/reminders Reminders list
/reminders/new Reminder wizard (?step=1..5)
/reminders/[id] Reminder detail (history, edit, delete)
/settings Profile (display name, default timezone)
# Server-side only — no public REST API for mutations:
GET /api/events Single SSE stream (read-only, public-safe)
All other /api/* paths return 404 (configured at aaPanel and as middleware match-all).
Why no REST API for mutations
- Server Actions in Next.js 16 are first-class. They post to the page's own URL with an encrypted action ID, run on the server, return a serializable result, and integrate with
revalidatePath/revalidateTagautomatically. - The browser never sees
/api/remindersetc. as discoverable URLs. - Type-safe end-to-end: the server action's return type flows through to the calling component without manual fetch boilerplate.
7. Live updates (SSE)
GET /api/events streams Server-Sent Events. The handler:
- Connects to Postgres with a dedicated client (
LISTEN web.event). - Forwards each notification's payload to the client as an SSE message:
event: session.qr
data: {"accountId":"...", "qrPng":"<base64>"}
event: session.connected
data: {"accountId":"...", "phoneNumber":"+60..."}
event: groups.synced
data: {"accountId":"...", "count":12}
event: reminder.fired
data: {"reminderId":"...", "runId":"...", "status":"success"}
event: reminder.failed
data: {"reminderId":"...", "error":"..."}
event: session.disconnected
data: {"accountId":"..."}
- On disconnect, releases the PG client.
Client side: a single useEvents() hook opens the stream once at app mount. Each event triggers queryClient.invalidateQueries for the relevant key — the React Query cache stays fresh without polling.
8. Pair flow (replaces Telegram QR delivery)
Operator on /accounts → tap "Pair New Account" → /accounts/new
Form: { label: string }
Submit → server action: pairAccountAction(label)
├─ Insert whatsapp_accounts row { status:'pending', label }
└─ pgNotify('bot.command', { type:'account.start_pairing', accountId })
Server action returns { accountId } and the page redirects to /accounts/[id]/pairing
/accounts/[id]/pairing (server component renders shell + client island for SSE)
Shows label, account ID, "Waiting for QR…" shimmer
Client component subscribes to SSE
↓
Bot's IPC consumer picks up notification:
sessionManager.start(accountId)
Listens for Baileys events:
qr → render PNG (base64) → pgNotify('web.event', { type:'session.qr', qrPng })
open → update DB + sync groups → pgNotify('session.connected') + 'groups.synced'
close (loggedOut) → pgNotify('session.timeout')
↓
Browser:
On 'session.qr' — replace shimmer with <img src="data:image/png;base64,..."> + 30s countdown ring
On 'session.connected' — show ✅ Connected as +60xxx + auto-redirect to /accounts/[id] after 3s
On 'session.timeout' or 5-min server timer — show "Pairing timed out" + "Try again" button
The 5-min server-side timeout from plan 2 stays (in bot). On timeout the bot deletes the pending row and pgNotifies session.timeout.
9. Reminder wizard (replaces Telegram menu)
/reminders/new is one page that uses URL search params for state (?step=N&...). Five steps, each rendered server-side, with a server action per step that validates and redirects to the next step's URL.
| Step | Inputs | Notes |
|---|---|---|
| 1 — Account | radio list of paired accounts | shown as cards: label, phone, last connected status |
| 2 — Groups | checkbox list with search | multi-target — gain over plan 2's single-group constraint |
| 3 — Compose | textarea + file upload | drag-drop on desktop, native picker on mobile; file uploads go to /data/media via server action uploadMediaAction |
| 4 — When | <input type="datetime-local"> + quick-pick chips |
native iOS/Android datetime picker; chips for Now / Tomorrow 9 AM / Next Mon 9 AM |
| 5 — Review | rendered summary + [Schedule] | server action createReminderAction writes DB + schedules pg-boss job |
Edit-on-the-fly: each step has a "← Edit account / groups / body / time" link that navigates back to that step with the data preserved in URL.
URL-state is sufficient for v1 — small enough to fit in a query string. If we ever need to support multi-MB body content (drafts), we move to a reminder_drafts table.
10. Visual & layout
- Mobile-first. Tailwind breakpoints:
sm:and up = "desktop layout"; below = single-column with comfortable tap targets (≥44px). - shadcn/ui components throughout. Latest registry: Sidebar, Dialog, Form, DataTable, Sonner (toast), Sheet (mobile drawer), Tabs, Skeleton, Card.
- Light + dark mode auto-follows system; manual toggle in
/settings. - Spacing rhythm: 4 / 8 / 16 / 24 / 32 px.
- Typography: Geist (default).
- Status colors: green (connected/success), amber (pending/disconnected), red (banned/failed), neutral (ended).
- Production-grade visual layer is delegated to the
frontend-design:frontend-designskill during implementation — it handles spacing, hierarchy, and feel.
Layout shape
| Viewport | Shell |
|---|---|
| Mobile (<640px) | Top app bar (title + back) + bottom nav (Dashboard / Accounts / Reminders / Settings). Sheets for filters, dialogs for confirms. |
| Desktop (≥640px) | Left sidebar (collapsible) with same nav items + secondary nav for "New Account" / "New Reminder". Main content area with breadcrumbs at top. |
11. PWA
app/manifest.webmanifest— name, short name, theme color, 192px + 512px icons,display: standalone,start_url: /,background_color.- Service worker via
@serwist/next(Workbox successor designed for App Router):- Cache app shell (HTML for navigation routes, CSS, JS, fonts) — instant launch after first visit.
- Network-first for data routes (so live data still wins).
- Static assets cache-first.
- Offline fallback page rendered if no network.
- iOS install via
apple-mobile-web-app-capable+apple-touch-iconmeta tags. - "Install on home screen" prompt rendered on the dashboard if
beforeinstallpromptfires.
12. Telegram removal (must happen first in implementation)
The plan-3 implementation starts with deleting Telegram-related code so the bot container builds clean afterward.
Files / modules deleted
apps/bot/src/telegram/— entire directory (bot.ts, callbacks.ts, menus.ts, state.ts, commands/, middleware/)apps/bot/src/media/ingest.tsTelegram-side download (replaced by web upload action)- Telegram-specific tests in
apps/bot/src/telegram/**/*.test.ts
Files modified
apps/bot/src/index.ts: drop createTelegramBot / tg.start / shutdown.tg.stop. Replace withstartCommandConsumer(boss)from a newapps/bot/src/ipc/command-consumer.ts.apps/bot/package.json: removegrammy, keepqrcode(still needed for QR PNG rendering, but moves usage — see below).apps/bot/src/whatsapp/qr-renderer.tsstays (called from the new IPC consumer's pair-handler).
New modules
apps/bot/src/ipc/command-consumer.ts— subscribes to PostgresLISTEN bot.command, dispatches:account.start_pairing→ starts Baileys session, wires QR/open/close events topgNotify('web.event', …)account.unpair→ existing unpair logicaccount.sync_groups→ group syncgroup.send_test→ existing send-test
apps/bot/src/ipc/notify.ts— typedpgNotify(event, payload)helper.
Env keys removed
TELEGRAM_BOT_TOKENTELEGRAM_OPERATOR_WHITELISTTELEGRAM_QR_CHAT_ID
SEED_OPERATOR_TELEGRAM_ID still exists for backwards-compat with the seed script but the value loses its meaning; we keep the seeded operators row for audit log foreign keys.
Cleanup tests
- All vitest tests under
apps/bot/src/telegram/deleted along with the source. - New tests: IPC consumer dispatch tests (mocked PG client), web's pair-flow server action tests (against a real test DB).
13. Error handling
| Failure | Detection | Response |
|---|---|---|
| WA send transient | sender throws | pg-boss retries 3× with backoff (already in plan 2). On final failure, reminder_run_targets row gets status='failed'. SSE pushes reminder.failed → toast in UI. |
| WA session lost | Baileys close event | account row → disconnected. SSE pushes session.disconnected → status badge in /accounts goes amber. Auto-reconnect after 5 sec. |
| Pair timeout | bot's 5-min timer | Account row deleted. SSE pushes session.timeout → page navigates to "try again" view. |
| Server action validation | zod parse fails | Returns { ok:false, errors: { field: msg } }. Form re-renders with field-level errors. |
| Postgres unavailable | drizzle throws | Both containers log error, restart via Docker. UI shows a banner "Reconnecting…" if the SSE channel drops. |
| Media upload exceeds limit (50MB) | server action rejects | Returns error; UI shows "File too large". |
| SSE channel drops | EventSource fires error |
Client reconnects with exponential backoff (built into EventSource). |
14. Observability
- Logs: pino JSON to stdout, captured by Docker (unchanged).
- Health endpoints:
- Web:
GET /api/health— DB ping + commit SHA + uptime. - Bot: internal
:8081/health— DB ping + per-WA-session counts.
- Web:
- Per-reminder audit trail stays in DB.
- Sentry hookup deferred (out of scope for this design).
15. Build, deploy, and dev experience
- New Dockerfile:
docker/web.Dockerfile(currently a placeholder). Multi-stage: deps → build (pnpm --filter @cmbot/web build→.next/standalone) → runtime (node apps/web/.next/standalone/server.js). - New service in compose:
web(replaces the existing placeholder). apps/web/package:@cmbot/web, depends on@cmbot/dband@cmbot/sharedworkspaces.- aaPanel reverse proxy: existing config block updated to forward to
web:3000and pass through SSE headers; deny/api/*except/api/events.
Local dev:
scripts/dev.sh upbrings web alongside tools + bot.- Hot reload: web mounts
apps/web/src(and dependent packages) into the container;next devwatches.
16. Out of scope (for this plan)
- Recurring reminders (RRULE) — same plan-2 deferral; web wizard supports one-off only for now.
- Standalone media library page — media is attached to reminders, not browseable separately yet.
- E2E browser tests (Playwright) — manual test runbook in plan 3 covers verification.
- Sentry / external error tracking.
- WebPush notifications (the operator already gets WhatsApp messages on his phone; PWA badging is enough).
- Multi-operator (still single-tenant).
- Passkeys / WebAuthn (only relevant if we add auth later).
17. Confirmed values
Inherits from the master spec:
- Subdomain:
wabot.04080616.xyz - Default timezone:
Asia/Kuala_Lumpur - Postgres:
192.168.0.210/wabot - Media retention: 90 days
New for this design:
- Component library: shadcn/ui
- Visual style: clean utility / admin dashboard
- Auth: none (URL is the secret)
- Real-time: SSE
- Service worker:
@serwist/next