Compare commits

..

No commits in common. "670eaf493cdb39e3abc30cb9c4759e14cd3a8785" and "bf3586fe7b271d7453914b4302c182818ff84605" have entirely different histories.

238 changed files with 1383 additions and 39856 deletions

View File

@ -1,4 +1,7 @@
DATABASE_URL=postgres://waBot:cJe3SGjHHAitNBE4@192.168.0.210:5432/wabot DATABASE_URL=postgres://waBot:cJe3SGjHHAitNBE4@192.168.0.210:5432/wabot
TELEGRAM_BOT_TOKEN=5327571437:AAFlowwnAysTEMx6LtYQNTevGCboKDZoYzY
TELEGRAM_OPERATOR_WHITELIST=818380985
TELEGRAM_QR_CHAT_ID=818380985
DATA_DIR=/data DATA_DIR=/data
SESSIONS_DIR=/data/sessions SESSIONS_DIR=/data/sessions
MEDIA_DIR=/data/media MEDIA_DIR=/data/media
@ -6,5 +9,5 @@ BOT_HEALTH_PORT=8081
BOT_LOG_LEVEL=debug BOT_LOG_LEVEL=debug
SEED_OPERATOR_TELEGRAM_ID=818380985 SEED_OPERATOR_TELEGRAM_ID=818380985
SEED_OPERATOR_NAME="yiekheng (dev)" SEED_OPERATOR_NAME="yiekheng (dev)"
WEB_PORT=9000 WEB_PORT=3000
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c

7
.gitignore vendored
View File

@ -8,12 +8,6 @@ dist/
.turbo/ .turbo/
*.tsbuildinfo *.tsbuildinfo
# serwist emits these into apps/web/public/ on every production build.
# Icons (icon-*.png, apple-touch-icon.png) ARE committed; the generated
# service-worker bundle is regenerated by the build itself.
apps/web/public/sw.js
apps/web/public/swe-worker-*.js
# env files: per project decision, .env.development and .env.production # env files: per project decision, .env.development and .env.production
# ARE committed to this private Gitea. Only ignore example overrides: # ARE committed to this private Gitea. Only ignore example overrides:
.env.local .env.local
@ -37,4 +31,3 @@ data/
# test coverage # test coverage
coverage/ coverage/
.vitest-cache/ .vitest-cache/
session

125
README.md
View File

@ -1,87 +1,41 @@
# cm WhatsApp Reminder Bot # cm WhatsApp Reminder Bot
Self-hosted WhatsApp reminder bot. Pair multiple WhatsApp accounts via Self-hosted WhatsApp reminder bot. Pairs multiple WhatsApp accounts via Telegram-delivered QR codes and sends scheduled reminders to groups.
a browser-based PWA, schedule recurring reminders to groups, and watch
the run history all from a phone home-screen icon.
## Status ## Status
**Plans 1, 2, and 3 complete.** The web app at `wabot.04080616.xyz` is **Plan 1 complete.** Foundation, DB schema, and Telegram-driven WhatsApp pairing are working end-to-end. Reminder scheduling, the web dashboard, and production deploy are upcoming plans (`docs/superpowers/plans/`).
the primary control surface; the Telegram bot has been removed.
What's working today: What's working today:
- **Self-hosted Next.js 16 PWA** — installable on a phone home screen. - Single-operator Telegram bot with a whitelist + audit log of every command.
Mobile-first single-row header with a slide-out drawer; desktop - BotFather-style menu navigation: `/menu` opens a single message that edits in place as you navigate.
sidebar. - Pair a new WhatsApp account with `/menu` → 📡 Pair New → reply with a label. QR is delivered to Telegram and refreshed in place as it expires.
- **Live QR pairing** — server-side Baileys session feeds the QR - Browse paired accounts with 📒 Accounts. Tap an account → see groups, send a test text message, or unpair.
payload directly into the browser via Server-Sent Events. Scan, - Group sync runs at pairing and on every Baileys `groups.upsert` / `groups.update` event, plus a manual 🔄 Refresh button. Removed groups are pruned automatically.
see "✅ Connected" within seconds, auto-redirect. - Auto-reconnect on transient drops; restart-survival via Baileys `useMultiFileAuthState` (no QR rescan needed across container restarts as long as WhatsApp hasn't logged the device out).
- **Multi-account, multi-group reminders** — 5-step wizard
(Account → Message → When → Groups → Review) plus per-section edit
pages so you don't have to walk the wizard end-to-end to fix one
field. Active recurrence picker covers Daily / Weekly / Monthly /
Yearly with multi-rule support and per-rule fire-time pickers; the
rendered description reads as plain English ("Every week on Mon,
Wed, Fri at 09:00") not raw cron.
- **Multi-message stacks** — a reminder can carry multiple ordered
parts (text + media), fired in sequence with a 1.5 s gap. Media
files swap at any time from the Edit Message page.
- **Smart media handling** — per-kind WhatsApp size caps (5 MB image,
16 MB video/audio, 100 MB document). HEIC photos and `.mov` videos
fall back to the document delivery path so they reach the recipient
as a downloadable file instead of failing silently.
- **Swipe-to-act rows** — on mobile, swipe a reminder or activity
row left for Delete or right for Pause/Restart/Archive. iOS-Mail
style.
- **Activity tab** — last 200 runs with status filters (Success /
Partial / Failed / Skipped) plus an Archived tab. Archive a noisy
run to keep the main list readable; restore later. Hard-delete
always available. Run history survives a reminder deletion.
- **Auto-reconnect on transient drops; restart-survival via Baileys
session persistence.** Pair once, the device stays linked across
container restarts.
- **All actions audited.** Reminder run history queryable from the
UI; per-run target results (sent / failed / skipped) preserved
even when the underlying group is removed.
Test count: **249 web + 31 shared + 26 bot = 306** passing.
## Host requirements ## Host requirements
Only Docker. No host Node, pnpm, or any other language toolchain — Only Docker. No host Node, pnpm, or any other language toolchain — everything runs in containers via the long-lived `tools` service.
everything runs in containers via the long-lived `tools` sidecar.
## Architecture in one paragraph ## Architecture in one paragraph
Two app containers and one external dependency. `bot` (Node.js) holds Two app containers and one external dependency. `bot` (Node.js) holds the live Baileys WhatsApp sessions, the grammy Telegram bot, and (in plan 2) a pg-boss scheduler. `web` (Next.js, plan 3) is stateless UI + API. `tools` is a long-running Node 22 + pnpm sidecar used for installs/tests/typechecks/migrations so the host doesn't need a Node toolchain. Postgres lives external at `192.168.0.210` in a `wabot` database. All cross-service communication goes through Postgres (`LISTEN/NOTIFY` for events, table writes for state).
the live Baileys WhatsApp sessions, the pg-boss scheduler, and a
Postgres `LISTEN bot.command` consumer. `web` (Next.js 16 App Router
+ React 19) is stateless UI: Server Components for reads, Server
Actions for mutations, an SSE endpoint for live updates,
`@serwist/next` for the PWA shell. `tools` is a long-running
Node 22 + pnpm sidecar used for installs / tests / typechecks /
migrations so the host doesn't need a Node toolchain. Postgres lives
external at `192.168.0.210` in a `wabot` database. All cross-service
communication goes through Postgres (`LISTEN/NOTIFY` for events,
table writes for state).
Full design spec: Full design spec: [`docs/superpowers/specs/2026-05-03-whatsapp-bot-design.md`](docs/superpowers/specs/2026-05-03-whatsapp-bot-design.md)
[`docs/superpowers/specs/2026-05-09-web-app-design.md`](docs/superpowers/specs/2026-05-09-web-app-design.md)
## Quick start (dev) ## Quick start (dev)
Prerequisites: Docker, the `wabot` database + `waBot` role on Prerequisites: Docker, the `wabot` database + `waBot` role on `192.168.0.210` (with a `pg_hba.conf` line permitting `192.168.0.0/24`), and a Telegram bot token from `@BotFather`.
`192.168.0.210` (with a `pg_hba.conf` line permitting
`192.168.0.0/24`).
```bash ```bash
# 1. Configure env # 1. Configure env
cp envs/.env.example .env.development cp envs/.env.example .env.development
# edit .env.development: real DATABASE_URL, plus the LAN host to expose # edit .env.development: real DATABASE_URL, TELEGRAM_BOT_TOKEN, your TG user ID
scripts/gen_auth_secret.sh --write scripts/gen_auth_secret.sh --write
# 2. Bring up the stack, install deps # 2. Bring up the tools container, install deps
NO_SUDO=1 scripts/dev.sh up NO_SUDO=1 scripts/dev.sh up
NO_SUDO=1 scripts/dev.sh pnpm install NO_SUDO=1 scripts/dev.sh pnpm install
@ -89,62 +43,39 @@ NO_SUDO=1 scripts/dev.sh pnpm install
NO_SUDO=1 scripts/db.sh migrate NO_SUDO=1 scripts/db.sh migrate
NO_SUDO=1 scripts/db.sh seed NO_SUDO=1 scripts/db.sh seed
# 4. Open the web app # 4. Watch the bot service
# Local: http://localhost:9000 NO_SUDO=1 scripts/dev.sh logs bot
# LAN: http://<host-ip>:9000 (e.g. http://192.168.0.253:9000)
# Public: https://wabot.04080616.xyz (whatever your reverse proxy serves)
``` ```
Pair an account: `/accounts` → "New Account" → enter a label → In Telegram, message your dev bot `/menu`, tap **📡 Pair New**, reply with a label, scan the QR.
"Pair WhatsApp" → scan the QR with WhatsApp's "Linked Devices".
PWA install: phone Chrome → menu → "Install App" / "Add to Home `NO_SUDO=1` is the right setting if your user is in the `docker` group (the default for this repo). Drop it if you need `sudo docker`.
Screen". Launches fullscreen.
`NO_SUDO=1` is the right setting if your user is in the `docker`
group (the default for this repo). Drop it if you need `sudo docker`.
## Manual test runbook
End-to-end checks that unit tests can't cover (live Baileys,
WhatsApp delivery, swipe gestures):
[`docs/superpowers/specs/manual-test-web.md`](docs/superpowers/specs/manual-test-web.md).
## Layout ## Layout
- `apps/bot/` — Baileys WhatsApp + pg-boss scheduler + LISTEN/NOTIFY - `apps/bot/` — Node service: Baileys WhatsApp + grammy Telegram + (later) pg-boss scheduler
command consumer - `apps/web/` — Next.js dashboard (plan 3)
- `apps/web/` — Next.js 16 App Router PWA
- `packages/db/` — Drizzle schema and migrations - `packages/db/` — Drizzle schema and migrations
- `packages/shared/` — cross-app helpers (rrule, media paths, - `packages/shared/` — cross-app helpers (rrule, media paths, timezones)
timezones, WhatsApp media classifier)
- `docs/superpowers/specs/` — design specs and manual test runbooks - `docs/superpowers/specs/` — design specs and manual test runbooks
- `docs/superpowers/plans/` — implementation plans - `docs/superpowers/plans/` — implementation plans
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`, - `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`, `web.Dockerfile` placeholder)
`web.Dockerfile`) - `scripts/``dev.sh`, `db.sh`, `gen_auth_secret.sh`, plus stubs for plans 2/4
- `scripts/``dev.sh`, `db.sh`, `gen_auth_secret.sh`
## Scripts ## Scripts
All `pnpm`/`tsx`/`drizzle-kit` invocations run inside the `tools` All `pnpm`/`tsx`/`drizzle-kit` invocations run inside the `tools` container, so no host Node is needed.
container, so no host Node is needed.
| Script | Purpose | | Script | Purpose |
|---|---| |---|---|
| `scripts/dev.sh up\|down\|logs\|status\|build\|exec\|pnpm\|shell\|restart-bot` | Stack lifecycle and tools-container shell | | `scripts/dev.sh up\|down\|logs\|status\|build\|exec\|pnpm\|shell\|restart-bot` | Stack lifecycle and tools-container shell |
| `scripts/db.sh migrate\|generate\|studio\|seed\|reset` | Drizzle migration helper | | `scripts/db.sh migrate\|generate\|studio\|seed\|reset` | Drizzle migration helper |
| `scripts/gen_auth_secret.sh [--write]` | Generate `AUTH_SECRET` (host-only, no Node needed) | | `scripts/gen_auth_secret.sh [--write]` | Generate `AUTH_SECRET` (host-only, no Node needed) |
| `scripts/publish.sh` | Push to Gitea registry — implemented in plan 4 |
| `scripts/link-account.sh` | CLI pairing without Telegram — implemented in plan 2 |
Set `NO_SUDO=1` if your user is in the docker group (recommended). Set `NO_SUDO=1` if your user is in the docker group (recommended).
## Deferred ## Next plan
- **Standalone media library** browser (currently media is uploaded `docs/superpowers/plans/<next-date>-reminder-scheduling.md` — pg-boss, reminder CRUD via Telegram, fire-reminder handler, sender (text/image/video), retry policy, run history.
per-reminder).
- **E2E browser tests** (Playwright) on the swipe and pairing flows.
- **Auth** (passkeys / email-password) — bring back if URL exposure
becomes a concern. Today the app trusts whatever's in front of the
reverse proxy.
- **Multi-operator** — schema supports `operator_id` on every row,
but the seed runs as a single operator and there's no /signup or
invite flow yet.

View File

@ -16,20 +16,15 @@
"@cmbot/db": "workspace:*", "@cmbot/db": "workspace:*",
"@cmbot/shared": "workspace:*", "@cmbot/shared": "workspace:*",
"@whiskeysockets/baileys": "7.0.0-rc10", "@whiskeysockets/baileys": "7.0.0-rc10",
"drizzle-orm": "^0.36.0", "grammy": "^1.31.0",
"luxon": "^3.5.0",
"p-limit": "^7.3.0",
"pg": "^8.13.0",
"pg-boss": "^12.18.2",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^11.3.0", "pino-pretty": "^11.3.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"drizzle-orm": "^0.36.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.4.2",
"@types/node": "^22.7.0", "@types/node": "^22.7.0",
"@types/pg": "^8.11.10",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.5.0", "typescript": "^5.5.0",

View File

@ -3,6 +3,9 @@ import { parseEnv } from "./env.js";
const valid = { const valid = {
DATABASE_URL: "postgres://u:p@h:5432/db", DATABASE_URL: "postgres://u:p@h:5432/db",
TELEGRAM_BOT_TOKEN: "123:abc",
TELEGRAM_OPERATOR_WHITELIST: "111,222",
TELEGRAM_QR_CHAT_ID: "111",
DATA_DIR: "/data", DATA_DIR: "/data",
SESSIONS_DIR: "/data/sessions", SESSIONS_DIR: "/data/sessions",
MEDIA_DIR: "/data/media", MEDIA_DIR: "/data/media",
@ -13,6 +16,8 @@ const valid = {
describe("parseEnv", () => { describe("parseEnv", () => {
it("parses a valid env", () => { it("parses a valid env", () => {
const env = parseEnv(valid); const env = parseEnv(valid);
expect(env.TELEGRAM_OPERATOR_WHITELIST).toEqual([111, 222]);
expect(env.TELEGRAM_QR_CHAT_ID).toBe(111);
expect(env.BOT_HEALTH_PORT).toBe(8081); expect(env.BOT_HEALTH_PORT).toBe(8081);
}); });
@ -21,37 +26,11 @@ describe("parseEnv", () => {
expect(() => parseEnv(rest)).toThrow(); expect(() => parseEnv(rest)).toThrow();
}); });
it("rejects empty whitelist", () => {
expect(() => parseEnv({ ...valid, TELEGRAM_OPERATOR_WHITELIST: "" })).toThrow();
});
it("rejects malformed port", () => { it("rejects malformed port", () => {
expect(() => parseEnv({ ...valid, BOT_HEALTH_PORT: "notanumber" })).toThrow(); expect(() => parseEnv({ ...valid, BOT_HEALTH_PORT: "notanumber" })).toThrow();
}); });
it("defaults BOT_FIRE_CONCURRENCY to 8 when unset", () => {
expect(parseEnv(valid).BOT_FIRE_CONCURRENCY).toBe(8);
});
it("defaults BOT_GROUP_CONCURRENCY to 3 when unset", () => {
expect(parseEnv(valid).BOT_GROUP_CONCURRENCY).toBe(3);
});
it("defaults BOT_MAX_SEND_PER_MINUTE to 40 when unset", () => {
expect(parseEnv(valid).BOT_MAX_SEND_PER_MINUTE).toBe(40);
});
it("parses overrides for the fan-out tuning vars as integers", () => {
const env = parseEnv({
...valid,
BOT_FIRE_CONCURRENCY: "16",
BOT_GROUP_CONCURRENCY: "5",
BOT_MAX_SEND_PER_MINUTE: "60",
});
expect(env.BOT_FIRE_CONCURRENCY).toBe(16);
expect(env.BOT_GROUP_CONCURRENCY).toBe(5);
expect(env.BOT_MAX_SEND_PER_MINUTE).toBe(60);
});
it("rejects non-numeric values for the fan-out tuning vars", () => {
expect(() => parseEnv({ ...valid, BOT_FIRE_CONCURRENCY: "many" })).toThrow();
expect(() => parseEnv({ ...valid, BOT_GROUP_CONCURRENCY: "-1" })).toThrow();
expect(() => parseEnv({ ...valid, BOT_MAX_SEND_PER_MINUTE: "40.5" })).toThrow();
});
}); });

View File

@ -4,20 +4,18 @@ const numberFromString = z.string().regex(/^\d+$/).transform((s) => Number(s));
const envSchema = z.object({ const envSchema = z.object({
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
TELEGRAM_BOT_TOKEN: z.string().min(1),
TELEGRAM_OPERATOR_WHITELIST: z
.string()
.min(1)
.transform((s) => s.split(",").map((x) => Number(x.trim())))
.pipe(z.array(z.number().int().positive()).min(1)),
TELEGRAM_QR_CHAT_ID: numberFromString,
DATA_DIR: z.string().min(1), DATA_DIR: z.string().min(1),
SESSIONS_DIR: z.string().min(1), SESSIONS_DIR: z.string().min(1),
MEDIA_DIR: z.string().min(1), MEDIA_DIR: z.string().min(1),
BOT_HEALTH_PORT: numberFromString, BOT_HEALTH_PORT: numberFromString,
BOT_LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), BOT_LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
// Reminder fan-out tuning. Defaults aim for an established WhatsApp
// account (~30-60 msg/min safe band).
// BOT_FIRE_CONCURRENCY — pg-boss worker pool: max accounts firing in parallel.
// BOT_GROUP_CONCURRENCY — per-account parallel group sends; parts within a group stay serial.
// BOT_MAX_SEND_PER_MINUTE — per-account token-bucket rate.
BOT_FIRE_CONCURRENCY: numberFromString.default("8"),
BOT_GROUP_CONCURRENCY: numberFromString.default("3"),
BOT_MAX_SEND_PER_MINUTE: numberFromString.default("40"),
}); });
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;

View File

@ -1,34 +1,26 @@
import { logger } from "./logger.js"; import { logger } from "./logger.js";
import { pool } from "./db.js"; import { pool } from "./db.js";
import { startHealthServer, setSessionCountsProvider } from "./health.js"; import { startHealthServer, setSessionCountsProvider } from "./health.js";
import { createTelegramBot } from "./telegram/bot.js";
import { sessionManager } from "./whatsapp/session-manager.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> { async function main(): Promise<void> {
logger.info("bot starting"); logger.info("bot starting");
const health = startHealthServer(); const health = startHealthServer();
setSessionCountsProvider(() => sessionManager.getCounts()); setSessionCountsProvider(() => sessionManager.getCounts());
const boss = await startBoss(); const tg = createTelegramBot();
await registerReminderJobs(boss); void tg.start({
onStart: (info) => logger.info({ username: info.username }, "telegram polling started"),
drop_pending_updates: true,
});
registerDefaultHandlers();
const stopConsumer = await startCommandConsumer();
await sweepStalePendingAccounts();
await sessionManager.resumeFromDb(); await sessionManager.resumeFromDb();
const shutdown = async (signal: string): Promise<void> => { const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "shutting down"); logger.info({ signal }, "shutting down");
await stopConsumer(); await tg.stop();
await sessionManager.stopAll(); await sessionManager.stopAll();
await stopBoss();
health.close(); health.close();
await pool.end(); await pool.end();
process.exit(0); process.exit(0);

View File

@ -1,82 +0,0 @@
import { Client } from "pg";
import type { Notification } from "pg";
import { logger } from "../logger.js";
import { env } from "../env.js";
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";
import { handleScheduleReminder } from "./schedule-reminder-handler.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: "reminder.schedule"; reminderId: string; scheduledAtIso: 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: Notification) => {
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: Error) => 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");
};
}
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);
});
registerHandler("reminder.schedule", async (cmd) => {
await handleScheduleReminder(cmd.reminderId, cmd.scheduledAtIso);
});
}

View File

@ -1,30 +0,0 @@
import { sql } from "drizzle-orm";
import { db } from "../db.js";
import { logger } from "../logger.js";
export type WebEvent =
// QR PNG bytes live in `whatsapp_accounts.last_qr_png` so this NOTIFY
// payload stays under Postgres' 8000-byte limit. Web fetches the PNG
// from /api/qr/[accountId] when it sees this event.
| { type: "session.qr"; accountId: string; ts: number }
| { 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 }
// The web action enqueues a send_test via pg_notify and shows
// "Sending…" optimistically. This event closes the loop.
| {
type: "send_test.done";
groupId: string;
ok: boolean;
error: string | null;
};
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");
}

View File

@ -1,223 +0,0 @@
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);
}
// Throw away the partial Baileys session files so the next pair
// attempt starts clean — but KEEP the account row so the operator
// sees it on the list with a "Re-pair" affordance.
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null })
.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;
}
// Detach any listener still subscribed from a prior pairing attempt for
// this account. Without this, repeated Re-pair clicks accumulate
// listeners and each one writes a fresh QR to the DB on every Baileys
// event — the UI then flashes through new QRs constantly.
const prevOff = offByAccount.get(accountId);
if (prevOff) {
prevOff();
offByAccount.delete(accountId);
}
// For Re-pair, an old session may still be alive. Stop it so
// sessionManager.start() actually opens a fresh socket and Baileys emits
// a new QR. (start() is a no-op when a session is already registered.)
if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId);
}
// Clear any stale QR lingering from a prior attempt.
lastQrPayload.delete(accountId);
await db
.update(whatsappAccounts)
.set({ lastQrPng: null })
.where(eq(whatsappAccounts.id, accountId));
const off = sessionManager.on(async (id, _state, event) => {
if (id !== accountId) return;
try {
if (event.type === "qr") {
// Dedupe by payload — Baileys can re-emit the same QR string in a
// burst. Different strings (a fresh QR) always pass through, so
// the user gets a new QR as soon as Baileys generates one.
if (lastQrPayload.get(id) === event.payload) return;
lastQrPayload.set(id, event.payload);
const png = await renderQrPng(event.payload);
// PNG is too large (~5-10KB) for pg_notify (8000 byte limit).
// Persist on the account row; web fetches via /api/qr/[id].
await db
.update(whatsappAccounts)
.set({ lastQrPng: png.toString("base64"), lastQrAt: new Date() })
.where(eq(whatsappAccounts.id, id));
await pgNotifyWeb({
type: "session.qr",
accountId: id,
ts: Date.now(),
});
} 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.restartRequired) {
// After the user scans, WhatsApp tells Baileys to "restart"
// the connection. The socket closes with status 515 and the
// session-manager will reopen it with the new credentials —
// the next `open` event is what completes the pairing.
// This is NOT a failure: keep the listener attached so we see
// that subsequent `open` event, and don't surface a timeout
// to the UI. The DB row stays in `pending` until `open`.
logger.info(
{ accountId: id },
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
);
// The session-manager handles the actual reconnect; nothing to
// do here other than NOT tear our listener / DB state down.
} else if (event.type === "close") {
// During the pairing window, any other close means the QR window
// ended without a successful link — Baileys' default is to
// close after exhausting QR refs (~2.5 min). Surface this to
// the UI so the user gets a "pairing timed out" screen, and
// park the row in a stable state so it shows up cleanly on
// the accounts list with a "Re-pair" affordance.
const t = pairTimeouts.get(id);
if (t) {
clearTimeout(t);
pairTimeouts.delete(id);
}
lastQrPayload.delete(id);
offByAccount.delete(id);
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null })
.where(eq(whatsappAccounts.id, 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. The bot was probably
* restarted mid-pair (or the operator never finished scanning) the
* row is parked as `unpaired` so the operator sees it on the list and
* can hit Re-pair, instead of silently disappearing.
*/
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
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null })
.where(eq(whatsappAccounts.id, row.id));
logger.info({ accountId: row.id, label: row.label }, "sweep: parked stale pending account as unpaired");
}
}

View File

@ -1,84 +0,0 @@
import { describe, it, expect } from "vitest";
import {
decideOnPairClose,
decideOnPairTimeout,
shouldAutoReconnect,
} from "./pair-state.js";
describe("decideOnPairClose", () => {
it("logged-out close → terminal `logged_out` and wipes QR", () => {
const r = decideOnPairClose({ current: "pending", loggedOut: true });
expect(r).toEqual({ next: "logged_out", clearQrPng: true });
});
it("restart-required close → null (it's a SUCCESS — reconnect, don't touch DB)", () => {
// Regression we just fixed: after the user scans, Baileys closes
// the socket with status 515 ("restart required") so it can
// reopen with the new credentials. Treating that close as a
// failure produced a spurious "Pairing timed out" right at the
// moment the user actually paired successfully.
expect(
decideOnPairClose({ current: "pending", loggedOut: false, restartRequired: true }),
).toBe(null);
});
it("non-loggedOut close from `pending` parks the row as `unpaired`", () => {
const r = decideOnPairClose({ current: "pending", loggedOut: false });
expect(r).toEqual({ next: "unpaired", clearQrPng: true });
});
it("non-loggedOut close from any transient state parks as `unpaired`", () => {
for (const current of ["disconnected", "unpaired", "connected"] as const) {
const r = decideOnPairClose({ current, loggedOut: false });
expect(r).not.toBe(null);
expect(r!.next).toBe("unpaired");
expect(r!.clearQrPng).toBe(true);
}
});
});
describe("decideOnPairTimeout (5-min pair-window expiry)", () => {
it("parks a still-`pending` row as `unpaired`", () => {
expect(decideOnPairTimeout({ current: "pending" })).toEqual({
next: "unpaired",
clearQrPng: true,
});
});
it("does nothing if the row already moved on", () => {
// Don't clobber a successfully-paired account that just happened
// to fire after the timeout for any reason.
for (const current of ["connected", "unpaired", "logged_out", "banned"] as const) {
expect(decideOnPairTimeout({ current })).toBe(null);
}
});
});
describe("shouldAutoReconnect", () => {
it("never reconnects after a logged-out close", () => {
expect(shouldAutoReconnect({ loggedOut: true, hasEverConnected: true })).toBe(false);
expect(shouldAutoReconnect({ loggedOut: true, hasEverConnected: false })).toBe(false);
// Even if Baileys also flagged restartRequired (it shouldn't, but
// be defensive), loggedOut wins.
expect(
shouldAutoReconnect({ loggedOut: true, restartRequired: true, hasEverConnected: true }),
).toBe(false);
});
it("ALWAYS reconnects on restart-required (post-pair-success), even for first-time accounts", () => {
// The regression: brand-new pair attempts have hasEverConnected=false,
// so the old logic refused to reconnect after status 515 — and the
// user got "Pairing timed out" the moment they actually paired.
expect(
shouldAutoReconnect({ loggedOut: false, restartRequired: true, hasEverConnected: false }),
).toBe(true);
expect(
shouldAutoReconnect({ loggedOut: false, restartRequired: true, hasEverConnected: true }),
).toBe(true);
});
it("reconnects only for accounts that have been linked at least once for non-restartRequired drops", () => {
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: true })).toBe(true);
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
});
});

View File

@ -1,82 +0,0 @@
/**
* Pure helpers for pairing-lifecycle status transitions. Extracted so
* the rules are unit-testable without spinning up Baileys / Postgres.
*
* Key invariant the tests guard:
* - A failed or abandoned pair MUST NOT leave the row stuck in
* `pending`. It transitions to `unpaired` so the operator can see
* the account on the list with a Re-pair affordance.
* - Successful pairing transitions to `connected` (set by the
* session-manager on the `open` event not this helper's job).
* - Auto-reconnect for transient drops only applies to accounts
* that have been linked at least once (`lastConnectedAt` set).
*/
export type AccountStatus =
| "pending"
| "unpaired"
| "connected"
| "disconnected"
| "logged_out"
| "banned";
export interface PairCloseInput {
/** Status of the account row at the moment the close event fires. */
current: AccountStatus;
/** Did Baileys signal a logged-out close (vs an ephemeral close)? */
loggedOut: boolean;
/** Was it the post-pair "restart required" close (status 515)? */
restartRequired?: boolean;
}
export type StatusUpdate = {
next: AccountStatus;
/** Wipe the cached QR PNG when the pair window closes. */
clearQrPng: boolean;
} | null;
/**
* Decide the status transition when the Baileys session closes during
* a pairing attempt.
*
* - logged_out close terminal: `logged_out`.
* - restart-required close null (this is a SUCCESS signal that triggers
* a reconnect; the row stays in its current state until `open` fires).
* - ephemeral close (refs exhausted, network blip, etc.) park as
* `unpaired` so the row stays visible and the user can retry.
*/
export function decideOnPairClose({ current, loggedOut, restartRequired }: PairCloseInput): StatusUpdate {
if (loggedOut) {
return { next: "logged_out", clearQrPng: true };
}
if (restartRequired) {
// Post-pair-success reconnect — the next `open` event finishes the
// job. Don't touch DB state and don't tear the listener down.
return null;
}
// Whatever transient state we were in (most often `pending`), park
// the row as `unpaired` — anything else hides it from the operator.
return { next: "unpaired", clearQrPng: true };
}
/** Whether the session-manager should auto-reconnect after a non-loggedOut close. */
export function shouldAutoReconnect(args: {
loggedOut: boolean;
restartRequired?: boolean;
/** True if the account row has `last_connected_at` set (has been linked before). */
hasEverConnected: boolean;
}): boolean {
if (args.loggedOut) return false;
// Status 515 is the post-pair-success reconnect — always do it,
// regardless of whether the account has ever connected before.
if (args.restartRequired) return true;
return args.hasEverConnected;
}
/** Decide what happens when the 5-min pair-window timeout fires. */
export function decideOnPairTimeout({ current }: { current: AccountStatus }): StatusUpdate | null {
// Only the still-pending rows need cleanup. Anything else has already
// moved on (connected, unpaired by an earlier close, etc.).
if (current !== "pending") return null;
return { next: "unpaired", clearQrPng: true };
}

View File

@ -1,6 +0,0 @@
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));
}

View File

@ -1,53 +0,0 @@
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";
import { pgNotifyWeb } from "./notify.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");
await pgNotifyWeb({
type: "send_test.done",
groupId,
ok: false,
error: "Group not found",
});
return;
}
const session = sessionManager.getSession(group.accountId);
if (!session) {
logger.warn({ groupId, accountId: group.accountId }, "send-test: account not connected");
await pgNotifyWeb({
type: "send_test.done",
groupId,
ok: false,
error: "Account not connected — re-pair before sending",
});
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 },
});
await pgNotifyWeb({ type: "send_test.done", groupId, ok: true, error: null });
} catch (err) {
logger.error({ err, groupId }, "send-test: failed");
await pgNotifyWeb({
type: "send_test.done",
groupId,
ok: false,
error: err instanceof Error ? err.message : "Send failed",
});
}
}

View File

@ -1,15 +0,0 @@
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 });
}

View File

@ -1,41 +0,0 @@
import { rm } from "node:fs/promises";
import { join } from "node:path";
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";
/**
* Unpair handler: stop the live Baileys session and remove the on-disk
* session files. The web action keeps the account row alive (status =
* 'unpaired') so the operator can re-pair without retyping the label;
* the {intentional: true} stop tells the session manager not to race
* the web's status write with its own "disconnected" update or
* schedule a reconnect for a session we just chose to tear down.
*
* For the delete-account flow the row IS gone by the time we run;
* the audit log lookup tolerates that.
*/
export async function handleUnpair(accountId: string): Promise<void> {
await sessionManager.stop(accountId, { intentional: true });
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
try {
const row = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
columns: { operatorId: true },
});
await writeAuditLog(db, {
operatorId: row?.operatorId ?? null,
source: "web",
action: "account.unpaired",
targetType: "whatsapp_account",
targetId: accountId,
payload: {},
});
} catch (err) {
logger.warn({ err, accountId }, "unpair: audit log failed (non-fatal)");
}
await pgNotifyWeb({ type: "session.disconnected", accountId });
}

View File

@ -1,109 +0,0 @@
import { eq, sql } from "drizzle-orm";
import {
reminders,
reminderMessages,
reminderTargets,
type Reminder,
} from "@cmbot/db";
import { db } from "../db.js";
import { DEFAULT_TIMEZONE } from "@cmbot/shared";
export type CreateReminderInput = {
accountId: string;
groupId: string;
name: string;
scheduledAt: Date;
text?: string | null;
mediaId?: string | null;
caption?: string | null;
createdBy: string;
timezone?: string;
};
export type ReminderWithDetails = Reminder & {
targets: { groupId: string }[];
messages: { id: string; position: number; kind: string; textContent: string | null; mediaId: string | null }[];
};
export async function createReminder(input: CreateReminderInput): Promise<string> {
return await db.transaction(async (tx) => {
const [rem] = await tx
.insert(reminders)
.values({
accountId: input.accountId,
name: input.name,
scheduleKind: "one_off",
scheduledAt: input.scheduledAt,
timezone: input.timezone ?? DEFAULT_TIMEZONE,
status: "active",
createdBy: input.createdBy,
})
.returning({ id: reminders.id });
await tx.insert(reminderTargets).values({
reminderId: rem!.id,
groupId: input.groupId,
position: 0,
});
let position = 0;
if (input.text && !input.mediaId) {
await tx.insert(reminderMessages).values({
reminderId: rem!.id,
position: position++,
kind: "text",
textContent: input.text,
mediaId: null,
});
} else if (input.mediaId) {
await tx.insert(reminderMessages).values({
reminderId: rem!.id,
position: position++,
kind: "media",
textContent: input.caption ?? input.text ?? null,
mediaId: input.mediaId,
});
}
return rem!.id;
});
}
export async function getReminderWithDetails(id: string): Promise<ReminderWithDetails | null> {
const rem = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, id),
});
if (!rem) return null;
const targets = await db.query.reminderTargets.findMany({
where: (t, { eq }) => eq(t.reminderId, id),
});
const messages = await db.query.reminderMessages.findMany({
where: (m, { eq }) => eq(m.reminderId, id),
orderBy: (m, { asc }) => [asc(m.position)],
});
return { ...rem, targets, messages };
}
export async function listRemindersForOperator(
operatorId: string,
limit = 50,
): Promise<(Reminder & { accountLabel: string; groupCount: number })[]> {
// Use parameterized SQL via drizzle's sql tag for safety. operatorId is a
// server-controlled UUID, but parameterizing is the right habit anyway.
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 ${limit}
`);
return rows.rows as never;
}
export async function deleteReminder(id: string): Promise<void> {
await db.delete(reminders).where(eq(reminders.id, id));
}

View File

@ -1,36 +0,0 @@
import { describe, it, expect } from "vitest";
import { quickToDate, buildCustomDate, formatCustomDay } from "./time-parsing.js";
describe("quickToDate", () => {
it("now returns ~30s ahead", () => {
const d = quickToDate("now");
const diffMs = d.getTime() - Date.now();
expect(diffMs).toBeGreaterThan(20 * 1000);
expect(diffMs).toBeLessThan(40 * 1000);
});
it("tomorrow_9am returns a future Date", () => {
const d = quickToDate("tomorrow_9am");
expect(d.getTime()).toBeGreaterThan(Date.now());
});
});
describe("buildCustomDate", () => {
it("rejects in-past day/hour/minute", () => {
const r = buildCustomDate(-1, 9, 0, "Asia/Kuala_Lumpur");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/past/i);
});
it("accepts a far-future combination", () => {
const r = buildCustomDate(7, 23, 45, "Asia/Kuala_Lumpur");
expect(r.ok).toBe(true);
});
});
describe("formatCustomDay", () => {
it("returns 'Today (...)' for offset 0", () => {
expect(formatCustomDay(0, "Asia/Kuala_Lumpur")).toMatch(/^Today/);
});
it("returns 'Tomorrow (...)' for offset 1", () => {
expect(formatCustomDay(1, "Asia/Kuala_Lumpur")).toMatch(/^Tomorrow/);
});
});

View File

@ -1,130 +0,0 @@
import { DateTime } from "luxon";
import { DEFAULT_TIMEZONE } from "@cmbot/shared";
export type Quick = "now" | "tomorrow_9am" | "next_mon_9am";
export function quickToDate(quick: Quick, timezone: string = DEFAULT_TIMEZONE): Date {
const now = DateTime.now().setZone(timezone);
switch (quick) {
case "now":
// Add 30s so pg-boss has time to schedule + the system has time to dispatch
return now.plus({ seconds: 30 }).toJSDate();
case "tomorrow_9am":
return now.plus({ days: 1 }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate();
case "next_mon_9am": {
const dow = now.weekday; // 1 = Mon
const daysUntilMon = ((1 - dow + 7) % 7) || 7;
return now.plus({ days: daysUntilMon }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate();
}
}
}
/**
* Build a Date from a day-offset (days from today, in the operator's timezone),
* an hour (0-23) and a minute (0-59). Returns the JS Date or null if the
* resulting time is in the past.
*/
export function buildCustomDate(
dayOffset: number,
hour: number,
minute: number,
timezone: string = DEFAULT_TIMEZONE,
): { ok: true; date: Date } | { ok: false; reason: string } {
const target = DateTime.now()
.setZone(timezone)
.plus({ days: dayOffset })
.set({ hour, minute, second: 0, millisecond: 0 });
if (!target.isValid) {
return { ok: false, reason: "Invalid date" };
}
const jsDate = target.toJSDate();
if (jsDate.getTime() <= Date.now()) {
return { ok: false, reason: "Time is in the past" };
}
return { ok: true, date: jsDate };
}
export function formatCustomDay(dayOffset: number, timezone: string = DEFAULT_TIMEZONE): string {
const dt = DateTime.now().setZone(timezone).plus({ days: dayOffset });
if (dayOffset === 0) return `Today (${dt.toFormat("EEE dd MMM")})`;
if (dayOffset === 1) return `Tomorrow (${dt.toFormat("EEE dd MMM")})`;
return dt.toFormat("EEE dd MMM");
}
/**
* Parse a typed YYYY-MM-DD string into a "day offset from today" relative to
* the operator's timezone. Returns the offset in days, or null if invalid /
* in the past. We return offset rather than a Date so the rest of the picker
* (hour, minute) works the same way as the preset-day path.
*/
export function parseTypedDate(
input: string,
timezone: string = DEFAULT_TIMEZONE,
): { ok: true; dayOffset: number; label: string } | { ok: false; reason: string } {
const trimmed = input.trim();
const dt = DateTime.fromFormat(trimmed, "yyyy-MM-dd", { zone: timezone });
if (!dt.isValid) {
return { ok: false, reason: "Couldn't parse — use YYYY-MM-DD, e.g. 2026-12-25" };
}
const today = DateTime.now().setZone(timezone).startOf("day");
const targetDay = dt.startOf("day");
const diffDays = Math.round(targetDay.diff(today, "days").days);
if (diffDays < 0) {
return { ok: false, reason: "That date is in the past" };
}
return { ok: true, dayOffset: diffDays, label: targetDay.toFormat("EEE dd MMM yyyy") };
}
/** Compute the day-offset from "today" in the given timezone for a year/month/day. */
export function dayOffsetFromYMD(
year: number,
month: number,
day: number,
timezone: string = DEFAULT_TIMEZONE,
): { ok: true; dayOffset: number; label: string } | { ok: false; reason: string } {
const target = DateTime.fromObject({ year, month, day }, { zone: timezone });
if (!target.isValid) {
return { ok: false, reason: "Invalid date" };
}
const today = DateTime.now().setZone(timezone).startOf("day");
const diffDays = Math.round(target.startOf("day").diff(today, "days").days);
if (diffDays < 0) {
return { ok: false, reason: "That date is in the past" };
}
return { ok: true, dayOffset: diffDays, label: target.toFormat("EEE dd MMM yyyy") };
}
/** Today's year/month/day in a given timezone. Used by the calendar picker. */
export function todayYMD(timezone: string = DEFAULT_TIMEZONE): {
year: number;
month: number;
day: number;
} {
const now = DateTime.now().setZone(timezone);
return { year: now.year, month: now.month, day: now.day };
}
export type ParseResult = { ok: true; date: Date } | { ok: false; reason: string };
const FORMATS = [
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm",
"dd/MM/yyyy HH:mm",
"dd-MM-yyyy HH:mm",
];
export function parseFreeText(input: string, timezone: string = DEFAULT_TIMEZONE): ParseResult {
const trimmed = input.trim();
for (const fmt of FORMATS) {
const dt = DateTime.fromFormat(trimmed, fmt, { zone: timezone });
if (dt.isValid) {
const jsDate = dt.toJSDate();
if (jsDate.getTime() <= Date.now()) {
return { ok: false, reason: "Time is in the past" };
}
return { ok: true, date: jsDate };
}
}
return { ok: false, reason: "Couldn't parse — try YYYY-MM-DD HH:MM (e.g. 2026-05-15 09:00)" };
}

View File

@ -1,128 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the per-key mutex module BEFORE importing fire-reminder so the
// runtime sees our spy when it dereferences `accountMutex.run`.
vi.mock("./per-key-mutex.js", () => {
return {
PerKeyMutex: class {},
accountMutex: {
run: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
},
};
});
// Stub everything fire-reminder pulls in so the import succeeds without
// actually starting a Baileys session, hitting the DB, or talking to
// pg-boss.
const getReminderMock = vi.fn();
vi.mock("../reminders/crud.js", () => ({
getReminderWithDetails: (...args: unknown[]) => getReminderMock(...args),
}));
vi.mock("../db.js", () => ({
db: {
insert: () => ({ values: () => ({ returning: async () => [{ id: "run-1" }] }) }),
update: () => ({ set: () => ({ where: async () => undefined }) }),
query: {
whatsappGroups: { findMany: async () => [] },
mediaFiles: { findMany: async () => [] },
},
},
}));
vi.mock("../whatsapp/session-manager.js", () => ({
sessionManager: { getSession: () => null },
}));
vi.mock("../ipc/notify.js", () => ({ pgNotifyWeb: vi.fn(async () => undefined) }));
vi.mock("../audit.js", () => ({ writeAuditLog: vi.fn(async () => undefined) }));
vi.mock("./pgboss-client.js", () => ({ getBoss: () => ({}) }));
vi.mock("./reminder-jobs.js", () => ({ scheduleReminderFire: vi.fn() }));
import { fireReminder } from "./fire-reminder.js";
import { accountMutex } from "./per-key-mutex.js";
describe("fireReminder", () => {
beforeEach(() => {
vi.mocked(accountMutex.run).mockClear();
getReminderMock.mockReset();
});
it("acquires accountMutex keyed by accountId for active reminders", async () => {
getReminderMock.mockResolvedValue({
id: "r-1",
accountId: "acct-A",
status: "active",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
name: "Test",
});
await fireReminder({ reminderId: "r-1" });
expect(accountMutex.run).toHaveBeenCalledTimes(1);
expect(accountMutex.run).toHaveBeenCalledWith("acct-A", expect.any(Function));
});
it("does NOT acquire the mutex when the reminder is inactive", async () => {
getReminderMock.mockResolvedValue({
id: "r-1",
accountId: "acct-A",
status: "ended",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
name: "Test",
});
await fireReminder({ reminderId: "r-1" });
expect(accountMutex.run).not.toHaveBeenCalled();
});
it("does NOT acquire the mutex when the reminder row is missing", async () => {
getReminderMock.mockResolvedValue(undefined);
await fireReminder({ reminderId: "r-missing" });
expect(accountMutex.run).not.toHaveBeenCalled();
});
it("uses different mutex keys for different accounts (cross-account isolation)", async () => {
getReminderMock.mockResolvedValueOnce({
id: "r-A",
accountId: "acct-A",
status: "active",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
name: "A",
});
getReminderMock.mockResolvedValueOnce({
id: "r-B",
accountId: "acct-B",
status: "active",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
name: "B",
});
await fireReminder({ reminderId: "r-A" });
await fireReminder({ reminderId: "r-B" });
const calls = vi.mocked(accountMutex.run).mock.calls;
expect(calls[0]?.[0]).toBe("acct-A");
expect(calls[1]?.[0]).toBe("acct-B");
});
});

View File

@ -1,335 +0,0 @@
import { and, eq, inArray } from "drizzle-orm";
import { reminderRuns, reminderRunTargets, reminders } from "@cmbot/db";
import {
generateWAMessageContent,
generateMessageID,
type AnyMessageContent,
type proto,
type WASocket,
} from "@whiskeysockets/baileys";
import pLimit from "p-limit";
import { readFile } from "node:fs/promises";
import { db } from "../db.js";
import { logger } from "../logger.js";
import { sessionManager } from "../whatsapp/session-manager.js";
import { absoluteMediaPath, nextOccurrence, resolveDeliveryKind } from "@cmbot/shared";
import { env } from "../env.js";
import { writeAuditLog } from "../audit.js";
import { getReminderWithDetails } from "../reminders/crud.js";
import { getBoss } from "./pgboss-client.js";
import { scheduleReminderFire } from "./reminder-jobs.js";
import { pgNotifyWeb } from "../ipc/notify.js";
import { accountMutex } from "./per-key-mutex.js";
import { accountRateLimiter } from "./rate-limiter.js";
import { MediaUploadCache } from "./media-upload-cache.js";
export type FireReminderPayload = { reminderId: string };
/** Random delay between same-group message parts. Just enough for
* visible ordering in the chat at WA's natural pace. */
function partJitterMs(): number {
return 200 + Math.floor(Math.random() * 300); // 200..499
}
/** Baileys's WASocket exposes assertSessions on its internal interface,
* but it isn't part of the public type. Call it once per group before
* the first send so relayMessage doesn't trip on missing sessions. */
type SocketWithAssertSessions = WASocket & {
assertSessions?: (jids: string[], force: boolean) => Promise<boolean>;
};
async function ensureGroupSessions(socket: WASocket, groupJid: string): Promise<void> {
const internal = socket as SocketWithAssertSessions;
if (typeof internal.assertSessions !== "function") return;
const meta = await socket.groupMetadata(groupJid);
const participantJids = meta.participants.map((p) => p.id);
// Chunk so a single bad participant doesn't fail the whole group.
const CHUNK = 5;
for (let i = 0; i < participantJids.length; i += CHUNK) {
const chunk = participantJids.slice(i, i + CHUNK);
try {
await internal.assertSessions(chunk, true);
} catch (err) {
logger.warn(
{ groupJid, err: (err as Error).message },
"fire-reminder: assertSessions chunk failed",
);
}
}
}
export async function fireReminder(payload: FireReminderPayload): Promise<void> {
const reminder = await getReminderWithDetails(payload.reminderId);
if (!reminder) {
logger.warn({ reminderId: payload.reminderId }, "fire-reminder: reminder not found");
return;
}
if (reminder.status !== "active") {
logger.info({ reminderId: reminder.id, status: reminder.status }, "fire-reminder: skipping (not active)");
return;
}
// Per-account mutex: two reminders on the SAME account take turns
// (running them concurrently would double the effective send rate
// and risk a ban). Different accounts run in parallel.
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder));
}
async function fireReminderInner(
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
): Promise<void> {
const [run] = await db
.insert(reminderRuns)
.values({
reminderId: reminder.id,
reminderName: reminder.name,
status: "pending",
})
.returning({ id: reminderRuns.id });
const runId = run!.id;
const session = sessionManager.getSession(reminder.accountId);
if (!session) {
logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected");
await markAllSkipped(runId, reminder, "account not connected");
await db
.update(reminderRuns)
.set({ status: "skipped", errorSummary: "account not connected" })
.where(eq(reminderRuns.id, runId));
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status: "skipped" });
return;
}
// Up-front bulk loads. Drops ~3000 round-trips to ~3 for a 1000-group run.
const groupIds = reminder.targets.map((t) => t.groupId);
const groupRows = groupIds.length
? await db.query.whatsappGroups.findMany({ where: (g) => inArray(g.id, groupIds) })
: [];
const groupById = new Map(groupRows.map((g) => [g.id, g]));
const mediaIds = Array.from(
new Set(reminder.messages.map((m) => m.mediaId).filter((id): id is string => Boolean(id))),
);
const mediaRows = mediaIds.length
? await db.query.mediaFiles.findMany({ where: (m) => inArray(m.id, mediaIds) })
: [];
const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
// Pre-create run_target rows so the Activity tab shows progress mid-run.
if (reminder.targets.length > 0) {
await db.insert(reminderRunTargets).values(
reminder.targets.map((t) => ({
runId,
groupId: t.groupId,
groupLabel: groupById.get(t.groupId)?.name ?? null,
status: "pending" as const,
})),
);
}
// Per-run media upload cache. Each unique mediaId is prepared via
// generateWAMessageContent ONCE (which uploads to WA's CDN through
// the socket's waUploadToServer); the resulting proto.Message is
// reused for every group via socket.relayMessage. For 1000 groups
// × 5 MB image, this turns 5 GB of upload into 5 MB.
const uploadCache = new MediaUploadCache<proto.IMessage>(async (mediaId) => {
const media = mediaById.get(mediaId);
if (!media) throw new Error(`media row missing: ${mediaId}`);
const filePath = absoluteMediaPath(media.storagePath, env.MEDIA_DIR);
const buffer = await readFile(filePath);
const head = buffer.subarray(0, 12);
const resolved = resolveDeliveryKind(media.mimeType, head);
const senderKind: "image" | "video" | "document" =
resolved === "image" || resolved === "video" ? resolved : "document";
const content: AnyMessageContent =
senderKind === "image"
? { image: buffer, mimetype: media.mimeType }
: senderKind === "video"
? { video: buffer, mimetype: media.mimeType }
: {
document: buffer,
fileName: media.filenameOriginal,
mimetype: media.mimeType,
};
return generateWAMessageContent(content, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
upload: (session.socket as any).waUploadToServer,
});
});
// Per-account rate limiter — gates each socket send to stay within
// the account's safe band (BOT_MAX_SEND_PER_MINUTE, default 40).
const rateLimiter = accountRateLimiter.get(reminder.accountId);
let sentCount = 0;
let failedCount = 0;
let skippedCount = 0;
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
await Promise.all(
reminder.targets.map((target) =>
groupConcurrency(async () => {
const group = groupById.get(target.groupId);
if (!group) {
await db
.update(reminderRunTargets)
.set({ status: "skipped", error: "group missing from db" })
.where(
and(
eq(reminderRunTargets.runId, runId),
eq(reminderRunTargets.groupId, target.groupId),
),
);
skippedCount++;
return;
}
const start = Date.now();
try {
// Once per group, before the first send. sendMessage handles
// sessions internally; relayMessage does not.
await ensureGroupSessions(session.socket, group.waGroupJid);
let lastMessageId: string | undefined;
for (const part of reminder.messages) {
await rateLimiter.acquire();
if (part.kind === "text" && part.textContent) {
const r = await session.socket.sendMessage(group.waGroupJid, {
text: part.textContent,
});
lastMessageId = r?.key?.id ?? undefined;
} else if (part.mediaId) {
const prebuilt = await uploadCache.get(part.mediaId);
if (part.textContent) injectCaption(prebuilt, part.textContent);
const messageId = generateMessageID();
await session.socket.relayMessage(group.waGroupJid, prebuilt, { messageId });
lastMessageId = messageId;
}
await new Promise((r) => setTimeout(r, partJitterMs()));
}
await db
.update(reminderRunTargets)
.set({
status: "sent",
waMessageId: lastMessageId ?? null,
latencyMs: Date.now() - start,
})
.where(
and(
eq(reminderRunTargets.runId, runId),
eq(reminderRunTargets.groupId, target.groupId),
),
);
sentCount++;
} catch (err) {
logger.error(
{ err, reminderId: reminder.id, groupId: target.groupId },
"fire-reminder: send failed",
);
await db
.update(reminderRunTargets)
.set({ status: "failed", error: (err as Error).message })
.where(
and(
eq(reminderRunTargets.runId, runId),
eq(reminderRunTargets.groupId, target.groupId),
),
);
failedCount++;
}
}),
),
);
const total = reminder.targets.length;
let status: "success" | "partial" | "failed";
let errorSummary: string | null = null;
if (sentCount === total) {
status = "success";
} else if (sentCount > 0) {
status = "partial";
errorSummary = `${sentCount} of ${total} groups delivered (${failedCount} failed, ${skippedCount} skipped).`;
} else {
status = "failed";
errorSummary = total === 0 ? "No targets attached to reminder." : `All ${total} sends failed.`;
}
await db
.update(reminderRuns)
.set({ status, errorSummary })
.where(eq(reminderRuns.id, runId));
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status });
if (reminder.scheduleKind === "one_off") {
await db
.update(reminders)
.set({ status: "ended", updatedAt: new Date() })
.where(eq(reminders.id, reminder.id));
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
await db
.update(reminders)
.set({ lastFiredAt: new Date(), updatedAt: new Date() })
.where(eq(reminders.id, reminder.id));
if (next) {
try {
await scheduleReminderFire(getBoss(), reminder.id, next);
logger.info({ reminderId: reminder.id, next }, "fire-reminder: re-armed for next occurrence");
} catch (err) {
logger.error({ err, reminderId: reminder.id }, "fire-reminder: failed to re-arm next occurrence");
}
} else {
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
}
}
await writeAuditLog(db, {
operatorId: reminder.createdBy,
source: "system",
action: "reminder.fired",
targetType: "reminder",
targetId: reminder.id,
payload: { runId, status, sent: sentCount, failed: failedCount, skipped: skippedCount },
});
logger.info(
{ reminderId: reminder.id, runId, status, sent: sentCount, failed: failedCount, skipped: skippedCount },
"fire-reminder: done",
);
}
async function markAllSkipped(
runId: string,
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
error: string,
): Promise<void> {
if (reminder.targets.length === 0) return;
const rows = await db.query.whatsappGroups.findMany({
where: (g) => inArray(g.id, reminder.targets.map((t) => t.groupId)),
columns: { id: true, name: true },
});
const labelById = new Map(rows.map((r) => [r.id, r.name]));
await db.insert(reminderRunTargets).values(
reminder.targets.map((t) => ({
runId,
groupId: t.groupId,
groupLabel: labelById.get(t.groupId) ?? null,
status: "skipped" as const,
error,
})),
);
}
/**
* Inject the caption into the prebuilt media message. Baileys' relayMessage
* doesn't take a caption alongside the content; the protobuf already has
* the slot, so we mutate it just before relaying.
*/
function injectCaption(msg: proto.IMessage, caption: string): void {
if (msg.imageMessage) msg.imageMessage.caption = caption;
else if (msg.videoMessage) msg.videoMessage.caption = caption;
else if (msg.documentMessage) msg.documentMessage.caption = caption;
}

View File

@ -1,70 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { MediaUploadCache } from "./media-upload-cache.js";
describe("MediaUploadCache", () => {
it("uploads each unique mediaId exactly once across N gets", async () => {
const prepare = vi.fn(async (mediaId: string) => ({
kind: "prepared",
mediaId,
}));
const cache = new MediaUploadCache(prepare);
const a1 = await cache.get("media-A");
const a2 = await cache.get("media-A");
const b1 = await cache.get("media-B");
expect(prepare).toHaveBeenCalledTimes(2);
expect(prepare).toHaveBeenCalledWith("media-A");
expect(prepare).toHaveBeenCalledWith("media-B");
expect(a1).toBe(a2);
expect(a1).not.toBe(b1);
});
it("coalesces concurrent gets of the same mediaId into ONE prepare call", async () => {
let resolveA: (v: unknown) => void = () => {};
const aPromise = new Promise((r) => (resolveA = r));
const prepare = vi.fn(async (mediaId: string) => {
if (mediaId === "media-A") return aPromise;
return { kind: "prepared", mediaId };
});
const cache = new MediaUploadCache(prepare);
const p1 = cache.get("media-A");
const p2 = cache.get("media-A");
const p3 = cache.get("media-A");
resolveA({ kind: "prepared", mediaId: "media-A" });
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
expect(prepare).toHaveBeenCalledTimes(1);
expect(r1).toBe(r2);
expect(r2).toBe(r3);
});
it("a thrown prepare is NOT cached — next get retries", async () => {
let attempt = 0;
const prepare = vi.fn(async (_mediaId: string) => {
attempt++;
if (attempt === 1) throw new Error("upload network blip");
return { kind: "prepared", attempt };
});
const cache = new MediaUploadCache(prepare);
await expect(cache.get("media-A")).rejects.toThrow("upload network blip");
const r = await cache.get("media-A");
expect(prepare).toHaveBeenCalledTimes(2);
expect(r).toEqual({ kind: "prepared", attempt: 2 });
});
it("size() reflects the number of cached unique mediaIds", async () => {
const prepare = async (mediaId: string) => ({ mediaId });
const cache = new MediaUploadCache(prepare);
expect(cache.size()).toBe(0);
await cache.get("a");
expect(cache.size()).toBe(1);
await cache.get("b");
expect(cache.size()).toBe(2);
await cache.get("a"); // already cached
expect(cache.size()).toBe(2);
});
});

View File

@ -1,44 +0,0 @@
/**
* Per-run cache of `prepareWAMessageMedia` results, keyed by
* `mediaId`. The point: when a reminder fans out to 1000 groups with
* one image, we want to upload that image to WhatsApp's CDN ONCE, not
* 1000 times. Subsequent group sends reuse the prepared message
* (with embedded directPath / mediaKey) via socket.relayMessage.
*
* Lifecycle: one cache instance per fire-reminder run. After the run
* completes, the cache is dropped we don't share uploads across
* runs because WA media tokens are short-lived.
*
* Concurrent gets of the same mediaId are coalesced into a single
* prepare call. Failed prepares are NOT cached so the next attempt
* retries (network blips at upload time shouldn't poison the cache).
*/
export class MediaUploadCache<T> {
private readonly prepare: (mediaId: string) => Promise<T>;
private readonly entries = new Map<string, Promise<T>>();
constructor(prepare: (mediaId: string) => Promise<T>) {
this.prepare = prepare;
}
async get(mediaId: string): Promise<T> {
const existing = this.entries.get(mediaId);
if (existing) return existing;
const inflight = this.prepare(mediaId);
// Insert eagerly so concurrent gets dedupe.
this.entries.set(mediaId, inflight);
try {
return await inflight;
} catch (err) {
// Don't cache failures — the next caller should retry.
this.entries.delete(mediaId);
throw err;
}
}
size(): number {
return this.entries.size;
}
}

View File

@ -1,93 +0,0 @@
import { describe, it, expect } from "vitest";
import { PerKeyMutex } from "./per-key-mutex.js";
/** Tiny clock-free helper: returns a Promise that resolves after
* `n` microtasks. Lets us check ordering without real timers. */
function tickN(n: number): Promise<void> {
let p: Promise<void> = Promise.resolve();
for (let i = 0; i < n; i++) p = p.then();
return p;
}
describe("PerKeyMutex", () => {
it("allows a single call against one key to run immediately", async () => {
const m = new PerKeyMutex();
const result = await m.run("k1", async () => 42);
expect(result).toBe(42);
});
it("serialises two calls against the same key", async () => {
const m = new PerKeyMutex();
const order: string[] = [];
const a = m.run("k1", async () => {
order.push("a-start");
await tickN(5);
order.push("a-end");
});
const b = m.run("k1", async () => {
order.push("b-start");
order.push("b-end");
});
await Promise.all([a, b]);
expect(order).toEqual(["a-start", "a-end", "b-start", "b-end"]);
});
it("runs different keys in parallel", async () => {
const m = new PerKeyMutex();
const order: string[] = [];
const a = m.run("k1", async () => {
order.push("a-start");
await tickN(5);
order.push("a-end");
});
const b = m.run("k2", async () => {
order.push("b-start");
order.push("b-end");
});
await Promise.all([a, b]);
expect(order[0]).toBe("a-start");
expect(order).toContain("b-start");
expect(order).toContain("b-end");
// b's pair lands before a's end (they run in parallel).
expect(order.indexOf("b-end")).toBeLessThan(order.indexOf("a-end"));
});
it("releases the lock when the handler throws", async () => {
const m = new PerKeyMutex();
await expect(
m.run("k1", async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
const result = await m.run("k1", async () => "after");
expect(result).toBe("after");
});
it("forwards the resolved value of the handler", async () => {
const m = new PerKeyMutex();
const out = await m.run("k1", async () => ({ ok: true, n: 7 }));
expect(out).toEqual({ ok: true, n: 7 });
});
it("cleans up internal state for keys with no waiters", async () => {
const m = new PerKeyMutex();
await m.run("k1", async () => {});
expect(m.activeKeyCount()).toBe(0);
});
it("retains a key while a chain is in flight, then drops it", async () => {
const m = new PerKeyMutex();
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const inFlight = m.run("k1", () => gate);
expect(m.activeKeyCount()).toBe(1);
release();
await inFlight;
expect(m.activeKeyCount()).toBe(0);
});
});

View File

@ -1,48 +0,0 @@
/**
* Async mutex keyed by a string. Different keys run in parallel;
* same-key calls serialise.
*
* Used by fire-reminder so two reminders on the SAME WhatsApp account
* take turns (running them concurrently would double the effective
* send rate and risk a ban), while reminders on DIFFERENT accounts
* proceed in parallel.
*
* Implementation is a chain-per-key Promise: each call appends its
* work to the key's tail. Empty chains are cleaned up so the Map
* doesn't grow unbounded across the bot's lifetime.
*/
export class PerKeyMutex {
private chains = new Map<string, Promise<void>>();
async run<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = this.chains.get(key) ?? Promise.resolve();
let release!: () => void;
const completion = new Promise<void>((r) => (release = r));
const chained = prev.then(() => completion);
this.chains.set(key, chained);
try {
await prev;
return await fn();
} finally {
release();
// Drop the entry only if no later caller has appended in the
// meantime — otherwise we'd evict the in-flight chain.
if (this.chains.get(key) === chained) {
this.chains.delete(key);
}
}
}
activeKeyCount(): number {
return this.chains.size;
}
}
/**
* Singleton mutex used by fire-reminder, keyed by accountId. Lives at
* module scope so multiple pg-boss workers in the same process share
* state.
*/
export const accountMutex = new PerKeyMutex();

View File

@ -1,32 +0,0 @@
import { PgBoss } from "pg-boss";
import { env } from "../env.js";
import { logger } from "../logger.js";
let boss: PgBoss | null = null;
export async function startBoss(): Promise<PgBoss> {
if (boss) return boss;
const instance = new PgBoss({
connectionString: env.DATABASE_URL,
schema: "pgboss",
});
instance.on("error", (err: unknown) => logger.error({ err }, "pg-boss: error"));
await instance.start();
boss = instance;
logger.info("pg-boss started");
return instance;
}
export async function stopBoss(): Promise<void> {
if (!boss) return;
await boss.stop({ graceful: true, timeout: 5000 });
boss = null;
logger.info("pg-boss stopped");
}
export { PgBoss };
export function getBoss(): PgBoss {
if (!boss) throw new Error("pg-boss not started");
return boss;
}

View File

@ -1,104 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { TokenBucket, accountRateLimiter } from "./rate-limiter.js";
describe("TokenBucket", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-10T10:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("starts full: first N=capacity acquires resolve immediately", async () => {
const b = new TokenBucket({ ratePerMinute: 60, capacity: 5 });
for (let i = 0; i < 5; i++) {
await b.acquire();
}
});
it("blocks the (capacity+1)th acquire until a token regenerates", async () => {
// 60/min = 1 token per second. Capacity 2.
const b = new TokenBucket({ ratePerMinute: 60, capacity: 2 });
await b.acquire();
await b.acquire();
let resolved = false;
const pending = b.acquire().then(() => {
resolved = true;
});
await Promise.resolve();
expect(resolved).toBe(false);
await vi.advanceTimersByTimeAsync(1000);
await pending;
expect(resolved).toBe(true);
});
it("FIFO: pending acquires resolve in the order they arrived", async () => {
const b = new TokenBucket({ ratePerMinute: 60, capacity: 1 });
await b.acquire(); // bucket empty
const order: number[] = [];
const a = b.acquire().then(() => order.push(1));
const c = b.acquire().then(() => order.push(2));
await vi.advanceTimersByTimeAsync(2000);
await Promise.all([a, c]);
expect(order).toEqual([1, 2]);
});
it("does not over-fill past capacity even if the clock leaps forward", async () => {
const b = new TokenBucket({ ratePerMinute: 60, capacity: 3 });
await b.acquire();
await b.acquire();
await b.acquire();
// Leap an hour. Naive impl would credit 3600 tokens; we should cap at 3.
await vi.advanceTimersByTimeAsync(3_600_000);
await b.acquire();
await b.acquire();
await b.acquire();
let resolved = false;
b.acquire().then(() => (resolved = true));
await Promise.resolve();
expect(resolved).toBe(false);
});
it("ratePerMinute=0 is rejected at construction", () => {
expect(() => new TokenBucket({ ratePerMinute: 0, capacity: 1 })).toThrow();
});
});
describe("accountRateLimiter (singleton)", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-10T10:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("returns the SAME bucket for repeated lookups of one accountId", () => {
const a1 = accountRateLimiter.get("acct-1");
const a2 = accountRateLimiter.get("acct-1");
expect(a1).toBe(a2);
});
it("returns DIFFERENT buckets for different accountIds (isolation)", () => {
const a = accountRateLimiter.get("acct-A");
const b = accountRateLimiter.get("acct-B");
expect(a).not.toBe(b);
});
it("a drained account A bucket does not block account B", async () => {
const a = accountRateLimiter.get("acct-A");
const b = accountRateLimiter.get("acct-B");
for (let i = 0; i < 40; i++) await a.acquire();
let bResolved = false;
b.acquire().then(() => (bResolved = true));
await Promise.resolve();
expect(bResolved).toBe(true);
});
});

View File

@ -1,95 +0,0 @@
import { env } from "../env.js";
/**
* Token bucket for per-account send pacing.
*
* Tokens regenerate at `ratePerMinute / 60` per second. Capacity caps
* how many can accumulate during idle periods (so the operator can't
* burst 1000 messages just because the account was quiet for a day).
*
* `acquire()` resolves when a token is available, FIFO across waiters.
* Used by fire-reminder to gate every `socket.sendMessage` call.
*/
export interface TokenBucketOptions {
ratePerMinute: number;
/** Defaults to ratePerMinute (one minute's worth). */
capacity?: number;
}
export class TokenBucket {
private readonly ratePerMs: number;
private readonly capacity: number;
private tokens: number;
private lastRefillMs: number;
private waiters: Array<() => void> = [];
constructor(opts: TokenBucketOptions) {
if (opts.ratePerMinute <= 0) {
throw new Error(`TokenBucket: ratePerMinute must be > 0, got ${opts.ratePerMinute}`);
}
this.ratePerMs = opts.ratePerMinute / 60_000;
this.capacity = opts.capacity ?? opts.ratePerMinute;
this.tokens = this.capacity;
this.lastRefillMs = Date.now();
}
/** Resolve when a token is available. FIFO across concurrent waiters. */
async acquire(): Promise<void> {
this.refill();
if (this.tokens >= 1 && this.waiters.length === 0) {
this.tokens -= 1;
return;
}
return new Promise<void>((resolve) => {
this.waiters.push(resolve);
this.scheduleNext();
});
}
private refill(): void {
const now = Date.now();
const elapsed = now - this.lastRefillMs;
if (elapsed <= 0) return;
const gained = elapsed * this.ratePerMs;
this.tokens = Math.min(this.capacity, this.tokens + gained);
this.lastRefillMs = now;
}
private scheduleNext(): void {
this.refill();
while (this.tokens >= 1 && this.waiters.length > 0) {
this.tokens -= 1;
const w = this.waiters.shift()!;
w();
}
if (this.waiters.length === 0) return;
const tokensShort = 1 - this.tokens;
const waitMs = Math.max(1, Math.ceil(tokensShort / this.ratePerMs));
setTimeout(() => this.scheduleNext(), waitMs);
}
}
/**
* Per-accountId TokenBucket registry. Each account gets its own
* pacing budget, so a slow account A never throttles account B.
*/
class AccountRateLimiter {
private buckets = new Map<string, TokenBucket>();
private ratePerMinute: number;
constructor(ratePerMinute: number) {
this.ratePerMinute = ratePerMinute;
}
get(accountId: string): TokenBucket {
let b = this.buckets.get(accountId);
if (!b) {
b = new TokenBucket({ ratePerMinute: this.ratePerMinute });
this.buckets.set(accountId, b);
}
return b;
}
}
export const accountRateLimiter = new AccountRateLimiter(env.BOT_MAX_SEND_PER_MINUTE);

View File

@ -1,58 +0,0 @@
import type { PgBoss } from "pg-boss";
import { logger } from "../logger.js";
import { env } from "../env.js";
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
export const REMINDER_FIRE_QUEUE = "reminder.fire";
export async function registerReminderJobs(boss: PgBoss): Promise<void> {
await boss.createQueue(REMINDER_FIRE_QUEUE);
await boss.work<FireReminderPayload>(
REMINDER_FIRE_QUEUE,
{
// Up to BOT_FIRE_CONCURRENCY workers per node, each polling and
// processing independently. Combined with the per-account mutex
// inside fireReminder, this lets reminders on DIFFERENT accounts
// run in parallel while same-account reminders take turns.
localConcurrency: env.BOT_FIRE_CONCURRENCY,
},
async (jobs) => {
const job = jobs[0];
if (!job) return;
logger.debug({ jobId: job.id, payload: job.data }, "reminder.fire: handling");
await fireReminder(job.data);
},
);
logger.info(
{ localConcurrency: env.BOT_FIRE_CONCURRENCY },
"reminder.fire: handler registered",
);
}
export async function scheduleReminderFire(
boss: PgBoss,
reminderId: string,
scheduledAt: Date,
): Promise<string | null> {
const id = await boss.send(
REMINDER_FIRE_QUEUE,
{ reminderId },
{
startAfter: scheduledAt,
retryLimit: 3,
retryDelay: 30,
retryBackoff: true,
// Use the reminderId as a singleton key so re-scheduling cancels the old job
singletonKey: `reminder:${reminderId}`,
},
);
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");
return id;
}
export async function cancelReminderFire(_boss: PgBoss, reminderId: string): Promise<void> {
// Soft cancel: pg-boss doesn't expose a clean cancel-by-singleton API in v12.
// The scheduled job will still fire, but `fireReminder` exits early when the
// reminder row is gone. Hard cancel can be added later by storing the jobId.
logger.info({ reminderId }, "reminder.fire: cancel requested (soft, fizzles on fire)");
}

View File

@ -0,0 +1,143 @@
import { Bot } from "grammy";
import { env } from "../env.js";
import { logger } from "../logger.js";
import { makeWhitelistMiddleware } from "./middleware/whitelist.js";
import { auditMiddleware } from "./middleware/audit.js";
import { handleHelp } from "./commands/help.js";
import { handlePair, executePairFlow } from "./commands/pair.js";
import { handleUnpair } from "./commands/unpair.js";
import { handleGroups } from "./commands/groups.js";
import {
showMainMenu,
showHelpMenu,
showAccountsMenu,
showAccountDetail,
showGroupsList,
showUnpairConfirm,
executeUnpair,
showPairPrompt,
showGroupDetail,
showSendTestPrompt,
executeSendTest,
refreshGroupsList,
} from "./callbacks.js";
import {
consumePendingPairLabel,
clearPendingPairLabel,
consumePendingSendToGroup,
clearPendingSendToGroup,
} from "./state.js";
export function createTelegramBot(): Bot {
const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
bot.use(makeWhitelistMiddleware(env.TELEGRAM_OPERATOR_WHITELIST));
bot.use(auditMiddleware);
// Slash commands. /start and /menu both open the main menu.
bot.command(["start", "menu"], async (ctx) => {
const tgId = ctx.from?.id;
if (tgId !== undefined) {
clearPendingPairLabel(tgId);
clearPendingSendToGroup(tgId);
}
await showMainMenu(ctx);
});
bot.command("help", handleHelp);
bot.command("pair", handlePair);
bot.command("unpair", handleUnpair);
bot.command("accounts", async (ctx) => {
// Backward-compatible: /accounts now opens the accounts menu in the same chat.
await showAccountsMenu(ctx);
});
bot.command("groups", handleGroups);
// Inline keyboard callbacks. Prefixes keep callback_data well under 64 bytes.
bot.callbackQuery("m:main", async (ctx) => {
const tgId = ctx.from?.id;
if (tgId !== undefined) {
clearPendingPairLabel(tgId);
clearPendingSendToGroup(tgId);
}
await ctx.answerCallbackQuery();
await showMainMenu(ctx);
});
bot.callbackQuery("m:accounts", showAccountsMenu);
bot.callbackQuery("m:help", showHelpMenu);
bot.callbackQuery("m:pair", showPairPrompt);
bot.callbackQuery(/^acc:(.+)$/, async (ctx) => {
await showAccountDetail(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^g:(.+)$/, async (ctx) => {
await showGroupsList(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^u:(.+)$/, async (ctx) => {
await showUnpairConfirm(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^uc:(.+)$/, async (ctx) => {
await executeUnpair(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^gr:(.+)$/, async (ctx) => {
await showGroupDetail(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^st:(.+)$/, async (ctx) => {
await showSendTestPrompt(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^rs:(.+)$/, async (ctx) => {
await refreshGroupsList(ctx, ctx.match[1]!);
});
// Plain-text messages: if the operator is in the "pending pair label" state
// (because they tapped 📡 Pair New), treat their next non-command message as
// the label. Otherwise, gently nudge them toward /menu.
bot.on("message:text", async (ctx) => {
const text = ctx.message?.text ?? "";
if (text.startsWith("/")) return; // commands are handled above
const tgId = ctx.from?.id;
if (tgId === undefined) return;
// Pending "Pair New" label
if (consumePendingPairLabel(tgId)) {
const label = text.trim().replace(/^["'“”‘’]|["'“”‘’]$/g, "");
if (!label) {
await ctx.reply("That label is empty. Tap /menu and try again.");
return;
}
await executePairFlow(ctx, label);
return;
}
// Pending "Send Test" message body
const pendingGroupId = consumePendingSendToGroup(tgId);
if (pendingGroupId) {
const body = text.trim();
if (!body) {
await ctx.reply("Empty message. Tap /menu and try again.");
return;
}
await executeSendTest(ctx, pendingGroupId, body);
return;
}
await ctx.reply("Tap /menu to see what I can do.");
});
bot.catch((err) => {
logger.error({ err }, "telegram error");
});
// Populate Telegram's slash menu with our commands.
void bot.api
.setMyCommands([
{ command: "menu", description: "Open the main menu" },
{ command: "start", description: "Open the main menu" },
{ command: "accounts", description: "List paired WhatsApp accounts" },
{ command: "pair", description: "Pair a new account (usage: /pair Label)" },
{ command: "unpair", description: "Unpair an account (usage: /unpair Label)" },
{ command: "groups", description: "List groups for an account (usage: /groups Label)" },
{ command: "help", description: "Show command help" },
])
.catch((err) => logger.warn({ err }, "setMyCommands failed"));
return bot;
}

View File

@ -0,0 +1,252 @@
import type { Context } from "grammy";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { eq } from "drizzle-orm";
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 { writeAuditLog } from "../audit.js";
import { setPendingPairLabel, setPendingSendToGroup } from "./state.js";
import { sendTextToGroup } from "../whatsapp/sender.js";
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
import {
mainMenu,
helpMenu,
pairPromptMenu,
accountsMenu,
accountDetailMenu,
groupsListMenu,
groupDetailMenu,
sendTestPromptMenu,
sendTestDoneMenu,
unpairConfirmMenu,
unpairDoneMenu,
type MenuView,
} from "./menus.js";
async function findOperator(ctx: Context) {
const tgId = ctx.from?.id;
if (!tgId) return null;
return db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, tgId),
});
}
// Edit the current message to render a new menu view. Falls back to a fresh
// reply if the previous message can't be edited (e.g. a photo message — Telegram
// won't let us turn it back into a text message).
async function showMenu(ctx: Context, view: MenuView): Promise<void> {
try {
await ctx.editMessageText(view.text, {
reply_markup: view.keyboard,
parse_mode: "Markdown",
});
} catch (err) {
logger.debug({ err }, "showMenu: edit failed, sending fresh message");
await ctx.reply(view.text, {
reply_markup: view.keyboard,
parse_mode: "Markdown",
});
}
}
export async function showMainMenu(ctx: Context): Promise<void> {
await showMenu(ctx, mainMenu());
}
export async function showHelpMenu(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
await showMenu(ctx, helpMenu());
}
export async function showAccountsMenu(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await accountsMenu(op.id);
await showMenu(ctx, view);
}
export async function showAccountDetail(ctx: Context, accountId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await accountDetailMenu(op.id, accountId);
if (!view) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
return;
}
await showMenu(ctx, view);
}
export async function showGroupsList(ctx: Context, accountId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await groupsListMenu(op.id, accountId);
if (!view) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
return;
}
await showMenu(ctx, view);
}
export async function refreshGroupsList(ctx: Context, accountId: string): Promise<void> {
const op = await findOperator(ctx);
if (!op) {
await ctx.answerCallbackQuery();
return;
}
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!account) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
return;
}
const session = sessionManager.getSession(accountId);
if (!session) {
await ctx.answerCallbackQuery({
text: "Account not connected. Re-pair first.",
show_alert: true,
});
return;
}
await ctx.answerCallbackQuery({ text: "Refreshing…" });
try {
const result = await syncGroupsForAccount(accountId, session.socket);
logger.info({ accountId, count: result.synced }, "refreshGroupsList: ok");
} catch (err) {
logger.error({ err, accountId }, "refreshGroupsList: failed");
}
const view = await groupsListMenu(op.id, accountId);
if (view) await showMenu(ctx, view);
}
export async function showUnpairConfirm(ctx: Context, accountId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await unpairConfirmMenu(op.id, accountId);
if (!view) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
return;
}
await showMenu(ctx, view);
}
export async function executeUnpair(ctx: Context, accountId: string): Promise<void> {
const op = await findOperator(ctx);
if (!op) {
await ctx.answerCallbackQuery();
return;
}
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!account) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
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: op.id,
source: "telegram",
action: "account.unpaired",
targetType: "whatsapp_account",
targetId: accountId,
payload: { label: account.label, via: "menu" },
});
await ctx.answerCallbackQuery({ text: "Unpaired." });
await showMenu(ctx, unpairDoneMenu(account.label));
}
export async function showPairPrompt(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const userId = ctx.from?.id;
if (userId) setPendingPairLabel(userId);
await showMenu(ctx, pairPromptMenu());
}
export async function showGroupDetail(ctx: Context, groupId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await groupDetailMenu(op.id, groupId);
if (!view) {
await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true });
return;
}
await showMenu(ctx, view);
}
export async function showSendTestPrompt(ctx: Context, groupId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, groupId),
});
if (!group) {
await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true });
return;
}
// Verify the group's account belongs to this operator before stashing state.
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, op.id)),
});
if (!account) {
await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true });
return;
}
const userId = ctx.from?.id;
if (userId) setPendingSendToGroup(userId, groupId);
await showMenu(ctx, sendTestPromptMenu(group.name));
}
export async function executeSendTest(
ctx: Context,
groupId: string,
text: string,
): Promise<void> {
const op = await findOperator(ctx);
if (!op) return;
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, groupId),
});
if (!group) {
await ctx.reply("Group not found.");
return;
}
const session = sessionManager.getSession(group.accountId);
if (!session) {
await ctx.reply("That account isn't currently connected. Re-pair it first.", {
reply_markup: sendTestDoneMenu(group.name, false, "session not connected").keyboard,
});
return;
}
try {
const result = await sendTextToGroup(session.socket, group.waGroupJid, text);
await writeAuditLog(db, {
operatorId: op.id,
source: "telegram",
action: "group.send_test",
targetType: "whatsapp_group",
targetId: groupId,
payload: { groupName: group.name, length: text.length, waMessageId: result.messageId ?? null },
});
const view = sendTestDoneMenu(group.name, true);
await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" });
} catch (err) {
logger.error({ err, groupId }, "send-test: failed");
const view = sendTestDoneMenu(group.name, false, (err as Error).message);
await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" });
}
}

View File

@ -0,0 +1,36 @@
import type { Context } from "grammy";
import { InlineKeyboard } from "grammy";
import { db } from "../../db.js";
import { sessionManager } from "../../whatsapp/session-manager.js";
export async function handleAccounts(ctx: Context): Promise<void> {
const operatorId = ctx.from?.id;
if (!operatorId) return;
const operatorRow = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, operatorId),
});
if (!operatorRow) return;
const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorRow.id),
orderBy: (a, { asc }) => [asc(a.label)],
});
if (accounts.length === 0) {
await ctx.reply('No accounts paired yet. Send /pair YourLabel to add one.');
return;
}
// One message per account so each gets its own action buttons. Keeps
// callback_data short and avoids hitting Telegram's per-message limits.
for (const a of accounts) {
const live = sessionManager.getState(a.id);
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
const text = `📒 ${a.label}${phone}\nstatus: ${a.status} (live: ${live})`;
const kb = new InlineKeyboard()
.text("📂 Groups", `g:${a.id}`)
.text("🗑 Unpair", `u:${a.id}`);
await ctx.reply(text, { reply_markup: kb });
}
}

View File

@ -0,0 +1,44 @@
import type { Context } from "grammy";
import { db } from "../../db.js";
export async function handleGroups(ctx: Context): Promise<void> {
const text = ctx.message?.text ?? "";
const label = text
.replace(/^\/groups\s*/, "")
.trim()
.replace(/^["'“”‘’]|["'“”‘’]$/g, "");
if (!label) {
await ctx.reply('Usage: /groups "Account Label"');
return;
}
const operatorId = ctx.from?.id;
if (!operatorId) return;
const operatorRow = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, operatorId),
});
if (!operatorRow) return;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
});
if (!account) {
await ctx.reply(`No account labelled "${label}".`);
return;
}
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq }) => eq(g.accountId, account.id),
orderBy: (g, { asc }) => [asc(g.name)],
});
if (groups.length === 0) {
await ctx.reply(`No groups synced for "${label}" yet.`);
return;
}
const lines = groups.slice(0, 50).map((g) => `${g.name} (${g.participantCount})`);
const overflow = groups.length > 50 ? `\n…and ${groups.length - 50} more` : "";
await ctx.reply(`👥 Groups in "${label}":\n${lines.join("\n")}${overflow}`);
}

View File

@ -0,0 +1,13 @@
import type { Context } from "grammy";
export async function handleHelp(ctx: Context): Promise<void> {
await ctx.reply(
"Available commands:\n\n" +
"/start — show the welcome message\n" +
"/help — show this help\n" +
"/pair <label> — pair a new WhatsApp account\n" +
"/unpair <label> — disconnect and forget a paired account\n" +
"/accounts — list paired accounts and connection status\n" +
"/groups <label> — list groups for a given account",
);
}

View File

@ -0,0 +1,178 @@
import type { Context } from "grammy";
import { InputFile } from "grammy";
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 { setPendingPairLabel } from "../state.js";
import { InlineKeyboard } from "grammy";
// Per-account state for the pairing flow. Re-running /pair for the same
// account tears down the previous flow before starting a new one so we never
// have multiple listeners fighting over the same Telegram message.
const qrMessageIdByAccount = new Map<string, number>();
const lastQrPayloadByAccount = new Map<string, string>();
const offByAccount = new Map<string, () => void>();
async function cancelExistingFlow(accountId: string): Promise<void> {
const off = offByAccount.get(accountId);
if (off) {
off();
offByAccount.delete(accountId);
}
qrMessageIdByAccount.delete(accountId);
lastQrPayloadByAccount.delete(accountId);
if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId);
}
// Wipe any half-baked session creds so the new flow gets a fresh QR
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
}
export async function handlePair(ctx: Context): Promise<void> {
const text = ctx.message?.text ?? "";
const label = text
.replace(/^\/pair\s*/, "")
.trim()
.replace(/^["'“”‘’]|["'“”‘’]$/g, "");
if (!label) {
// No label after /pair — set pending state and prompt the operator to
// reply with a label as a regular message.
const tgId = ctx.from?.id;
if (tgId !== undefined) setPendingPairLabel(tgId);
const kb = new InlineKeyboard().text("⬅ Cancel", "m:main");
await ctx.reply(
"📡 *Pair a new account*\n\n" +
"What name should I give this WhatsApp account?\n\n" +
"Reply to this message with a short label, e.g. `Sales 1`.",
{ reply_markup: kb, parse_mode: "Markdown" },
);
return;
}
await executePairFlow(ctx, label);
}
export async function executePairFlow(ctx: Context, label: string): Promise<void> {
const operatorId = ctx.from?.id;
if (!operatorId) return;
const operatorRow = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, operatorId),
});
if (!operatorRow) {
await ctx.reply("Your Telegram ID is whitelisted but no operator row exists. Run scripts/db.sh seed.");
return;
}
const existing = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
});
if (existing && existing.status === "connected") {
await ctx.reply(`Account "${label}" is already connected. Use /unpair first.`);
return;
}
let accountId = existing?.id;
if (!accountId) {
const [created] = await db
.insert(whatsappAccounts)
.values({ operatorId: operatorRow.id, label, status: "pending" })
.returning({ id: whatsappAccounts.id });
accountId = created!.id;
}
// If a previous pairing flow for this account is still alive (or stuck),
// tear it down cleanly before opening a new one.
await cancelExistingFlow(accountId);
await ctx.reply(`📡 Starting pairing for "${label}". A QR code will arrive shortly.`);
const off = sessionManager.on(async (id, _state, event) => {
if (id !== accountId) return;
try {
if (event.type === "qr") {
// Skip duplicate QR pushes — Baileys can re-emit the same QR which
// makes editMessageMedia fail with "message is not modified".
if (lastQrPayloadByAccount.get(id) === event.payload) return;
lastQrPayloadByAccount.set(id, event.payload);
const png = await renderQrPng(event.payload);
const file = new InputFile(png, `pair-${id}.png`);
const caption = `📱 Scan with WhatsApp → Linked Devices.\nLabel: "${label}". Expires in ~30s.`;
const existingMsg = qrMessageIdByAccount.get(id);
if (existingMsg) {
try {
await ctx.api.editMessageMedia(ctx.chat!.id, existingMsg, {
type: "photo",
media: file,
caption,
});
} catch (err) {
// If the edit fails for a benign reason (e.g. message gone), fall
// back to sending a fresh photo so the operator still sees the QR.
logger.warn({ err, accountId: id }, "pair: editMessageMedia failed; sending fresh QR");
qrMessageIdByAccount.delete(id);
const sent = await ctx.replyWithPhoto(file, { caption });
qrMessageIdByAccount.set(id, sent.message_id);
}
} else {
const sent = await ctx.replyWithPhoto(file, { caption });
qrMessageIdByAccount.set(id, sent.message_id);
}
} else if (event.type === "open") {
qrMessageIdByAccount.delete(id);
lastQrPayloadByAccount.delete(id);
offByAccount.delete(id);
await writeAuditLog(db, {
operatorId: operatorRow.id,
source: "telegram",
action: "account.paired",
targetType: "whatsapp_account",
targetId: id,
payload: { label },
});
const session = sessionManager.getSession(id);
let syncedCount = 0;
if (session) {
const result = await syncGroupsForAccount(id, session.socket);
syncedCount = result.synced;
}
const phoneText = event.phoneNumber ? ` as +${event.phoneNumber}` : "";
const kb = new InlineKeyboard()
.text("📂 View Groups", `g:${id}`)
.row()
.text("⬅ Main Menu", "m:main");
await ctx.reply(
`✅ *${label}* connected${phoneText}.\n\nSynced ${syncedCount} group${syncedCount === 1 ? "" : "s"}.`,
{ reply_markup: kb, parse_mode: "Markdown" },
);
off();
} else if (event.type === "close" && event.loggedOut) {
qrMessageIdByAccount.delete(id);
lastQrPayloadByAccount.delete(id);
offByAccount.delete(id);
const kb = new InlineKeyboard().text("⬅ Main Menu", "m:main");
await ctx.reply(`⚠️ Pairing failed (logged out).`, { reply_markup: kb });
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");
await ctx.reply(`Pairing failed to start: ${(err as Error).message}`);
off();
offByAccount.delete(accountId);
}
}

View File

@ -0,0 +1,13 @@
import type { Context } from "grammy";
import { InlineKeyboard } from "grammy";
export async function handleStart(ctx: Context): Promise<void> {
const kb = new InlineKeyboard()
.text("📒 Accounts", "m:accounts")
.text("📡 How to Pair", "m:pair")
.row()
.text("❓ Help", "m:help");
await ctx.reply("👋 cm WhatsApp Reminder Bot is online.\n\nWhat would you like to do?", {
reply_markup: kb,
});
}

View File

@ -0,0 +1,56 @@
import type { Context } from "grammy";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { eq } from "drizzle-orm";
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";
export async function handleUnpair(ctx: Context): Promise<void> {
const text = ctx.message?.text ?? "";
const label = text
.replace(/^\/unpair\s*/, "")
.trim()
.replace(/^["'“”‘’]|["'“”‘’]$/g, "");
if (!label) {
await ctx.reply('Usage: /unpair "Account Label"');
return;
}
const operatorId = ctx.from?.id;
if (!operatorId) return;
const operatorRow = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, operatorId),
});
if (!operatorRow) return;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)),
});
if (!account) {
await ctx.reply(`No account labelled "${label}".`);
return;
}
await sessionManager.stop(account.id);
await rm(join(env.SESSIONS_DIR, account.id), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "logged_out", phoneNumber: null })
.where(eq(whatsappAccounts.id, account.id));
await writeAuditLog(db, {
operatorId: operatorRow.id,
source: "telegram",
action: "account.unpaired",
targetType: "whatsapp_account",
targetId: account.id,
payload: { label },
});
await ctx.reply(`🗑 "${label}" unpaired. Session files deleted.`);
}

View File

@ -0,0 +1,248 @@
import { InlineKeyboard } from "grammy";
import { db } from "../db.js";
import { sessionManager } from "../whatsapp/session-manager.js";
// BotFather-style navigation: every leaf has a way home, every branch shows
// you where you are. All callbacks edit the same message.
// Callback data scheme (kept short to stay under Telegram's 64-byte limit):
// m:main — top-level menu
// m:accounts — accounts list
// m:help — help text
// m:pair — prompt for new account label
// acc:<id> — single account view
// g:<id> — groups list for account
// u:<id> — unpair confirm prompt
// uc:<id> — unpair execute
// ux:<id> — cancel unpair, go back to account view
export type MenuView = {
text: string;
keyboard: InlineKeyboard;
};
export function mainMenu(): MenuView {
const keyboard = new InlineKeyboard()
.text("📒 Accounts", "m:accounts")
.text("📡 Pair New", "m:pair")
.row()
.text("❓ Help", "m:help");
return {
text:
"👋 *cm WhatsApp Reminder Bot*\n\n" +
"What would you like to do?",
keyboard,
};
}
export function helpMenu(): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Main Menu", "m:main");
return {
text:
"*Available actions:*\n\n" +
"📒 *Accounts* — list paired WhatsApp accounts and act on each one\n" +
"📡 *Pair New* — link a new WhatsApp account via QR code\n" +
"❓ *Help* — this screen\n\n" +
"Type /start or /menu anytime to come back here.",
keyboard,
};
}
export function pairPromptMenu(): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Cancel", "m:main");
return {
text:
"📡 *Pair a new account*\n\n" +
"What name should I give this WhatsApp account?\n\n" +
"Reply to this message with a short label, e.g. `Sales 1`.\n\n" +
"(Or tap *Cancel* to go back.)",
keyboard,
};
}
export async function accountsMenu(operatorId: string): Promise<MenuView> {
const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId),
orderBy: (a, { asc }) => [asc(a.label)],
});
if (accounts.length === 0) {
const keyboard = new InlineKeyboard()
.text("📡 Pair New", "m:pair")
.row()
.text("⬅ Main Menu", "m:main");
return {
text: "📒 *Accounts*\n\nNo accounts paired yet.",
keyboard,
};
}
const keyboard = new InlineKeyboard();
for (const a of accounts) {
keyboard.text(`📒 ${a.label}`, `acc:${a.id}`).row();
}
keyboard.text("📡 Pair New", "m:pair").row().text("⬅ Main Menu", "m:main");
const lines = accounts.map((a) => {
const live = sessionManager.getState(a.id);
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
return `• *${a.label}*${phone}${a.status} (live: ${live})`;
});
return {
text: `📒 *Paired accounts:*\n\n${lines.join("\n")}\n\nTap an account to view its actions.`,
keyboard,
};
}
export async function accountDetailMenu(
operatorId: string,
accountId: string,
): Promise<MenuView | null> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const live = sessionManager.getState(accountId);
const phone = account.phoneNumber ? ` (+${account.phoneNumber})` : "";
const keyboard = new InlineKeyboard()
.text("📂 Groups", `g:${accountId}`)
.text("🗑 Unpair", `u:${accountId}`)
.row()
.text("⬅ Accounts", "m:accounts")
.text("⬅ Main Menu", "m:main");
return {
text:
`📒 *${account.label}*${phone}\n\n` +
`db status: \`${account.status}\`\n` +
`live status: \`${live}\`\n\n` +
"What would you like to do?",
keyboard,
};
}
export async function groupsListMenu(
operatorId: string,
accountId: string,
): Promise<MenuView | null> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq }) => eq(g.accountId, accountId),
orderBy: (g, { asc }) => [asc(g.name)],
});
const keyboard = new InlineKeyboard();
// One button per group (truncate to 30 to stay under Telegram's 100-button
// ceiling and keep the message readable). Group name truncated to 32 chars.
const visible = groups.slice(0, 30);
for (const g of visible) {
const name = g.name.length > 32 ? `${g.name.slice(0, 31)}` : g.name;
keyboard.text(`👥 ${name}`, `gr:${g.id}`).row();
}
keyboard
.text("🔄 Refresh", `rs:${accountId}`)
.row()
.text("⬅ Account", `acc:${accountId}`)
.text("⬅ Main Menu", "m:main");
if (groups.length === 0) {
return {
text: `👥 *Groups in ${account.label}*\n\nNo groups synced yet. Tap *Refresh* to pull the latest list.`,
keyboard,
};
}
const overflow = groups.length > 30 ? `\n\n_…${groups.length - 30} more not shown_` : "";
return {
text: `👥 *Groups in ${account.label}*\n\nTap a group to send a test message, or *Refresh* to pick up new groups.${overflow}`,
keyboard,
};
}
export async function groupDetailMenu(
operatorId: string,
groupId: string,
): Promise<MenuView | null> {
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, groupId),
});
if (!group) return null;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const keyboard = new InlineKeyboard()
.text("📝 Send Test Text", `st:${groupId}`)
.row()
.text("⬅ Groups", `g:${group.accountId}`)
.text("⬅ Main Menu", "m:main");
return {
text:
`👥 *${group.name}*\n\n` +
`Account: ${account.label}\n` +
`Members: ${group.participantCount}\n\n` +
"What would you like to do?",
keyboard,
};
}
export function sendTestPromptMenu(groupName: string): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Cancel", "m:main");
return {
text:
`📝 *Send a test message to ${groupName}*\n\n` +
"Reply to this message with the text you want to send.\n\n" +
"(Or tap *Cancel*.)",
keyboard,
};
}
export function sendTestDoneMenu(groupName: string, ok: boolean, errorMsg?: string): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Main Menu", "m:main");
if (ok) {
return {
text: `✅ Test message sent to *${groupName}*.`,
keyboard,
};
}
return {
text: `❌ Failed to send to *${groupName}*.\n\n\`${errorMsg ?? "unknown error"}\``,
keyboard,
};
}
export async function unpairConfirmMenu(
operatorId: string,
accountId: string,
): Promise<MenuView | null> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const keyboard = new InlineKeyboard()
.text("✅ Yes, unpair", `uc:${accountId}`)
.text("⬅ Cancel", `acc:${accountId}`);
return {
text:
`🗑 *Unpair ${account.label}?*\n\n` +
"The session files will be deleted and you'll need to re-scan a QR code if you want this account back.",
keyboard,
};
}
export function unpairDoneMenu(label: string): MenuView {
const keyboard = new InlineKeyboard()
.text("⬅ Accounts", "m:accounts")
.text("⬅ Main Menu", "m:main");
return {
text: `🗑 *${label}* unpaired. Session files deleted.`,
keyboard,
};
}

View File

@ -0,0 +1,21 @@
import type { Context, MiddlewareFn } from "grammy";
import { db } from "../../db.js";
import { writeAuditLog } from "../../audit.js";
import { logger } from "../../logger.js";
export const auditMiddleware: MiddlewareFn<Context> = async (ctx, next) => {
const text = ctx.message?.text;
if (text?.startsWith("/")) {
try {
await writeAuditLog(db, {
operatorId: null,
source: "telegram",
action: `tg.command.${text.split(" ")[0]?.slice(1) ?? "unknown"}`,
payload: { from: ctx.from?.id, text },
});
} catch (err) {
logger.warn({ err }, "audit middleware: failed to write");
}
}
await next();
};

View File

@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { makeWhitelistMiddleware } from "./whitelist.js";
function ctx(userId: number | undefined) {
return {
from: userId === undefined ? undefined : { id: userId },
reply: vi.fn().mockResolvedValue(undefined),
} as unknown as { from?: { id: number }; reply: ReturnType<typeof vi.fn> };
}
describe("makeWhitelistMiddleware", () => {
it("calls next for whitelisted user", async () => {
const mw = makeWhitelistMiddleware([42]);
const c = ctx(42);
const next = vi.fn().mockResolvedValue(undefined);
await mw(c as never, next);
expect(next).toHaveBeenCalledOnce();
expect(c.reply).not.toHaveBeenCalled();
});
it("rejects non-whitelisted user with reply", async () => {
const mw = makeWhitelistMiddleware([42]);
const c = ctx(99);
const next = vi.fn();
await mw(c as never, next);
expect(next).not.toHaveBeenCalled();
expect(c.reply).toHaveBeenCalledWith(expect.stringMatching(/private/i));
});
it("rejects user-less updates silently", async () => {
const mw = makeWhitelistMiddleware([42]);
const c = ctx(undefined);
const next = vi.fn();
await mw(c as never, next);
expect(next).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,14 @@
import type { Context, MiddlewareFn } from "grammy";
export function makeWhitelistMiddleware(allowedUserIds: number[]): MiddlewareFn<Context> {
const allowed = new Set(allowedUserIds);
return async (ctx, next) => {
const userId = ctx.from?.id;
if (userId === undefined) return;
if (!allowed.has(userId)) {
await ctx.reply("Sorry, this bot is private.");
return;
}
await next();
};
}

View File

@ -0,0 +1,43 @@
// Per-user conversation state for menu-driven flows.
// Currently tracks: "operator clicked Pair New, waiting for them to type the label".
// In-memory only — fine for a single-instance bot. If we ever scale horizontally,
// move this to Postgres.
const PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes
const pendingPairLabel = new Map<number, number>(); // userId → expires_at_ms
export function setPendingPairLabel(userId: number): void {
pendingPairLabel.set(userId, Date.now() + PENDING_TTL_MS);
}
export function clearPendingPairLabel(userId: number): void {
pendingPairLabel.delete(userId);
}
export function consumePendingPairLabel(userId: number): boolean {
const expiresAt = pendingPairLabel.get(userId);
if (!expiresAt) return false;
pendingPairLabel.delete(userId);
return Date.now() < expiresAt;
}
// "Send a test message to this WhatsApp group" pending state.
type PendingSend = { groupId: string; expiresAt: number };
const pendingSendToGroup = new Map<number, PendingSend>();
export function setPendingSendToGroup(userId: number, groupId: string): void {
pendingSendToGroup.set(userId, { groupId, expiresAt: Date.now() + PENDING_TTL_MS });
}
export function clearPendingSendToGroup(userId: number): void {
pendingSendToGroup.delete(userId);
}
export function consumePendingSendToGroup(userId: number): string | null {
const pending = pendingSendToGroup.get(userId);
if (!pending) return null;
pendingSendToGroup.delete(userId);
if (Date.now() >= pending.expiresAt) return null;
return pending.groupId;
}

View File

@ -1,9 +1,11 @@
import { readFile, stat } from "node:fs/promises"; import type { WASocket } from "@whiskeysockets/baileys";
import type { WASocket, AnyMessageContent } from "@whiskeysockets/baileys";
import pino from "pino"; import pino from "pino";
const logger = pino({ name: "sender" }); const logger = pino({ name: "sender" });
// Internal Baileys method used to fetch pre-key bundles and establish individual
// libsignal sessions for a list of JIDs. Not part of the public type, but it's
// the only way to avoid "No sessions" on the first group send after pairing.
type SocketWithAssertSessions = WASocket & { type SocketWithAssertSessions = WASocket & {
assertSessions?: (jids: string[], force: boolean) => Promise<boolean>; assertSessions?: (jids: string[], force: boolean) => Promise<boolean>;
}; };
@ -12,10 +14,19 @@ const CHUNK_SIZE = 5;
async function chunked<T>(items: T[], size: number): Promise<T[][]> { async function chunked<T>(items: T[], size: number): Promise<T[][]> {
const out: T[][] = []; const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); for (let i = 0; i < items.length; i += size) {
out.push(items.slice(i, i + size));
}
return out; return out;
} }
/**
* Establish per-participant libsignal sessions in small chunks. WhatsApp's
* pre-key endpoint returns 406 "not-acceptable" if any single JID in the
* batch is in a broken state (deleted account, deactivated, etc.) so we
* chunk the work and tolerate per-chunk failures rather than letting one
* bad participant poison the whole send.
*/
async function ensureSessionsForGroup( async function ensureSessionsForGroup(
socket: WASocket, socket: WASocket,
groupJid: string, groupJid: string,
@ -28,73 +39,44 @@ async function ensureSessionsForGroup(
} }
let ok = 0; let ok = 0;
let failed = 0; let failed = 0;
for (const chunk of await chunked(participantJids, CHUNK_SIZE)) { const chunks = await chunked(participantJids, CHUNK_SIZE);
for (const chunk of chunks) {
try { try {
await internal.assertSessions(chunk, true); await internal.assertSessions(chunk, true);
ok += chunk.length; ok += chunk.length;
} catch (err) { } catch (err) {
failed += chunk.length; failed += chunk.length;
logger.warn({ groupJid, err: (err as Error).message }, "assertSessions chunk failed"); logger.warn(
{ groupJid, chunkSize: chunk.length, err: (err as Error).message },
"assertSessions chunk failed; continuing",
);
} }
} }
logger.info(
{ groupJid, ok, failed, total: participantJids.length },
"ensureSessionsForGroup: done",
);
return { ok, failed, total: participantJids.length }; return { ok, failed, total: participantJids.length };
} }
async function sendWithRetry(
socket: WASocket,
groupJid: string,
content: AnyMessageContent,
): Promise<{ messageId: string | undefined }> {
await ensureSessionsForGroup(socket, groupJid);
try {
const result = await socket.sendMessage(groupJid, content);
return { messageId: result?.key?.id ?? undefined };
} catch (err) {
const message = (err as Error)?.message ?? "";
if (message.includes("No sessions")) {
await new Promise((r) => setTimeout(r, 2000));
await ensureSessionsForGroup(socket, groupJid);
const result = await socket.sendMessage(groupJid, content);
return { messageId: result?.key?.id ?? undefined };
}
throw err;
}
}
export async function sendTextToGroup( export async function sendTextToGroup(
socket: WASocket, socket: WASocket,
groupJid: string, groupJid: string,
text: string, text: string,
): Promise<{ messageId: string | undefined }> { ): Promise<{ messageId: string | undefined }> {
return sendWithRetry(socket, groupJid, { text }); await ensureSessionsForGroup(socket, groupJid);
try {
const result = await socket.sendMessage(groupJid, { text });
return { messageId: result?.key?.id ?? undefined };
} catch (err) {
const message = (err as Error)?.message ?? "";
if (message.includes("No sessions")) {
await new Promise((resolve) => setTimeout(resolve, 2000));
await ensureSessionsForGroup(socket, groupJid);
const result = await socket.sendMessage(groupJid, { text });
return { messageId: result?.key?.id ?? undefined };
}
throw err;
} }
export type MediaKind = "image" | "video" | "document";
export async function sendMediaToGroup(
socket: WASocket,
groupJid: string,
kind: MediaKind,
filePath: string,
options: { caption?: string; mimeType?: string; filename?: string } = {},
): Promise<{ messageId: string | undefined }> {
// Validate the file exists and read into a buffer. For very large files
// (>50MB) Baileys also accepts a stream, but for our reminder use case
// files are typically <30MB which fits comfortably in memory.
await stat(filePath);
const buffer = await readFile(filePath);
const content: AnyMessageContent =
kind === "image"
? { image: buffer, caption: options.caption, mimetype: options.mimeType }
: kind === "video"
? { video: buffer, caption: options.caption, mimetype: options.mimeType }
: {
document: buffer,
caption: options.caption,
fileName: options.filename ?? "file",
mimetype: options.mimeType ?? "application/octet-stream",
};
return sendWithRetry(socket, groupJid, content);
} }

View File

@ -40,13 +40,6 @@ class SessionManager {
private states = new Map<string, SessionState>(); private states = new Map<string, SessionState>();
private listeners = new Set<SessionListener>(); private listeners = new Set<SessionListener>();
private reconnectTimers = new Map<string, NodeJS.Timeout>(); private reconnectTimers = new Map<string, NodeJS.Timeout>();
/**
* Account IDs whose next close event was triggered by us on purpose
* (unpair, delete, app shutdown). When an entry is present, the close
* handler skips the DB status write and the auto-reconnect schedule
* the caller has already chosen the row's next state.
*/
private intentionalStops = new Set<string>();
on(listener: SessionListener): () => void { on(listener: SessionListener): () => void {
this.listeners.add(listener); this.listeners.add(listener);
@ -99,10 +92,7 @@ class SessionManager {
this.sessions.set(accountId, session); this.sessions.set(accountId, session);
} }
async stop( async stop(accountId: string): Promise<void> {
accountId: string,
opts?: { intentional?: boolean },
): Promise<void> {
const timer = this.reconnectTimers.get(accountId); const timer = this.reconnectTimers.get(accountId);
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer);
@ -110,12 +100,6 @@ class SessionManager {
} }
const session = this.sessions.get(accountId); const session = this.sessions.get(accountId);
if (!session) return; if (!session) return;
if (opts?.intentional) {
// Mark the upcoming close event as expected so handleEvent won't
// race with the caller's DB write. We only set this when the
// caller will manage the row's status themselves (unpair, delete).
this.intentionalStops.add(accountId);
}
await session.close(); await session.close();
this.sessions.delete(accountId); this.sessions.delete(accountId);
} }
@ -151,54 +135,21 @@ class SessionManager {
}) })
.where(eq(whatsappAccounts.id, accountId)); .where(eq(whatsappAccounts.id, accountId));
} else if (event.type === "close") { } else if (event.type === "close") {
// Drain the intentional-stop flag exactly once so a stale flag
// can't bleed into a later, unrelated session.
const wasIntentional = this.intentionalStops.delete(accountId);
this.transition(accountId, { kind: "close", loggedOut: event.loggedOut }); this.transition(accountId, { kind: "close", loggedOut: event.loggedOut });
if (wasIntentional) {
// Caller (unpair/delete handler) is writing the row themselves.
// Don't overwrite their status, and don't schedule a reconnect
// for a session we just chose to tear down.
} else {
await db await db
.update(whatsappAccounts) .update(whatsappAccounts)
.set({ status: event.loggedOut ? "logged_out" : "disconnected" }) .set({ status: event.loggedOut ? "logged_out" : "disconnected" })
.where(eq(whatsappAccounts.id, accountId)); .where(eq(whatsappAccounts.id, accountId));
if (event.loggedOut) { if (!event.loggedOut) {
await this.stop(accountId);
} else if (event.restartRequired) {
// Status 515 — the post-pair-success reconnect. Always re-open
// immediately (no 5 s back-off, no `lastConnectedAt` gate). If
// we don't, the auth handshake never completes and the user
// sees a spurious "Pairing timed out".
const timer = setTimeout(() => {
this.reconnectTimers.delete(accountId);
void this.stop(accountId).then(() => this.start(accountId));
}, 250);
this.reconnectTimers.set(accountId, timer);
} else {
// Other ephemeral closes (refs exhausted, network blip): only
// auto-reconnect for accounts that have been linked at least
// once. During an initial pair attempt this would otherwise
// restart the pair dance and rotate the QR every few seconds.
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
columns: { lastConnectedAt: true },
});
if (account?.lastConnectedAt) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.reconnectTimers.delete(accountId); this.reconnectTimers.delete(accountId);
void this.stop(accountId).then(() => this.start(accountId)); void this.stop(accountId).then(() => this.start(accountId));
}, 5000); }, 5000);
this.reconnectTimers.set(accountId, timer); this.reconnectTimers.set(accountId, timer);
} else { } else {
// Brand-new account that hasn't authenticated yet — let the
// pair-handler clean up via its timeout.
await this.stop(accountId); await this.stop(accountId);
} }
}
}
} else if (event.type === "qr") { } else if (event.type === "qr") {
await db await db
.update(whatsappAccounts) .update(whatsappAccounts)

View File

@ -16,11 +16,7 @@ import { syncGroupsForAccount } from "./group-sync.js";
export type SessionEvent = export type SessionEvent =
| { type: "qr"; payload: string } | { type: "qr"; payload: string }
| { type: "open"; phoneNumber: string | undefined } | { type: "open"; phoneNumber: string | undefined }
// `restartRequired` is set when Baileys closes the socket with status | { type: "close"; reason: number; loggedOut: boolean };
// 515 — the normal post-pair handshake reconnect, NOT a failure. Both
// pair-handler and session-manager use it to skip the "pairing failed"
// path and re-open the socket so the account finishes linking.
| { type: "close"; reason: number; loggedOut: boolean; restartRequired: boolean };
export type SessionEventHandler = (event: SessionEvent) => void | Promise<void>; export type SessionEventHandler = (event: SessionEvent) => void | Promise<void>;
@ -50,11 +46,6 @@ export async function startSession(params: {
auth: state, auth: state,
browser: Browsers.macOS("Safari"), browser: Browsers.macOS("Safari"),
syncFullHistory: false, syncFullHistory: false,
// Use Baileys' default QR cadence (60 s for the first ref, ~20 s for
// each subsequent ref) — that's the native WhatsApp Web cadence and
// each rotation just refreshes the displayed QR. The earlier "QR
// refresh every 5 s" bug was the session-manager reconnect loop,
// not the cadence.
logger: logger.child({ accountId, component: "baileys" }) as never, logger: logger.child({ accountId, component: "baileys" }) as never,
}); });
@ -88,8 +79,7 @@ export async function startSession(params: {
const reason = const reason =
(update.lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)?.output?.statusCode ?? 0; (update.lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)?.output?.statusCode ?? 0;
const loggedOut = reason === DisconnectReason.loggedOut; const loggedOut = reason === DisconnectReason.loggedOut;
const restartRequired = reason === DisconnectReason.restartRequired; void onEvent({ type: "close", reason, loggedOut });
void onEvent({ type: "close", reason, loggedOut, restartRequired });
} }
}); });

View File

@ -4,6 +4,5 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src"
}, },
"include": ["src/**/*"], "include": ["src/**/*"]
"exclude": ["src/**/*.test.ts"]
} }

View File

@ -1,25 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -1,50 +0,0 @@
import type { NextConfig } from "next";
import { join } from "node:path";
import withSerwistInit from "@serwist/next";
// Pin Turbopack's workspace root explicitly — pnpm + Turbopack can't always
// infer it inside Docker bind mounts.
const workspaceRoot = join(import.meta.dirname, "..", "..");
// We consume @cmbot/db and @cmbot/shared via their compiled dist (their
// package.json `main` points at ./dist/index.js). The dist is built at
// container start (see docker-compose.dev.yml) and during the production
// Docker build (see docker/web.Dockerfile). This sidesteps Turbopack's
// inability to resolve NodeNext-style `.js` extensions to `.ts` source.
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
outputFileTracingRoot: workspaceRoot,
// Allow Server Actions and dev HMR from the LAN host (phone testing).
// Tighten before exposing publicly via the reverse proxy.
allowedDevOrigins: ["192.168.0.253", "test.04080616.xyz", "rexwa.04080616.xyz"],
experimental: {
typedRoutes: true,
serverActions: {
// Default Server Action body limit is 1 MB — way under WhatsApp's
// 100 MB document cap. Lifted to 100 MB so document uploads reach
// the action; the per-kind WhatsApp validator
// (lib/whatsapp-media.ts) then enforces the actual limit
// (5 MB image / 16 MB video/audio / 100 MB document) and returns
// a useful error for the rest.
bodySizeLimit: "100mb",
},
},
turbopack: {
root: workspaceRoot,
},
};
// PWA: @serwist/next compiles `src/pwa/sw.ts` into `public/sw.js` at
// production build time, baking in the static-asset precache manifest.
// We disable it in dev because Turbopack + a service worker on every
// reload makes hot-reload extremely flaky.
const withSerwist = withSerwistInit({
swSrc: "src/pwa/sw.ts",
swDest: "public/sw.js",
cacheOnNavigation: true,
reloadOnOnline: true,
disable: process.env.NODE_ENV !== "production",
});
export default withSerwist(nextConfig);

View File

@ -1,59 +0,0 @@
{
"name": "@cmbot/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --hostname 0.0.0.0",
"build": "next build --webpack",
"start": "next start --hostname 0.0.0.0",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"gen:icons": "tsx scripts/gen-pwa-icons.ts"
},
"dependencies": {
"@cmbot/db": "workspace:*",
"@cmbot/shared": "workspace:*",
"@hookform/resolvers": "^5.2.2",
"@serwist/next": "^9.5.11",
"@types/luxon": "^3.4.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.36.0",
"geist": "^1.7.0",
"lucide-react": "^1.14.0",
"luxon": "^3.5.0",
"next": "^16.0.0",
"next-themes": "^0.4.6",
"pg": "^8.13.0",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.75.0",
"server-only": "^0.0.1",
"serwist": "^9.5.11",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.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",
"@vitest/ui": "^2.1.9",
"sharp": "^0.34.5",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.0",
"vitest": "^2.1.9"
}
}

View File

@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -1,69 +0,0 @@
#!/usr/bin/env tsx
/**
* Generate placeholder PWA icons (icon-512.png, icon-192.png,
* apple-touch-icon.png) into apps/web/public/.
*
* Run once via `pnpm --filter @cmbot/web run gen:icons`. The output is
* intentionally minimal a dark square with the "cm" wordmark in
* a light bold sans-serif until a designer hands us a real icon.
*
* Sharp is already in the workspace's node_modules (Baileys depends
* on it), so we re-use it here rather than introducing a new image
* library. Output is written as PNG with no alpha channel; the
* "any maskable" purpose in the manifest covers both regular launch
* icons and Android adaptive icons (which crop to their own shape).
*/
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
const OUT_DIR = join(import.meta.dirname, "..", "public");
const BG = "#0a0a0a"; // matches the manifest theme_color
const FG = "#ffffff"; // wordmark
const TEXT = "cm";
/** Render the icon at the given pixel size. */
async function renderIcon(size: number): Promise<Buffer> {
// Font size is tuned to fill ~half the icon's height with comfortable
// padding around the wordmark — same proportions Apple/Google use
// for their own home-screen icons.
const fontSize = Math.round(size * 0.46);
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" fill="${BG}" />
<text
x="50%"
y="50%"
text-anchor="middle"
dominant-baseline="central"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif"
font-weight="700"
font-size="${fontSize}"
fill="${FG}"
letter-spacing="-${Math.round(fontSize * 0.04)}"
>${TEXT}</text>
</svg>
`.trim();
return sharp(Buffer.from(svg)).png().toBuffer();
}
async function main(): Promise<void> {
const targets: Array<{ size: number; filename: string }> = [
{ size: 512, filename: "icon-512.png" },
{ size: 192, filename: "icon-192.png" },
{ size: 180, filename: "apple-touch-icon.png" },
];
for (const { size, filename } of targets) {
const png = await renderIcon(size);
const out = join(OUT_DIR, filename);
await writeFile(out, png);
console.log(` ${filename} (${size}×${size}, ${png.byteLength} bytes)`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -1,218 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { whatsappAccounts, whatsappGroups } from "@cmbot/db";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { pgNotifyBot } from "@/lib/notify";
import { checkRateLimit } from "@/lib/rate-limit";
async function rateLimit(key: string) {
const h = await headers();
const ip =
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
h.get("x-real-ip") ??
"unknown";
const r = await checkRateLimit(`${key}:${ip}`, { max: 30, windowSec: 10 });
if (r.limited) throw new Error("Too many requests");
}
const addAccountSchema = z.object({
label: z
.string()
.trim()
.min(1, "Label is required")
.max(60, "Label too long (max 60)"),
});
export type AddAccountResult =
| { ok: true; accountId: string }
| { ok: false; error: string };
/**
* Step 1 of the lifecycle: create an account row with status='unpaired'.
* No QR scan yet. Caller redirects to /accounts/[id] where the operator
* sees the account detail with a "Pair Now" button.
*/
export async function addAccountAction(
_prev: unknown,
formData: FormData,
): Promise<AddAccountResult> {
await rateLimit("add-account");
const parsed = addAccountSchema.safeParse({ label: formData.get("label") });
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid label" };
}
const op = await getSeededOperator();
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) {
return {
ok: false,
error: `An account labelled "${parsed.data.label}" already exists.`,
};
}
const [created] = await db
.insert(whatsappAccounts)
.values({ operatorId: op.id, label: parsed.data.label, status: "unpaired" })
.returning({ id: whatsappAccounts.id });
revalidatePath("/accounts");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${created!.id}` as any);
}
const renameAccountSchema = z.object({
accountId: z.string().uuid(),
label: z
.string()
.trim()
.min(1, "Label is required")
.max(60, "Label too long (max 60)"),
});
export type RenameAccountResult =
| { ok: true }
| { ok: false; error: string };
/**
* Edit the operator-facing label for an existing account. The label is
* what shows up in lists, the page header, and run history; it has no
* effect on the WhatsApp side.
*/
export async function renameAccountAction(input: {
accountId: string;
label: string;
}): Promise<RenameAccountResult> {
await rateLimit("rename-account");
const parsed = renameAccountSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
const op = await getSeededOperator();
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) =>
and(eq(a.id, parsed.data.accountId), eq(a.operatorId, op.id)),
});
if (!account) return { ok: false, error: "Account not found" };
// Reject duplicate labels for the same operator.
const dupe = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and, ne }) =>
and(
eq(a.operatorId, op.id),
eq(a.label, parsed.data.label),
ne(a.id, parsed.data.accountId),
),
});
if (dupe) {
return {
ok: false,
error: `An account labelled "${parsed.data.label}" already exists.`,
};
}
await db
.update(whatsappAccounts)
.set({ label: parsed.data.label })
.where(eq(whatsappAccounts.id, parsed.data.accountId));
revalidatePath("/accounts");
revalidatePath(`/accounts/${parsed.data.accountId}`);
return { ok: true };
}
/**
* Trigger pair / re-pair for an existing account. Transitions the row to
* status='pending' and asks the bot to open a Baileys session. Operator
* lands on the live QR page.
*/
export async function pairAccountAction(formData: FormData): Promise<void> {
await rateLimit("pair");
const accountId = formData.get("accountId");
if (typeof accountId !== "string") return;
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;
if (account.status === "connected") {
// Already connected — bounce to detail
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${accountId}` as any);
}
await db
.update(whatsappAccounts)
.set({ status: "pending", lastQrAt: new Date() })
.where(eq(whatsappAccounts.id, accountId));
await pgNotifyBot({ type: "account.start_pairing", accountId });
revalidatePath("/accounts");
revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${accountId}/pairing` as any);
}
/**
* Unpair: stop the Baileys session and clear session files via the bot,
* but KEEP the account row (status -> 'unpaired') so the operator can
* re-pair without retyping the label or losing any references.
*/
export async function unpairAccountAction(formData: FormData): Promise<void> {
await rateLimit("unpair");
const accountId = formData.get("accountId");
if (typeof accountId !== "string") return;
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;
await pgNotifyBot({ type: "account.unpair", accountId });
await db
.update(whatsappAccounts)
.set({ status: "unpaired", phoneNumber: null })
.where(eq(whatsappAccounts.id, accountId));
// Wipe synced groups too — they belong to a different WA login now.
await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId));
revalidatePath("/accounts");
revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/accounts/${accountId}` as any);
}
/**
* Permanently delete an account, its groups, reminders and run history
* via the cascade FKs added in migration 0003.
*/
export async function deleteAccountAction(formData: FormData): Promise<void> {
await rateLimit("delete-account");
const accountId = formData.get("accountId");
if (typeof accountId !== "string") return;
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;
// Stop any live session / clean session files first.
await pgNotifyBot({ type: "account.unpair", accountId });
// Cascade FKs handle groups, reminders, runs, run_targets, messages.
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
revalidatePath("/accounts");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/accounts" as any);
}
export async function syncGroupsAction(formData: FormData): Promise<void> {
await rateLimit("sync");
const accountId = formData.get("accountId");
if (typeof accountId !== "string") return;
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;
await pgNotifyBot({ type: "account.sync_groups", accountId });
revalidatePath(`/accounts/${accountId}`);
revalidatePath(`/accounts/${accountId}/groups`);
}

View File

@ -1,56 +0,0 @@
"use server";
import { headers } from "next/headers";
import { z } from "zod";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { pgNotifyBot } from "@/lib/notify";
import { checkRateLimit } from "@/lib/rate-limit";
async function rateLimit(key: string) {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown";
const r = await checkRateLimit(`${key}:${ip}`, { max: 30, windowSec: 10 });
if (r.limited) throw new Error("Too many requests");
}
const sendTestSchema = z.object({
groupId: z.string().uuid(),
text: z.string().trim().min(1, "Message is empty").max(4000, "Message too long"),
});
export type SendTestResult =
| { ok: true; message: string }
| { ok: false; error: string };
export async function sendTestAction(_prev: unknown, formData: FormData): Promise<SendTestResult> {
await rateLimit("send-test");
const parsed = sendTestSchema.safeParse({
groupId: formData.get("groupId"),
text: formData.get("text"),
});
if (!parsed.success) {
return { ok: false, 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, 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, error: "Group not yours" };
if (account.status !== "connected") {
return { ok: false, error: "Account not connected" };
}
await pgNotifyBot({
type: "group.send_test",
groupId: parsed.data.groupId,
text: parsed.data.text,
});
return { ok: true, message: `Sending to ${group.name}` };
}

View File

@ -1,105 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { eq, sql } from "drizzle-orm";
import { reminderRuns } from "@cmbot/db";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { checkRateLimit } from "@/lib/rate-limit";
async function rateLimit(key: string, opts: { max?: number; windowSec?: number } = {}) {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown";
const r = await checkRateLimit(`${key}:${ip}`, {
max: opts.max ?? 5,
windowSec: opts.windowSec ?? 60,
});
if (r.limited) throw new Error("Too many requests");
}
/**
* Verify the run belongs to the seeded operator (or is an orphan from a
* deleted reminder, which the dashboard considers shared history). Returns
* the run's id when ownership checks out, otherwise null.
*/
async function checkRunOwnership(runId: string): Promise<string | null> {
const op = await getSeededOperator();
const rows = await db.execute<{ id: string }>(sql`
SELECT rr.id
FROM reminder_runs rr
LEFT JOIN reminders r ON r.id = rr.reminder_id
LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE rr.id = ${runId}
AND (wa.operator_id = ${op.id} OR r.id IS NULL)
LIMIT 1
`);
return rows.rows[0]?.id ?? null;
}
/**
* Wipe the operator's reminder run history. Operators only see runs whose
* underlying reminder is still owned by them PLUS orphan runs (whose
* reminder was deleted) the dashboard query mirrors this. We delete
* both sets so "clear history" feels exhaustive.
*/
export async function clearHistoryAction(): Promise<void> {
await rateLimit("clear-history");
const op = await getSeededOperator();
await db.execute(sql`
DELETE FROM ${reminderRuns}
WHERE id IN (
SELECT rr.id
FROM ${reminderRuns} rr
LEFT JOIN reminders r ON r.id = rr.reminder_id
LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${op.id} OR r.id IS NULL
)
`);
revalidatePath("/");
revalidatePath("/reminders");
}
/** Soft-archive one run. Hidden from the default activity list afterwards. */
export async function archiveRunAction(formData: FormData): Promise<void> {
await rateLimit("archive-run", { max: 30, windowSec: 60 });
const runId = formData.get("runId");
if (typeof runId !== "string") return;
const ownedId = await checkRunOwnership(runId);
if (!ownedId) return;
await db
.update(reminderRuns)
.set({ archivedAt: new Date() })
.where(eq(reminderRuns.id, ownedId));
revalidatePath("/");
revalidatePath("/activity");
}
/** Move a previously-archived run back to the default activity list. */
export async function unarchiveRunAction(formData: FormData): Promise<void> {
await rateLimit("unarchive-run", { max: 30, windowSec: 60 });
const runId = formData.get("runId");
if (typeof runId !== "string") return;
const ownedId = await checkRunOwnership(runId);
if (!ownedId) return;
await db
.update(reminderRuns)
.set({ archivedAt: null })
.where(eq(reminderRuns.id, ownedId));
revalidatePath("/");
revalidatePath("/activity");
}
/** Hard-delete one run. Cascades through reminder_run_targets via FK. */
export async function deleteRunAction(formData: FormData): Promise<void> {
await rateLimit("delete-run", { max: 30, windowSec: 60 });
const runId = formData.get("runId");
if (typeof runId !== "string") return;
const ownedId = await checkRunOwnership(runId);
if (!ownedId) return;
await db.delete(reminderRuns).where(eq(reminderRuns.id, ownedId));
revalidatePath("/");
revalidatePath("/activity");
}

View File

@ -1,71 +0,0 @@
"use server";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { createHash } from "node:crypto";
import { headers } from "next/headers";
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";
import { checkRateLimit } from "@/lib/rate-limit";
import { validateForWhatsApp } from "@/lib/whatsapp-media";
async function rateLimit(key: string) {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown";
const r = await checkRateLimit(`${key}:${ip}`, { max: 10, windowSec: 30 });
if (r.limited) throw new Error("Too many uploads");
}
export type UploadMediaResult =
| { ok: true; mediaId: string; filename: string; mimeType: string }
| { ok: false; error: string };
export async function uploadMediaAction(
_prev: unknown,
formData: FormData,
): Promise<UploadMediaResult> {
await rateLimit("media-upload");
const file = formData.get("file");
if (!(file instanceof File)) return { ok: false, error: "No file uploaded" };
const mimeType = file.type || "application/octet-stream";
const op = await getSeededOperator();
const buffer = Buffer.from(await file.arrayBuffer());
// Validate against the resolved delivery kind. The validator sniffs
// the magic bytes too, so an iOS HEIC labelled image/jpeg gets
// routed to the document path (100 MB cap) instead of the image
// path (5 MB cap) — the upload still succeeds but the bot delivers
// it as a downloadable file rather than an inline image. Only
// size-related rejections happen here.
const sizeCheck = validateForWhatsApp(mimeType, buffer.byteLength, buffer);
if (!sizeCheck.ok) return { ok: false, error: sizeCheck.error };
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,
sizeBytes: buffer.byteLength,
sha256,
storagePath,
})
.returning({ id: mediaFiles.id });
return {
ok: true,
mediaId: row!.id,
filename: file.name,
mimeType,
};
}

View File

@ -1,30 +0,0 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { DateTime } from "luxon";
/**
* Regression test for the "Invalid datetime" error.
*
* Earlier the action used `z.string().datetime()` (strict UTC `Z` only).
* Luxon's `dt.toISO()` produces an offset-suffixed form like
* `2026-05-10T09:00:00.000+08:00`, which the strict validator rejects.
* The fix uses `.datetime({ offset: true })`.
*
* If this test ever fails again it means the schema regressed and any
* Asia/Kuala_Lumpur reminder will be rejected at submit.
*/
describe("createReminderAction Zod schema (datetime validator)", () => {
const offsetIso = DateTime.fromISO("2026-05-10T09:00:00", { zone: "Asia/Kuala_Lumpur" }).toISO()!;
const utcIso = DateTime.fromISO("2026-05-10T09:00:00", { zone: "UTC" }).toISO()!;
it("strict .datetime() (no options) rejects offset-suffixed ISO — that was the bug", () => {
const strict = z.string().datetime();
expect(strict.safeParse(offsetIso).success).toBe(false);
});
it(".datetime({ offset: true }) accepts both offset and UTC ISO — that's the fix", () => {
const lenient = z.string().datetime({ offset: true });
expect(lenient.safeParse(offsetIso).success).toBe(true);
expect(lenient.safeParse(utcIso).success).toBe(true);
});
});

View File

@ -1,540 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { DateTime } from "luxon";
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { checkRateLimit } from "@/lib/rate-limit";
import { pgNotifyBot } from "@/lib/notify";
import { validateUpdateScheduledAt } from "@/lib/reminder-update";
import { resolveReminderName } from "@/lib/reminder-name";
async function rateLimit(key: string) {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown";
const r = await checkRateLimit(`${key}:${ip}`, { max: 30, windowSec: 10 });
if (r.limited) throw new Error("Too many requests");
}
export async function deleteReminderAction(formData: FormData): Promise<void> {
await rateLimit("delete-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const op = await getSeededOperator();
const reminder = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!reminder) return;
// Verify ownership via the account
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;
// Cascading FKs (reminder_runs + reminder_targets + reminder_messages) clean up.
// pg-boss job for this reminder will fire and find the row gone (soft cancel).
await db.delete(reminders).where(eq(reminders.id, reminderId));
revalidatePath("/reminders" as any);
redirect("/reminders" as any);
}
/**
* Resolve and verify the reminder owned by the seeded operator. Returns
* null if the reminder doesn't exist or belongs to a different account.
*/
async function loadOwnedReminder(reminderId: string) {
const op = await getSeededOperator();
const reminder = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!reminder) return null;
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 null;
return reminder;
}
/**
* Pause an active reminder. The pg-boss job stays armed (we don't have
* a hard cancel) but `fireReminder` exits early when status !== "active".
*/
export async function pauseReminderAction(formData: FormData): Promise<void> {
await rateLimit("pause-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const reminder = await loadOwnedReminder(reminderId);
if (!reminder) return;
if (reminder.status !== "active") return; // already not running
await db
.update(reminders)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(reminders.id, reminderId));
revalidatePath("/reminders" as any);
revalidatePath(`/reminders/${reminderId}` as any);
}
/**
* Restart a paused or ended reminder. For a one-off whose scheduledAt is
* in the past, push it to "now + 1 minute" so it fires soon. For a
* recurring reminder, compute the next occurrence from the RRULE.
* Either way the row flips back to `active` and the pg-boss job is
* re-armed.
*/
export async function restartReminderAction(formData: FormData): Promise<void> {
await rateLimit("restart-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const reminder = await loadOwnedReminder(reminderId);
if (!reminder) return;
let nextFire: Date | null = null;
const now = new Date();
if (reminder.scheduleKind === "recurring" && reminder.rrule) {
const { nextOccurrence } = await import("@cmbot/shared");
nextFire = nextOccurrence(reminder.rrule, reminder.timezone, now);
} else if (reminder.scheduledAt && reminder.scheduledAt.getTime() > Date.now() + 30_000) {
// The original time is still in the future and far enough away to
// be useful — keep it.
nextFire = reminder.scheduledAt;
} else {
nextFire = new Date(Date.now() + 60_000);
}
if (!nextFire) return;
await db
.update(reminders)
.set({
status: "active",
scheduledAt: nextFire,
updatedAt: now,
})
.where(eq(reminders.id, reminderId));
await pgNotifyBot({
type: "reminder.schedule",
reminderId,
scheduledAtIso: nextFire.toISOString(),
});
revalidatePath("/reminders" as any);
revalidatePath(`/reminders/${reminderId}` as any);
}
/**
* Duplicate a reminder. Creates a new reminder with the same account,
* groups, and message parts. The copy starts \`paused\` and inherits
* the source's scheduledAt / rrule unchanged the user can edit the
* schedule from the detail page and Restart when ready.
*/
export async function duplicateReminderAction(formData: FormData): Promise<void> {
await rateLimit("duplicate-reminder");
const reminderId = formData.get("reminderId");
if (typeof reminderId !== "string") return;
const op = await getSeededOperator();
const source = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!source) return;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) =>
and(eq(a.id, source.accountId), eq(a.operatorId, op.id)),
});
if (!account) return;
const sourceTargets = await db.query.reminderTargets.findMany({
where: (t, { eq }) => eq(t.reminderId, reminderId),
});
const sourceMessages = await db.query.reminderMessages.findMany({
where: (m, { eq }) => eq(m.reminderId, reminderId),
});
const newId = await db.transaction(async (tx) => {
const [rem] = await tx
.insert(reminders)
.values({
accountId: source.accountId,
name: `${source.name} (copy)`.slice(0, 60),
scheduleKind: source.scheduleKind,
scheduledAt: source.scheduledAt,
rrule: source.rrule,
timezone: source.timezone,
// Start paused so the copy doesn't fire on top of the original
// — the user picks a new time / reactivates from the detail page.
status: "paused",
createdBy: op.id,
})
.returning({ id: reminders.id });
if (sourceTargets.length > 0) {
await tx.insert(reminderTargets).values(
sourceTargets.map((t) => ({
reminderId: rem!.id,
groupId: t.groupId,
position: t.position,
})),
);
}
if (sourceMessages.length > 0) {
await tx.insert(reminderMessages).values(
sourceMessages.map((m) => ({
reminderId: rem!.id,
position: m.position,
kind: m.kind,
textContent: m.textContent,
mediaId: m.mediaId,
})),
);
}
return rem!.id;
});
revalidatePath("/reminders");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/reminders/${newId}` as any);
}
// A single deliverable message part. See lib/reminder-messages.ts for
// the wire format the wizard URL uses.
const messagePartSchema = z
.object({
kind: z.enum(["text", "media"]),
textContent: z.string().nullable().optional(),
mediaId: z.string().uuid().nullable().optional(),
})
.refine(
(m) =>
m.kind === "text"
? Boolean(m.textContent && m.textContent.trim())
: Boolean(m.mediaId),
{ message: "Each message part needs text or a media file" },
);
const createReminderSchema = z
.object({
accountId: z.string().uuid(),
groupIds: z.array(z.string().uuid()),
// The new shape — caller passes one or more MessageParts in send order.
// Optional/nullable here so the legacy fallback below can be used by
// older URL bookmarks; the refine() guarantees we end up with at
// least one valid message either way.
messages: z.array(messagePartSchema).optional(),
// User-supplied label shown in the list / detail page header.
// Required: every reminder must carry a non-empty name. The
// resolver still clamps to REMINDER_NAME_MAX so the DB column
// never has to reject the row. The legacy auto-derive from the
// first message part is kept as a fallback ONLY for legacy
// bookmarked URLs (where the create form was submitted before
// the field was added) — new submits always carry a name.
name: z.string().trim().min(1, "Give the reminder a name").max(60),
// Legacy single-message fields. Still accepted so bookmarked
// /reminders/new URLs don't 400 after the migration. The action body
// collapses these into `messages` before doing any work.
text: z.string().nullable().optional(),
mediaId: z.string().uuid().nullable().optional(),
caption: z.string().nullable().optional(),
// `.datetime({ offset: true })` accepts both UTC `Z` and zoned offsets
// like `+08:00` (luxon's `toISO()` produces the offset form).
scheduledAtIso: z.string().datetime({ offset: true }),
rrule: z.string().nullable().optional(),
timezone: z.string().default(DEFAULT_TIMEZONE),
// Delivery window in the operator's timezone. End hour will gate
// the runtime fan-out in a later phase; start is documented but
// not yet enforced. Optional in the input shape for backward
// compatibility — the action body falls back to 6/18.
deliveryWindowStartHour: z.number().int().min(0).max(24).optional(),
deliveryWindowEndHour: z.number().int().min(0).max(24).optional(),
})
.refine(
(d) =>
(d.messages && d.messages.length > 0) ||
Boolean(d.text?.trim()) ||
Boolean(d.mediaId),
{
message: "Add a message or attach a file",
path: ["messages"],
},
)
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 18), {
message: "Delivery window start must be earlier than end",
path: ["deliveryWindowStartHour"],
});
/** Resolve the schema's union of new + legacy fields into a flat list. */
function resolveMessageParts(parsed: z.infer<typeof createReminderSchema>): Array<{
kind: "text" | "media";
textContent: string | null;
mediaId: string | null;
}> {
if (parsed.messages && parsed.messages.length > 0) {
return parsed.messages.map((m) => ({
kind: m.kind,
textContent: m.textContent ?? null,
mediaId: m.mediaId ?? null,
}));
}
// Legacy: fold (text, mediaId, caption) into one part.
if (parsed.mediaId) {
return [
{
kind: "media",
mediaId: parsed.mediaId,
textContent: parsed.caption?.trim() || parsed.text?.trim() || null,
},
];
}
return [
{
kind: "text",
textContent: parsed.text!,
mediaId: null,
},
];
}
export type CreateReminderResult =
| { ok: true; reminderId: string }
| { ok: false; error: string };
export async function createReminderAction(
input: z.infer<typeof createReminderSchema>,
): Promise<CreateReminderResult> {
await rateLimit("create-reminder");
const parsed = createReminderSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
const {
accountId,
groupIds,
scheduledAtIso,
rrule,
timezone,
} = parsed.data;
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
const parts = resolveMessageParts(parsed.data);
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, error: "Account not yours" };
// Resolve the first-fire timestamp. Cron rules ignore the user-
// supplied date+time (the form sends a placeholder) and let the cron
// expression define when the reminder runs first.
let scheduledAt: Date;
if (rrule && isCronRule(rrule)) {
const minCheck = validateMinInterval(rrule, timezone);
if (!minCheck.ok) return { ok: false, error: minCheck.reason };
const firstFire = nextOccurrence(rrule, timezone, new Date());
if (!firstFire) {
return { ok: false, error: "Cron expression doesn't produce any future fire times" };
}
scheduledAt = firstFire;
} else {
scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate();
if (Number.isNaN(scheduledAt.getTime())) {
return { ok: false, error: "Invalid date" };
}
if (scheduledAt.getTime() <= Date.now()) {
return { ok: false, error: "Time is in the past" };
}
}
// Verify all groups belong to this account
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)),
});
if (groups.length !== groupIds.length) {
return { ok: false, error: "One or more groups don't belong to this account" };
}
// User-supplied name wins. If they didn't supply one, derive from
// the first text-bearing part (text body or caption). Falls back to
// the literal "Reminder" if every part is media-without-caption.
const reminderName = resolveReminderName(parsed.data.name, parts);
const reminderId = await db.transaction(async (tx) => {
const [rem] = await tx
.insert(reminders)
.values({
accountId,
name: reminderName,
scheduleKind: rrule ? "recurring" : "one_off",
scheduledAt,
rrule: rrule ?? null,
timezone,
deliveryWindowStartHour,
deliveryWindowEndHour,
status: "active",
createdBy: op.id,
})
.returning({ id: reminders.id });
if (groupIds.length > 0) {
await tx.insert(reminderTargets).values(
groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })),
);
}
await tx.insert(reminderMessages).values(
parts.map((p, position) => ({
reminderId: rem!.id,
position,
kind: p.kind,
textContent: p.textContent,
mediaId: p.mediaId,
})),
);
return rem!.id;
});
// Schedule via the bot's IPC consumer (Postgres NOTIFY)
await pgNotifyBot({
type: "reminder.schedule",
reminderId,
scheduledAtIso: scheduledAt.toISOString(),
});
return { ok: true, reminderId };
}
const updateReminderSchema = createReminderSchema.and(
z.object({ reminderId: z.string().uuid() }),
);
export type UpdateReminderResult =
| { ok: true; reminderId: string }
| { ok: false; error: string };
export async function updateReminderAction(
input: z.infer<typeof updateReminderSchema>,
): Promise<UpdateReminderResult> {
await rateLimit("update-reminder");
const parsed = updateReminderSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
}
const {
reminderId,
accountId,
groupIds,
scheduledAtIso,
rrule,
timezone,
} = parsed.data;
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
const parts = resolveMessageParts(parsed.data);
const op = await getSeededOperator();
// Verify the reminder exists, the operator owns its account, and the
// (possibly changed) target account is also theirs.
const existing = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!existing) return { ok: false, error: "Reminder not found" };
const ownerOfExisting = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, existing.accountId), eq(a.operatorId, op.id)),
});
if (!ownerOfExisting) return { ok: false, error: "Reminder not yours" };
const targetAccount = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!targetAccount) return { ok: false, error: "Account not yours" };
let scheduledAt: Date;
if (rrule && isCronRule(rrule)) {
const minCheck = validateMinInterval(rrule, timezone);
if (!minCheck.ok) return { ok: false, error: minCheck.reason };
const firstFire = nextOccurrence(rrule, timezone, new Date());
if (!firstFire) {
return { ok: false, error: "Cron expression doesn't produce any future fire times" };
}
scheduledAt = firstFire;
} else {
const validated = validateUpdateScheduledAt({
iso: scheduledAtIso,
timezone,
existingStatus: existing.status,
existingScheduledAt: existing.scheduledAt,
now: new Date(),
});
if (!validated.ok) return { ok: false, error: validated.error };
scheduledAt = validated.scheduledAt;
}
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)),
});
if (groups.length !== groupIds.length) {
return { ok: false, error: "One or more groups don't belong to this account" };
}
const reminderName = resolveReminderName(parsed.data.name, parts);
await db.transaction(async (tx) => {
await tx
.update(reminders)
.set({
accountId,
name: reminderName,
scheduleKind: rrule ? "recurring" : "one_off",
scheduledAt,
rrule: rrule ?? null,
timezone,
deliveryWindowStartHour,
deliveryWindowEndHour,
// Preserve the lifecycle status. Editing fields shouldn't
// implicitly re-activate a paused or ended reminder — the
// user can use the explicit Restart action for that.
status: existing.status,
updatedAt: new Date(),
})
.where(eq(reminders.id, reminderId));
// Replace targets and messages wholesale — simpler than diffing.
await tx.delete(reminderTargets).where(eq(reminderTargets.reminderId, reminderId));
if (groupIds.length > 0) {
await tx.insert(reminderTargets).values(
groupIds.map((groupId, position) => ({ reminderId, groupId, position })),
);
}
await tx.delete(reminderMessages).where(eq(reminderMessages.reminderId, reminderId));
await tx.insert(reminderMessages).values(
parts.map((p, position) => ({
reminderId,
position,
kind: p.kind,
textContent: p.textContent,
mediaId: p.mediaId,
})),
);
});
// Re-arm the pg-boss job at the new scheduled time. The handler uses
// singletonKey=reminder:<id> so this supersedes the prior arming.
await pgNotifyBot({
type: "reminder.schedule",
reminderId,
scheduledAtIso: scheduledAt.toISOString(),
});
revalidatePath("/reminders");
revalidatePath(`/reminders/${reminderId}`);
return { ok: true, reminderId };
}

View File

@ -1,47 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries";
import { EditAccountLabelForm } from "@/components/account-edit/edit-label-form";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditAccountLabelPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const account = await getAccount(op.id, id);
if (!account) notFound();
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}` as any}>
<ArrowLeftIcon />
Back
</Link>
</Button>
<div className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight">Edit name</h1>
<p className="text-sm text-muted-foreground">
The label shown in the accounts list, detail header, and activity log.
</p>
</div>
<Card>
<CardContent className="py-5">
<EditAccountLabelForm
accountId={account.id}
initialLabel={account.label}
/>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,135 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ArrowLeftIcon,
SearchIcon,
UsersIcon,
RefreshCwIcon,
Users2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
} from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator";
import { listGroupsForAccount } from "@/lib/queries";
interface Props {
params: Promise<{ id: string }>;
searchParams: Promise<{ q?: string }>;
}
export default async function GroupsListPage({ params, searchParams }: Props) {
const { id } = await params;
const { q } = await searchParams;
const op = await getSeededOperator();
const data = await listGroupsForAccount(op.id, id, q);
if (!data) {
notFound();
}
const { account, groups } = data;
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-3xl mx-auto space-y-6">
{/* Back link */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}` as any}>
<ArrowLeftIcon />
{account.label}
</Link>
</Button>
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">
Groups in {account.label}
</h1>
<Badge variant="secondary" className="h-6 px-2.5 text-xs tabular-nums">
{groups.length}
</Badge>
</div>
{/* Refresh button — no-op placeholder, wired in Task 17 */}
<form action={async () => { "use server"; /* wired in Task 17 */ }}>
<Button type="submit" variant="outline" size="sm" className="shrink-0">
<RefreshCwIcon />
Refresh Groups
</Button>
</form>
</div>
{/* Search */}
<form method="GET" className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
name="q"
type="search"
placeholder="Search groups…"
defaultValue={q ?? ""}
className="pl-9"
/>
</form>
{/* Group list */}
{groups.length > 0 ? (
<div className="divide-y divide-border rounded-xl ring-1 ring-foreground/10 overflow-hidden bg-card">
{groups.map((group) => (
<Link
key={group.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/groups/${group.id}` as any}
className="flex items-center justify-between gap-4 px-4 py-3.5 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted">
<UsersIcon className="size-4 text-muted-foreground" />
</div>
<span className="font-medium text-sm truncate">{group.name}</span>
</div>
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{group.participantCount} member{group.participantCount !== 1 ? "s" : ""}
</span>
</Link>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<Users2Icon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
{q ? (
<>
<p className="text-sm font-medium">No groups match &ldquo;{q}&rdquo;</p>
<p className="text-xs text-muted-foreground">
Try a different search term or clear the search to see all groups.
</p>
</>
) : (
<>
<p className="text-sm font-medium">No groups synced yet</p>
<p className="text-xs text-muted-foreground">
Use &ldquo;Refresh Groups&rdquo; to pull the latest groups from this WhatsApp account.
</p>
</>
)}
</div>
{q && (
<Button asChild variant="outline" size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}/groups` as any}>Clear search</Link>
</Button>
)}
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -1,316 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
UsersIcon,
Trash2Icon,
ArrowLeftIcon,
SmartphoneIcon,
CalendarIcon,
TagIcon,
DatabaseIcon,
PencilIcon,
PowerIcon,
PowerOffIcon,
ChevronRightIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries";
import {
unpairAccountAction,
pairAccountAction,
deleteAccountAction,
} from "@/actions/accounts";
interface AccountDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function AccountDetailPage({ params }: AccountDetailPageProps) {
const { id } = await params;
const op = await getSeededOperator();
const account = await getAccount(op.id, id);
if (!account) {
notFound();
}
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts" as any}>
<ArrowLeftIcon />
Accounts
</Link>
</Button>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{account.label}</h1>
<AccountStatusBadge status={account.status} />
</div>
{account.phoneNumber && account.status === "connected" && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<SmartphoneIcon className="size-3.5 shrink-0" />
{account.phoneNumber}
</p>
)}
</div>
<div className="flex flex-col gap-3">
{/* Name dedicated edit route mirrors the reminder edit-name
pattern. Tapping the row opens a focused editor; the
label is purely operator-facing. */}
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}/edit/label` as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted shrink-0">
<TagIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">Name</p>
<p className="text-sm font-medium truncate">{account.label}</p>
</div>
</div>
<PencilIcon className="size-4 text-muted-foreground/60 shrink-0" />
</CardContent>
</Card>
</Link>
{/* Pair / Re-pair keep the form-submit semantics. The whole
card surface is still the click target via a transparent
overlay submit button positioned over the card; the visible
Card stays a <div>, so we never nest a <div> inside a
<button> (invalid HTML SSR hydration mismatch). */}
{account.status !== "connected" && (
<form action={pairAccountAction} className="relative">
<input type="hidden" name="accountId" value={account.id} />
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10">
<PowerIcon className="size-4 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium">
{account.status === "unpaired" ? "Pair WhatsApp" : "Re-pair WhatsApp"}
</p>
<p className="text-xs text-muted-foreground">
Show a QR code so this account can connect to WhatsApp
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
<button
type="submit"
aria-label={account.status === "unpaired" ? "Pair WhatsApp" : "Re-pair WhatsApp"}
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</form>
)}
{account.status === "connected" && (
<>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}/groups` as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<UsersIcon className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Groups</p>
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
</Link>
{/* Unpair transparent <button> overlay opens the dialog
so we don't pass button-specific props onto the Card div
(Radix asChild does that and it produces a hydration
mismatch on a div). */}
<Dialog>
<Card className="relative transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm font-medium">Unpair</p>
<p className="text-xs text-muted-foreground">
Disconnect from WhatsApp; keep the account so you can re-pair later
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
<DialogTrigger asChild>
<button
type="button"
aria-label="Unpair WhatsApp"
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</DialogTrigger>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair this account?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> will disconnect from WhatsApp and
scheduled reminders using it will stop firing until you re-pair.
The account itself is kept; reminders and other data are not deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={unpairAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="default" size="sm">
<PowerOffIcon />
Yes, unpair
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
{/* Delete — transparent <button> overlay opens the dialog. */}
<Dialog>
<Card className="relative transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-medium text-destructive">Delete Account</p>
<p className="text-xs text-muted-foreground">
Remove the account and all its reminders, groups, and history
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
<DialogTrigger asChild>
<button
type="button"
aria-label="Delete account"
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
/>
</DialogTrigger>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this account permanently?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> will be removed along with its
synced groups, scheduled reminders, and all run history. This cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={deleteAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
Account details
</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex items-start gap-2">
<TagIcon className="size-3.5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<dt className="text-xs text-muted-foreground">Label</dt>
<dd className="text-sm font-medium">{account.label}</dd>
</div>
</div>
<div className="flex items-start gap-2">
<DatabaseIcon className="size-3.5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<dt className="text-xs text-muted-foreground">Status</dt>
<dd className="text-sm font-medium capitalize">
{account.status.replace(/_/g, " ")}
</dd>
</div>
</div>
<div className="flex items-start gap-2">
<CalendarIcon className="size-3.5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<dt className="text-xs text-muted-foreground">Paired at</dt>
<dd className="text-sm font-medium">
{account.createdAt.toLocaleString("en-MY", {
timeZone: "Asia/Kuala_Lumpur",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
})}
</dd>
</div>
</div>
{account.phoneNumber && (
<div className="flex items-start gap-2">
<SmartphoneIcon className="size-3.5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<dt className="text-xs text-muted-foreground">Phone number</dt>
<dd className="text-sm font-medium">{account.phoneNumber}</dd>
</div>
</div>
)}
</dl>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,56 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { PairLive } from "@/components/pair-live";
import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries";
interface PairingPageProps {
params: Promise<{ id: string }>;
}
export default async function PairingPage({ params }: PairingPageProps) {
const { id } = await params;
const op = await getSeededOperator();
const account = await getAccount(op.id, id);
if (!account) {
notFound();
}
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-lg mx-auto space-y-6">
{/* Back */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts" as any}>
<ArrowLeftIcon />
Accounts
</Link>
</Button>
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">{account.label}</h1>
<p className="text-sm text-muted-foreground">
Waiting for WhatsApp pairing
</p>
</div>
{/* Live QR card */}
<Card>
<CardHeader>
<CardTitle>Scan QR code</CardTitle>
<CardDescription>
A QR code will appear below. Scan it with WhatsApp on your phone to link this account.
</CardDescription>
</CardHeader>
<CardContent>
<PairLive accountId={account.id} label={account.label} />
</CardContent>
</Card>
</div>
);
}

View File

@ -1,53 +0,0 @@
import Link from "next/link";
import { ArrowLeftIcon, SmartphoneIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { PairForm } from "@/components/pair-form";
export default function NewAccountPage() {
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-lg mx-auto space-y-6">
{/* Back */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts" as any}>
<ArrowLeftIcon />
Accounts
</Link>
</Button>
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<SmartphoneIcon className="size-5 text-muted-foreground" />
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Add Account</h1>
<p className="text-sm text-muted-foreground">
Create a new account slot. You'll pair the WhatsApp number on the next screen.
</p>
</div>
</div>
{/* Form card */}
<Card>
<CardHeader>
<CardTitle>Account details</CardTitle>
<CardDescription>
Give this account a short label so you can identify it later. You can pair
multiple numbers one per account.
</CardDescription>
</CardHeader>
<CardContent>
<PairForm />
</CardContent>
</Card>
</div>
);
}

View File

@ -1,10 +0,0 @@
import { AccountsListView } from "@/components/accounts-list-view";
import { getSeededOperator } from "@/lib/operator";
import { listAccounts } from "@/lib/queries";
export default async function AccountsPage() {
const op = await getSeededOperator();
const accounts = await listAccounts(op.id);
return <AccountsListView accounts={accounts} />;
}

View File

@ -1,419 +0,0 @@
import Link from "next/link";
import {
ActivityIcon,
AlertTriangleIcon,
ArchiveIcon,
ArchiveRestoreIcon,
CheckCircle2Icon,
MinusCircleIcon,
Trash2Icon,
XCircleIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries";
import {
archiveRunAction,
clearHistoryAction,
deleteRunAction,
unarchiveRunAction,
} from "@/actions/history";
import { SwipeableRow } from "@/components/swipeable-row";
function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (diffSec < 60) return rtf.format(-diffSec, "second");
if (diffSec < 3600) return rtf.format(-Math.floor(diffSec / 60), "minute");
if (diffSec < 86400) return rtf.format(-Math.floor(diffSec / 3600), "hour");
return rtf.format(-Math.floor(diffSec / 86400), "day");
}
const RUN_STATUS_CONFIG: Record<
string,
{ label: string; className: string; icon: React.ElementType }
> = {
success: {
label: "Success",
className:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
icon: CheckCircle2Icon,
},
partial: {
label: "Partial",
className:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
icon: AlertTriangleIcon,
},
failed: {
label: "Failed",
className:
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
icon: XCircleIcon,
},
skipped: {
label: "Skipped",
className:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
icon: MinusCircleIcon,
},
};
function RunStatusBadge({ status }: { status: string }) {
const cfg = RUN_STATUS_CONFIG[status] ?? {
label: status,
className: "bg-secondary text-secondary-foreground border-transparent",
icon: ActivityIcon,
};
const Icon = cfg.icon;
return (
<Badge variant="secondary" className={cfg.className}>
<Icon className="size-3 mr-0.5" />
{cfg.label}
</Badge>
);
}
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped" | "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "success", label: "Success" },
{ value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
{ value: "archived", label: "Archived" },
];
interface PageProps {
searchParams: Promise<{ filter?: string }>;
}
interface ShelfButtonProps {
runId: string;
isArchived: boolean;
}
/**
* Left-shelf (revealed by swiping the row RIGHT). Hard-delete button.
* iOS-Mail-style: destructive action lives on the leading edge.
*/
function DeleteShelfButton({ runId }: ShelfButtonProps) {
return (
<form action={deleteRunAction} className="flex w-full">
<input type="hidden" name="runId" value={runId} />
<button
type="submit"
aria-label="Delete"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-destructive/15 text-destructive hover:bg-destructive/25 text-xs font-medium"
>
<Trash2Icon className="size-4" />
Delete
</button>
</form>
);
}
/**
* Right-shelf (revealed by swiping the row LEFT). Archive (or Restore
* when the row is already archived). Non-destructive trailing action.
*/
function ArchiveShelfButton({ runId, isArchived }: ShelfButtonProps) {
return (
<form
action={isArchived ? unarchiveRunAction : archiveRunAction}
className="flex w-full"
>
<input type="hidden" name="runId" value={runId} />
<button
type="submit"
aria-label={isArchived ? "Restore" : "Archive"}
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30 text-xs font-medium"
>
{isArchived ? (
<ArchiveRestoreIcon className="size-4" />
) : (
<ArchiveIcon className="size-4" />
)}
{isArchived ? "Restore" : "Archive"}
</button>
</form>
);
}
export default async function ActivityPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filter: FilterValue =
sp.filter === "success" ||
sp.filter === "partial" ||
sp.filter === "failed" ||
sp.filter === "skipped" ||
sp.filter === "archived"
? sp.filter
: "all";
const showingArchived = filter === "archived";
const op = await getSeededOperator();
const runs = await listActivityRuns(op.id, { archived: showingArchived });
const filtered =
filter === "all" || filter === "archived"
? runs
: runs.filter((r) => r.status === filter);
const hasAny = runs.length > 0;
return (
<PageShell
title="Activity"
action={
hasAny && !showingArchived ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
<Trash2Icon />
Clear history
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear all run history?</DialogTitle>
<DialogDescription>
This permanently removes every reminder run record, including
runs from reminders that have already been deleted. Reminders
themselves are not affected.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={clearHistoryAction}>
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, clear history
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
) : undefined
}
>
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
packed into a phone-width row left every label squeezed to
~50px. Wrap the list in an overflow-x scroller so each tab
keeps a readable label + comfortable touch target on mobile;
on desktop the row fits naturally and no scroll bar appears.
Negative margins extend the scroller to the page edges so the
first/last tabs don't look clipped against the container. */}
<Tabs value={filter}>
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsList>
{FILTER_TABS.map(({ value, label }) => (
<TabsTrigger key={value} value={value} asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
{label}
</Link>
</TabsTrigger>
))}
</TabsList>
</div>
</Tabs>
{filtered.length > 0 ? (
<>
<p className="text-xs text-muted-foreground sm:hidden">
Swipe left to Delete, or right to {showingArchived ? "Restore" : "Archive"}.
</p>
{/* Mobile: swipeable cards */}
<div className="flex flex-col gap-2 sm:hidden">
{filtered.map((run) => {
const clickable = run.reminderId && !run.isDeleted;
const inner = (
<CardContent className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{run.reminderName}
{run.isDeleted && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground italic">
(deleted)
</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{relativeTime(run.firedAt)}
</p>
</div>
<RunStatusBadge status={run.status} />
</CardContent>
);
const card = (
<Card
size="sm"
className={
clickable
? "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer rounded-none border-0 ring-0"
: "rounded-none border-0 ring-0"
}
>
{clickable ? (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminderId}` as any}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{inner}
</Link>
) : (
inner
)}
</Card>
);
return (
<SwipeableRow
// Key includes the archived flag so flipping it
// remounts the row with a fresh offset (closed shelf).
key={`${run.id}-${run.archivedAt ? "1" : "0"}`}
// Right swipe → reveal left shelf → Archive (non-destructive).
leftActions={
<ArchiveShelfButton runId={run.id} isArchived={Boolean(run.archivedAt)} />
}
// Left swipe → reveal right shelf → Delete (destructive).
rightActions={<DeleteShelfButton runId={run.id} isArchived={Boolean(run.archivedAt)} />}
>
{card}
</SwipeableRow>
);
})}
</div>
{/* Desktop: table with hover-revealed actions */}
<div className="hidden sm:block">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Reminder</TableHead>
<TableHead>Status</TableHead>
<TableHead>Fired</TableHead>
<TableHead className="w-1 text-right pr-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((run) => {
const clickable = run.reminderId && !run.isDeleted;
const isArchived = Boolean(run.archivedAt);
return (
<TableRow
key={run.id}
className={clickable ? "hover:bg-muted/50" : undefined}
>
<TableCell className="font-medium">
{clickable ? (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminderId}` as any}
className="block focus-visible:outline-none focus-visible:underline"
>
{run.reminderName}
</Link>
) : (
<span className="text-muted-foreground italic">
{run.reminderName}
{run.isDeleted && " (deleted)"}
</span>
)}
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
{relativeTime(run.firedAt)}
</TableCell>
<TableCell className="text-right pr-2 whitespace-nowrap">
<div className="inline-flex items-center gap-0.5">
<form
action={
isArchived ? unarchiveRunAction : archiveRunAction
}
>
<input type="hidden" name="runId" value={run.id} />
<Button
type="submit"
variant="ghost"
size="icon-sm"
aria-label={isArchived ? "Restore" : "Archive"}
className="text-muted-foreground hover:text-amber-700 dark:hover:text-amber-400"
>
{isArchived ? (
<ArchiveRestoreIcon className="size-4" />
) : (
<ArchiveIcon className="size-4" />
)}
</Button>
</form>
<form action={deleteRunAction}>
<input type="hidden" name="runId" value={run.id} />
<Button
type="submit"
variant="ghost"
size="icon-sm"
aria-label="Delete"
className="text-muted-foreground hover:text-destructive"
>
<Trash2Icon className="size-4" />
</Button>
</form>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</>
) : (
<EmptyState
icon={ActivityIcon}
title={
filter === "all"
? "No activity yet."
: showingArchived
? "No archived runs."
: `No ${filter} runs yet.`
}
description={
hasAny
? "Runs in other states aren't shown by this filter."
: "Reminder fire events will appear here."
}
/>
)}
</PageShell>
);
}

View File

@ -1,85 +0,0 @@
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() });
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
}
};
_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",
},
});
}

View File

@ -1,82 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the db module before importing the route — the route reaches into
// `db.query.whatsappAccounts.findFirst`. Each test sets the resolved value.
const findFirstMock = vi.fn();
vi.mock("@/lib/db", () => ({
db: {
query: {
whatsappAccounts: {
findFirst: (...args: unknown[]) => findFirstMock(...args),
},
},
},
}));
import { GET } from "./route";
const ACCOUNT_ID = "11111111-1111-1111-1111-111111111111";
const ctx = { params: Promise.resolve({ accountId: ACCOUNT_ID }) };
// "PNG\r\n\x1A\n" — start of a valid PNG, in base64.
const FAKE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
describe("GET /api/qr/[accountId]", () => {
beforeEach(() => {
findFirstMock.mockReset();
});
it("returns 404 when the account has no QR yet", async () => {
findFirstMock.mockResolvedValue({ lastQrPng: null });
const res = await GET(new Request("http://x/api/qr/x"), ctx);
expect(res.status).toBe(404);
});
it("returns 404 when the account row doesn't exist", async () => {
findFirstMock.mockResolvedValue(undefined);
const res = await GET(new Request("http://x/api/qr/x"), ctx);
expect(res.status).toBe(404);
});
it("returns 200 with the PNG bytes and the right headers when a QR is present", async () => {
findFirstMock.mockResolvedValue({ lastQrPng: FAKE_PNG_BASE64 });
const res = await GET(new Request("http://x/api/qr/x"), ctx);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("image/png");
// The endpoint serves a fresh QR each time the SSE bumps the timestamp,
// so it must not be cached.
expect(res.headers.get("cache-control")).toBe("no-store");
// Body should round-trip exactly back to the stored base64.
const buf = Buffer.from(await res.arrayBuffer());
expect(buf.toString("base64")).toBe(FAKE_PNG_BASE64);
// Sanity check: starts with the PNG magic bytes \x89 P N G.
expect(buf[0]).toBe(0x89);
expect(buf.subarray(1, 4).toString()).toBe("PNG");
});
it("queries the DB by the URL accountId", async () => {
findFirstMock.mockResolvedValue({ lastQrPng: FAKE_PNG_BASE64 });
await GET(new Request("http://x/api/qr/x"), ctx);
expect(findFirstMock).toHaveBeenCalledTimes(1);
const arg = findFirstMock.mock.calls[0]![0] as { where: unknown; columns: unknown };
expect(arg.columns).toEqual({ lastQrPng: true });
// Exercise the `where` predicate Drizzle would call with the schema +
// operator helpers. The route passes a closure that only uses `eq`.
let captured: unknown = null;
const fakeAccount = { id: "fake_id_col" };
const helpers = {
eq: (a: unknown, b: unknown) => {
captured = [a, b];
return "EQ_PREDICATE";
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (arg.where as any)(fakeAccount, helpers);
expect(result).toBe("EQ_PREDICATE");
expect(captured).toEqual([fakeAccount.id, ACCOUNT_ID]);
});
});

View File

@ -1,24 +0,0 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
interface RouteContext {
params: Promise<{ accountId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext): Promise<Response> {
const { accountId } = await ctx.params;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
columns: { lastQrPng: true },
});
if (!account?.lastQrPng) {
return new NextResponse("Not Found", { status: 404 });
}
const buf = Buffer.from(account.lastQrPng, "base64");
return new NextResponse(new Uint8Array(buf), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "no-store",
},
});
}

View File

@ -1,138 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: "Geist", system-ui, sans-serif;
}
:root {
color-scheme: light dark;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
body {
@apply bg-background text-foreground;
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@ -1,113 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ArrowLeftIcon,
UsersIcon,
BellPlusIcon,
ClockIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { SendTestForm } from "@/components/send-test-form";
import { getSeededOperator } from "@/lib/operator";
import { getGroup } from "@/lib/queries";
interface Props {
params: Promise<{ id: string }>;
}
export default async function GroupDetailPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getGroup(op.id, id);
if (!data) {
notFound();
}
const { group, account } = data;
const lastSynced = group.lastSyncedAt
? new Date(group.lastSyncedAt).toLocaleDateString("en-MY", {
timeZone: "Asia/Kuala_Lumpur",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "Never";
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
{/* Back link */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${group.accountId}/groups` as any}>
<ArrowLeftIcon />
Back to Groups
</Link>
</Button>
{/* Hero */}
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{group.name}</h1>
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<UsersIcon className="size-3.5 shrink-0" />
Account: {account.label}
{" · "}
{group.participantCount} member{group.participantCount !== 1 ? "s" : ""}
</p>
</div>
{/* Send Test Message */}
<Card>
<CardHeader>
<CardTitle>Send Test Message</CardTitle>
<CardDescription>
Send a one-off message to this group to verify the connection.
</CardDescription>
</CardHeader>
<CardContent>
<SendTestForm groupId={group.id} />
</CardContent>
</Card>
{/* Use in reminder */}
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<BellPlusIcon className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Use in a Reminder</p>
<p className="text-xs text-muted-foreground">
Schedule recurring messages to this group
</p>
</div>
</div>
<Button asChild variant="outline" size="sm" className="shrink-0">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/reminders/new?groupId=${group.id}` as any}>
New Reminder
</Link>
</Button>
</CardContent>
</Card>
{/* Last synced */}
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ClockIcon className="size-3.5 shrink-0" />
Last synced: {lastSynced}
</p>
</div>
);
}

View File

@ -1,56 +0,0 @@
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 { NotificationManager } from "@/components/notification-manager";
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",
// PWA wiring: the manifest comes from the dynamic route at
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
// emitted from public/, and `appleWebApp.capable` lets iOS treat the
// page like a standalone app when added to the home screen.
manifest: "/manifest.webmanifest",
icons: {
icon: [
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
},
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 (
// `suppressHydrationWarning` here is for *attribute* differences only.
// Two sources legitimately mutate <html>/<body> attributes after the
// document loads:
// - next-themes adds the `class="light|dark"` (and the colour-scheme
// style) before React hydrates,
// - browser extensions inject dunder attributes like
// `__gcrremoteframetoken`, password-manager flags, etc.
// Children are still hydration-checked normally so real bugs surface.
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
<body suppressHydrationWarning>
<ThemeProvider>
<AppShell>{children}</AppShell>
<Toaster richColors position="top-right" />
{/* SSE → browser notification bridge. Renders no DOM. */}
<NotificationManager />
</ThemeProvider>
</body>
</html>
);
}

View File

@ -1,79 +0,0 @@
import { describe, it, expect } from "vitest";
import { GET } from "./route";
/**
* Contract test for the PWA manifest. Most of these fields drive
* platform behaviour at install time:
*
* - Safari uses `name` and `apple-touch-icon.png` for the home
* screen tile,
* - Android Chrome uses `start_url` + `display: standalone` to
* decide whether to launch in fullscreen,
* - The `purpose: "any maskable"` icons let Android adaptive
* launchers crop without visual breakage.
*
* If any of these flip we want the test to fail loudly rather than
* have a silent change in install behaviour ship.
*/
describe("/manifest.webmanifest GET", () => {
it("responds with JSON content-type", async () => {
const res = GET();
expect(res.headers.get("content-type")).toMatch(/application\/json/);
});
it("declares the standalone display mode and home start URL", async () => {
const res = GET();
const body = (await res.json()) as Record<string, unknown>;
expect(body.display).toBe("standalone");
expect(body.start_url).toBe("/");
expect(body.scope).toBe("/");
expect(body.orientation).toBe("portrait");
});
it("carries the brand name + short_name + description", async () => {
const body = (await GET().json()) as Record<string, unknown>;
expect(body.name).toBe("cm WhatsApp Bot");
expect(body.short_name).toBe("cm WA Bot");
expect(body.description).toBe("Self-hosted WhatsApp reminder bot");
});
it("uses the dark theme + matching background colors", async () => {
const body = (await GET().json()) as Record<string, unknown>;
expect(body.theme_color).toBe("#0a0a0a");
expect(body.background_color).toBe("#0a0a0a");
});
it("ships a 192 + 512 icon pair, both PNG, both 'any maskable'", async () => {
const body = (await GET().json()) as { icons: Array<{
src: string;
sizes: string;
type: string;
purpose?: string;
}> };
expect(body.icons).toHaveLength(2);
const i192 = body.icons.find((i) => i.sizes === "192x192");
const i512 = body.icons.find((i) => i.sizes === "512x512");
expect(i192).toBeDefined();
expect(i512).toBeDefined();
for (const icon of body.icons) {
expect(icon.type).toBe("image/png");
// "any maskable" lets Android launchers crop the icon to their
// adaptive shape without exposing transparent bezels.
expect(icon.purpose).toBe("any maskable");
expect(icon.src.startsWith("/")).toBe(true);
}
});
it("matches the icon files actually committed under public/", async () => {
const body = (await GET().json()) as { icons: Array<{ src: string }> };
// Cross-check: the manifest claims paths the build pipeline must
// serve. If someone removes one of these PNGs without removing
// the manifest entry, install pages on Android break silently.
const srcs = body.icons.map((i) => i.src);
expect(srcs).toContain("/icon-192.png");
expect(srcs).toContain("/icon-512.png");
});
});

View File

@ -1,29 +0,0 @@
import { NextResponse } from "next/server";
/**
* PWA manifest. Served from `/manifest.webmanifest` so the document
* `<link rel="manifest" href="/manifest.webmanifest">` (set up via
* Next's metadata API in layout.tsx) can find it.
*
* `purpose: "any maskable"` lets the same icon work for both regular
* launch icons and Android maskable icons (where the OS crops the
* icon to a system-defined shape). `display: "standalone"` removes
* the browser chrome when launched from the home screen.
*/
export function GET() {
return NextResponse.json({
name: "cm WhatsApp Bot",
short_name: "cm WA Bot",
description: "Self-hosted WhatsApp reminder bot",
start_url: "/",
scope: "/",
display: "standalone",
orientation: "portrait",
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" },
],
});
}

View File

@ -1,352 +0,0 @@
import Link from "next/link";
import {
WifiIcon,
BellIcon,
ActivityIcon,
CheckCircle2Icon,
AlertTriangleIcon,
XCircleIcon,
MinusCircleIcon,
Trash2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { clearHistoryAction } from "@/actions/history";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { getSeededOperator } from "@/lib/operator";
import { getDashboardStats } from "@/lib/queries";
import { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
// ---------------------------------------------------------------------------
// Time helpers (no external dep, server-safe)
// ---------------------------------------------------------------------------
function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const diffMs = Date.now() - d.getTime();
const diffSec = Math.floor(diffMs / 1000);
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (diffSec < 60) return rtf.format(-diffSec, "second");
if (diffSec < 3600) return rtf.format(-Math.floor(diffSec / 60), "minute");
if (diffSec < 86400) return rtf.format(-Math.floor(diffSec / 3600), "hour");
return rtf.format(-Math.floor(diffSec / 86400), "day");
}
/** Absolute-time fallback used as a tooltip on relative-time displays.
* 12-hour format with AM/PM so the user can read it at a glance. */
function absoluteTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(d);
}
// ---------------------------------------------------------------------------
// Run-status pill
// ---------------------------------------------------------------------------
const RUN_STATUS_CONFIG: Record<
string,
{ label: string; className: string; icon: React.ElementType }
> = {
success: {
label: "Success",
className:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
icon: CheckCircle2Icon,
},
partial: {
label: "Partial",
className:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
icon: AlertTriangleIcon,
},
failed: {
label: "Failed",
className:
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
icon: XCircleIcon,
},
skipped: {
label: "Skipped",
className:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
icon: MinusCircleIcon,
},
};
function RunStatusBadge({ status }: { status: string }) {
const cfg = RUN_STATUS_CONFIG[status] ?? {
label: status,
className: "bg-secondary text-secondary-foreground border-transparent",
icon: ActivityIcon,
};
const Icon = cfg.icon;
return (
<Badge variant="secondary" className={cfg.className}>
<Icon className="size-3 mr-0.5" />
{cfg.label}
</Badge>
);
}
// ---------------------------------------------------------------------------
// Stat card — entire card is the link to its tab
// ---------------------------------------------------------------------------
function StatCard({
title,
value,
icon: Icon,
description,
href,
}: {
title: string;
value: string | number;
icon: React.ElementType;
description?: string;
href: string;
}) {
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<Icon className="size-4 text-muted-foreground shrink-0" />
</div>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
{description && (
<CardDescription className="mt-1 text-xs">{description}</CardDescription>
)}
</CardContent>
</Card>
</Link>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default async function DashboardPage() {
const op = await getSeededOperator();
const stats = await getDashboardStats(op.id);
const hasRuns = stats.recentRuns.length > 0;
return (
<PageShell title="Dashboard">
{/* Stat cards — click to drill into the corresponding tab */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard
title="WhatsApp accounts"
value={`${stats.connectedAccounts} / ${stats.unpairedAccounts} / ${stats.totalAccounts}`}
icon={WifiIcon}
description="Connected / Unpaired / Total"
href="/accounts"
/>
<StatCard
title="Reminders"
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.endedReminders} / ${stats.totalReminders}`}
icon={BellIcon}
description="Active / Paused / Ended / Total"
href="/reminders"
/>
</div>
{/* Recent activity */}
<section className="space-y-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-lg font-medium tracking-tight">Recent activity</h2>
<div className="flex items-center gap-1">
{hasRuns && (
<Button asChild variant="ghost" size="sm" className="text-muted-foreground">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/activity" as any}>View all</Link>
</Button>
)}
{hasRuns && (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
<Trash2Icon />
Clear history
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear all run history?</DialogTitle>
<DialogDescription>
This permanently removes every reminder run record, including
runs from reminders that have already been deleted. Reminders
themselves are not affected.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={clearHistoryAction}>
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, clear history
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</div>
{hasRuns ? (
<>
{/* Mobile: card list — clickable when the reminder still exists */}
<div className="flex flex-col gap-3 sm:hidden">
{stats.recentRuns.map((run) => {
const body = (
<Card size="sm" className={run.is_deleted ? undefined : "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"}>
<CardContent className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{run.name}
{run.is_deleted && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground italic">
(deleted)
</span>
)}
</p>
<time
dateTime={new Date(run.fired_at).toISOString()}
title={absoluteTime(run.fired_at)}
className="text-xs text-muted-foreground mt-0.5 block"
>
{absoluteTime(run.fired_at)} · {relativeTime(run.fired_at)}
</time>
</div>
<RunStatusBadge status={run.status} />
</CardContent>
</Card>
);
return run.reminder_id && !run.is_deleted ? (
<Link
key={run.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminder_id}` as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{body}
</Link>
) : (
<div key={run.id}>{body}</div>
);
})}
</div>
{/* Desktop: table — rows are clickable when reminder still exists */}
<div className="hidden sm:block">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Reminder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Fired</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.recentRuns.map((run) => {
const clickable = run.reminder_id && !run.is_deleted;
return (
<TableRow
key={run.id}
className={clickable ? "cursor-pointer hover:bg-muted/50" : undefined}
>
<TableCell className="font-medium">
{clickable ? (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminder_id}` as any}
className="block focus-visible:outline-none focus-visible:underline"
>
{run.name}
</Link>
) : (
<span className="text-muted-foreground italic">
{run.name}
</span>
)}
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-right text-muted-foreground text-xs">
<time
dateTime={new Date(run.fired_at).toISOString()}
title={absoluteTime(run.fired_at)}
>
{absoluteTime(run.fired_at)}
</time>
<span className="block text-[10px] opacity-75">
{relativeTime(run.fired_at)}
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</>
) : (
<EmptyState
icon={ActivityIcon}
title="No reminders have fired yet."
description="Schedule one to start sending WhatsApp messages."
action={
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}>Schedule a reminder</Link>
</Button>
}
/>
)}
</section>
</PageShell>
);
}

View File

@ -1,90 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
vi.mock("@/actions/reminders", () => ({
pauseReminderAction: vi.fn(),
restartReminderAction: vi.fn(),
deleteReminderAction: vi.fn(),
duplicateReminderAction: vi.fn(),
}));
// Make Dialog primitives transparent so we can grep the underlying tree.
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children }: { children: ReactNode }) => <>{children}</>,
DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}</>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
import { ActionsBar } from "./actions-bar";
describe("ActionsBar — card visibility by status", () => {
it("active: shows Pause and Delete (no Restart)", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="active" isRecurring={false} />,
);
expect(html).toMatch(/aria-label="Pause"/);
expect(html).toMatch(/aria-label="Delete"/);
expect(html).not.toMatch(/aria-label="Restart"/);
});
it("paused: shows Restart and Delete (no Pause)", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="paused" isRecurring={false} />,
);
expect(html).toMatch(/aria-label="Restart"/);
expect(html).toMatch(/aria-label="Delete"/);
expect(html).not.toMatch(/aria-label="Pause"/);
});
it("ended: shows Restart and Delete (no Pause)", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="ended" isRecurring={false} />,
);
expect(html).toMatch(/aria-label="Restart"/);
expect(html).toMatch(/aria-label="Delete"/);
expect(html).not.toMatch(/aria-label="Pause"/);
});
it("any other terminal status (banned, etc.): only Delete is offered", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="failed" isRecurring={false} />,
);
expect(html).toMatch(/aria-label="Delete"/);
expect(html).not.toMatch(/aria-label="Pause"/);
expect(html).not.toMatch(/aria-label="Restart"/);
});
});
describe("ActionsBar — copy varies for recurring vs one-off restart", () => {
it("recurring: Restart description mentions next occurrence", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="paused" isRecurring={true} />,
);
expect(html).toContain("Activate and re-arm at next occurrence");
});
it("one-off: Restart description says it'll fire in ~1 minute", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="paused" isRecurring={false} />,
);
expect(html).toContain("Activate and fire in ~1 minute");
});
});
describe("ActionsBar — every confirm dialog carries the reminderId", () => {
it("hidden inputs match the supplied id, in every visible card", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="abc-uuid" status="active" isRecurring={false} />,
);
// Pause and Delete should each have a hidden input with the id.
const matches = html.match(
/<input[^>]+type="hidden"[^>]+name="reminderId"[^>]+value="abc-uuid"/g,
);
expect(matches?.length ?? 0).toBeGreaterThanOrEqual(2);
});
});

View File

@ -1,209 +0,0 @@
"use client";
import { useState } from "react";
import {
AlertCircleIcon,
CopyIcon,
Loader2Icon,
PauseIcon,
PlayIcon,
Trash2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
deleteReminderAction,
duplicateReminderAction,
pauseReminderAction,
restartReminderAction,
} from "@/actions/reminders";
interface ActionsBarProps {
reminderId: string;
status: string;
isRecurring: boolean;
}
/**
* Lifecycle controls for a reminder. Three cards rendered side-by-side
* on desktop, stacked on mobile:
*
* - Pause only when status === "active"
* - Restart when status is "paused" or "ended"
* - Delete always available (terminal)
*
* Each Dialog confirms before firing the corresponding server action.
* No <button>-wrapping-Card nesting (caught by the static guard test).
*/
export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) {
const canPause = status === "active";
const canRestart = status === "paused" || status === "ended";
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
{canPause && (
<ConfirmCard
title="Pause"
description="Stop firing until you restart"
icon={<PauseIcon className="size-4 text-amber-600 dark:text-amber-400" />}
accentBg="bg-amber-500/10"
accentRing="hover:ring-amber-500/30"
dialogTitle="Pause this reminder?"
dialogBody="It won't fire while paused. You can restart it later from this page."
confirmLabel="Yes, pause"
confirmVariant="default"
confirmIcon={<PauseIcon />}
action={pauseReminderAction}
reminderId={reminderId}
/>
)}
{canRestart && (
<ConfirmCard
title="Restart"
description={
isRecurring
? "Activate and re-arm at next occurrence"
: "Activate and fire in ~1 minute"
}
icon={<PlayIcon className="size-4 text-emerald-600 dark:text-emerald-400" />}
accentBg="bg-emerald-500/10"
accentRing="hover:ring-emerald-500/30"
dialogTitle="Restart this reminder?"
dialogBody={
isRecurring
? "The next occurrence will be computed from the recurrence rule and the reminder will fire on schedule."
: "The reminder will become active and fire about a minute from now."
}
confirmLabel="Yes, restart"
confirmVariant="default"
confirmIcon={<PlayIcon />}
action={restartReminderAction}
reminderId={reminderId}
/>
)}
{/* Duplicate — always available, non-destructive */}
<ConfirmCard
title="Duplicate"
description="Make a paused copy you can edit and start"
icon={<CopyIcon className="size-4 text-sky-600 dark:text-sky-400" />}
accentBg="bg-sky-500/10"
accentRing="hover:ring-sky-500/30"
dialogTitle="Duplicate this reminder?"
dialogBody="A paused copy is created with the same account, groups, message and schedule. Edit it and Restart when you're ready."
confirmLabel="Yes, duplicate"
confirmVariant="default"
confirmIcon={<CopyIcon />}
action={duplicateReminderAction}
reminderId={reminderId}
/>
{/* Delete is always available */}
<ConfirmCard
title="Delete"
description="Remove the reminder; history is kept"
icon={<Trash2Icon className="size-4 text-destructive" />}
accentBg="bg-destructive/10"
accentRing="hover:ring-destructive/30"
dialogTitle="Delete this reminder?"
dialogBody="The reminder will be removed. Run history is preserved on the Activity tab."
confirmLabel="Yes, delete"
confirmVariant="destructive"
confirmIcon={<Trash2Icon />}
action={deleteReminderAction}
reminderId={reminderId}
/>
</div>
);
}
interface ConfirmCardProps {
title: string;
description: string;
icon: React.ReactNode;
accentBg: string;
accentRing: string;
dialogTitle: string;
dialogBody: string;
confirmLabel: string;
confirmVariant: "default" | "destructive";
confirmIcon: React.ReactNode;
action: (formData: FormData) => Promise<void>;
reminderId: string;
}
function ConfirmCard(props: ConfirmCardProps) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<Dialog>
<Card className={`relative transition-all hover:shadow-md ${props.accentRing} cursor-pointer`}>
<CardContent className="flex items-center gap-3 py-3 px-4">
<div className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${props.accentBg}`}>
{props.icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{props.title}</p>
<p className="text-xs text-muted-foreground truncate">{props.description}</p>
</div>
</CardContent>
<DialogTrigger asChild>
<button
type="button"
aria-label={props.title}
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</DialogTrigger>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.dialogTitle}</DialogTitle>
<DialogDescription>{props.dialogBody}</DialogDescription>
</DialogHeader>
{error && (
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" />
{error}
</p>
)}
<DialogFooter showCloseButton>
<form
action={async (fd: FormData) => {
setSubmitting(true);
setError(null);
try {
await props.action(fd);
} catch (e) {
setError(e instanceof Error ? e.message : "Action failed");
setSubmitting(false);
}
}}
>
<input type="hidden" name="reminderId" value={props.reminderId} />
<Button
type="submit"
variant={props.confirmVariant}
size="sm"
disabled={submitting}
className="gap-2"
>
{submitting ? <Loader2Icon className="size-4 animate-spin" /> : props.confirmIcon}
{submitting ? "Working…" : props.confirmLabel}
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,57 +0,0 @@
import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns, listAccounts } from "@/lib/queries";
import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditAccountForm } from "@/components/reminder-edit/edit-account-form";
import type { MessagePart } from "@/lib/reminder-messages";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditAccountPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getReminderWithRuns(op.id, id);
if (!data) notFound();
const { reminder, messages } = data;
const allAccounts = await listAccounts(op.id);
// Forward the entire message stack through as-is. Earlier this page
// pulled only `messages[0]` and reduced it to legacy text/mediaId
// fields — saving from the form then deleted parts 2..N from
// reminder_messages, since updateReminderAction replaces the stack.
const initialMessages: MessagePart[] = messages
.slice()
.sort((a, b) => a.position - b.position)
.map((m) => ({
kind: m.kind === "media" ? "media" : "text",
textContent: m.textContent ?? null,
mediaId: m.mediaId ?? null,
}));
return (
<EditShell
reminderId={reminder.id}
title="Edit account"
description="Switch which WhatsApp account sends this reminder. Group targets reset because groups are scoped per account."
>
<EditAccountForm
reminderId={reminder.id}
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule}
messages={initialMessages}
name={reminder.name}
timezone={reminder.timezone}
accounts={allAccounts.map((a) => ({
id: a.id,
label: a.label,
status: a.status,
phoneNumber: a.phoneNumber,
}))}
initialAccountId={reminder.accountId}
/>
</EditShell>
);
}

View File

@ -1,53 +0,0 @@
import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns, listGroupsForAccount } from "@/lib/queries";
import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditGroupsForm } from "@/components/reminder-edit/edit-groups-form";
import type { MessagePart } from "@/lib/reminder-messages";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditGroupsPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getReminderWithRuns(op.id, id);
if (!data) notFound();
const { reminder, targets, messages } = data;
const groupsResult = await listGroupsForAccount(op.id, reminder.accountId);
const groups = groupsResult?.groups ?? [];
// Pass the full message stack through. See edit/account/page.tsx —
// the action replaces the stack on save, so we have to forward all
// existing parts or they get dropped.
const initialMessages: MessagePart[] = messages
.slice()
.sort((a, b) => a.position - b.position)
.map((m) => ({
kind: m.kind === "media" ? "media" : "text",
textContent: m.textContent ?? null,
mediaId: m.mediaId ?? null,
}));
return (
<EditShell
reminderId={reminder.id}
title="Edit groups"
description="Pick which WhatsApp groups receive this reminder. Leave empty to save without targets."
>
<EditGroupsForm
reminderId={reminder.id}
accountId={reminder.accountId}
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule}
messages={initialMessages}
name={reminder.name}
timezone={reminder.timezone}
groups={groups}
initialSelected={targets.map((t) => t.groupId)}
/>
</EditShell>
);
}

View File

@ -1,68 +0,0 @@
import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
import { db } from "@/lib/db";
import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditMessageForm } from "@/components/reminder-edit/edit-message-form";
import type { MessagePart } from "@/lib/reminder-messages";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditMessagePage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getReminderWithRuns(op.id, id);
if (!data) notFound();
const { reminder, targets, messages } = data;
// Hydrate the wire-format MessagePart[] from the per-row `reminder_messages`
// table. The DB already supports a stack of parts in `position` order;
// earlier code only ever wrote one row, but parsing N is the same loop.
const initialMessages: MessagePart[] = messages
.slice()
.sort((a, b) => a.position - b.position)
.map((m) => ({
kind: m.kind === "media" ? "media" : "text",
textContent: m.textContent ?? null,
mediaId: m.mediaId ?? null,
}));
// Resolve filenames for any attached media so the editor shows what
// the user previously uploaded instead of a blank "Replace" button.
const mediaIds = initialMessages
.map((m) => m.mediaId)
.filter((id): id is string => Boolean(id));
const mediaInfo: Record<string, { filename: string; mimeType: string }> = {};
if (mediaIds.length > 0) {
const rows = await db.query.mediaFiles.findMany({
where: (m, { inArray }) => inArray(m.id, mediaIds),
columns: { id: true, filenameOriginal: true, mimeType: true },
});
for (const r of rows) {
mediaInfo[r.id] = { filename: r.filenameOriginal, mimeType: r.mimeType };
}
}
return (
<EditShell
reminderId={reminder.id}
title="Edit message"
description="Stack as many text and file parts as you need; the bot sends them in order with a short pause between."
>
<EditMessageForm
reminderId={reminder.id}
accountId={reminder.accountId}
groupIds={targets.map((t) => t.groupId)}
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule}
timezone={reminder.timezone}
name={reminder.name}
initialMessages={initialMessages}
initialMediaInfo={mediaInfo}
/>
</EditShell>
);
}

View File

@ -1,50 +0,0 @@
import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditNameForm } from "@/components/reminder-edit/edit-name-form";
import type { MessagePart } from "@/lib/reminder-messages";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditNamePage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getReminderWithRuns(op.id, id);
if (!data) notFound();
const { reminder, targets, messages } = data;
// Forward the existing message stack so saving the name doesn't
// wipe parts 2..N from reminder_messages (the action replaces the
// stack wholesale on every update).
const initialMessages: MessagePart[] = messages
.slice()
.sort((a, b) => a.position - b.position)
.map((m) => ({
kind: m.kind === "media" ? "media" : "text",
textContent: m.textContent ?? null,
mediaId: m.mediaId ?? null,
}));
return (
<EditShell
reminderId={reminder.id}
title="Edit name"
description="The label shown in the reminder list, detail header, and activity log."
>
<EditNameForm
reminderId={reminder.id}
accountId={reminder.accountId}
groupIds={targets.map((t) => t.groupId)}
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule}
timezone={reminder.timezone}
initialName={reminder.name}
messages={initialMessages}
/>
</EditShell>
);
}

View File

@ -1,52 +0,0 @@
import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
import { specFromRrule } from "@/lib/recurrence";
import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditWhenForm } from "@/components/reminder-edit/edit-when-form";
import type { MessagePart } from "@/lib/reminder-messages";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditWhenPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getReminderWithRuns(op.id, id);
if (!data) notFound();
const { reminder, targets, messages } = data;
// Pass the full stack through. See edit/account/page.tsx for why —
// previously this page took only messages[0] and the action then
// wiped parts 2..N when saving the schedule.
const initialMessages: MessagePart[] = messages
.slice()
.sort((a, b) => a.position - b.position)
.map((m) => ({
kind: m.kind === "media" ? "media" : "text",
textContent: m.textContent ?? null,
mediaId: m.mediaId ?? null,
}));
return (
<EditShell
reminderId={reminder.id}
title="Edit schedule"
description="Change when this reminder fires and how often it repeats."
>
<EditWhenForm
reminderId={reminder.id}
accountId={reminder.accountId}
groupIds={targets.map((t) => t.groupId)}
messages={initialMessages}
name={reminder.name}
initialIso={(reminder.scheduledAt ?? new Date()).toISOString()}
initialSpec={specFromRrule(reminder.rrule)}
initialDeliveryEndHour={reminder.deliveryWindowEndHour}
timezone={reminder.timezone}
/>
</EditShell>
);
}

View File

@ -1,329 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ArrowLeftIcon,
CalendarIcon,
SmartphoneIcon,
UsersIcon,
ClockIcon,
FileTextIcon,
RepeatIcon,
PencilIcon,
TagIcon,
} from "lucide-react";
import { DateTime } from "luxon";
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
import { ActionsBar } from "./actions-bar";
function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—";
return new Intl.DateTimeFormat("en-MY", {
timeZone: tz,
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(date));
}
const STATUS_STYLES: Record<string, string> = {
active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
ended:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
failed:
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
success:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
};
function StatusPill({ status }: { status: string }) {
const cls =
STATUS_STYLES[status] ??
"bg-secondary text-secondary-foreground border-transparent";
const label = status.charAt(0).toUpperCase() + status.slice(1);
return (
<Badge variant="secondary" className={cls}>
{label}
</Badge>
);
}
interface Props {
params: Promise<{ id: string }>;
}
export default async function ReminderDetailPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getReminderWithRuns(op.id, id);
if (!data) {
notFound();
}
const { reminder, account, targets, messages, runs } = data;
const tz = op.defaultTimezone ?? "UTC";
// Per-section edit pages — each opens a focused single-form editor for
// just that part of the reminder, no multi-step flow.
type Section = "name" | "account" | "message" | "when" | "groups";
const editHref = (section: Section): string =>
`/reminders/${reminder.id}/edit/${section}`;
const cardClasses =
"transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer";
const linkWrapperClasses =
"block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-3xl mx-auto space-y-6">
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders" as any}>
<ArrowLeftIcon />
Back to Reminders
</Link>
</Button>
<div className="space-y-2">
<div className="flex items-start gap-3 flex-wrap">
<h1 className="text-2xl font-semibold tracking-tight leading-tight flex-1 min-w-0">
{reminder.name}
</h1>
<StatusPill status={reminder.status} />
</div>
<p className="text-xs text-muted-foreground italic">
Tap any section below to edit it.
</p>
</div>
<Separator />
{/* Name click to edit. Required field, the operator's
identifier for the reminder in lists / activity / runs. */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editHref("name") as any} className={linkWrapperClasses} aria-label="Edit name">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<TagIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Name
</p>
<p className="text-sm font-medium truncate">{reminder.name}</p>
</div>
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
</CardContent>
</Card>
</Link>
{/* Account — click to edit step 1 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editHref("account") as any} className={linkWrapperClasses} aria-label="Edit account">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<SmartphoneIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Account
</p>
<p className="text-sm font-medium truncate">{account.label}</p>
{account.phoneNumber && (
<p className="text-xs text-muted-foreground truncate">{account.phoneNumber}</p>
)}
</div>
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
</CardContent>
</Card>
</Link>
{/* Message — click to edit step 2 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editHref("message") as any} className={linkWrapperClasses} aria-label="Edit message">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<FileTextIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Message
</p>
{messages.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No message parts defined.</p>
) : (
messages.map((msg, i) => (
<div key={msg.id}>
{i > 0 && <Separator className="my-2" />}
{msg.kind === "text" && msg.textContent ? (
<p className="text-sm whitespace-pre-wrap break-words">
{msg.textContent}
</p>
) : (
<p className="text-sm font-mono text-muted-foreground">
[{msg.kind}]{msg.textContent ? ` ${msg.textContent}` : ""}
</p>
)}
</div>
))
)}
</div>
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
</CardContent>
</Card>
</Link>
{/* When / Recurrence — click to edit step 3 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editHref("when") as any} className={linkWrapperClasses} aria-label="Edit schedule">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<CalendarIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{reminder.rrule ? "First fire" : "When"}
</p>
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
{reminder.rrule && reminder.scheduledAt ? (
<p className="flex items-center gap-1.5 text-xs text-primary/80">
<RepeatIcon className="size-3 shrink-0" />
{describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
</p>
) : (
<p className="text-xs text-muted-foreground">One-off</p>
)}
</div>
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
</CardContent>
</Card>
</Link>
{/* Groups — click to edit step 4 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editHref("groups") as any} className={linkWrapperClasses} aria-label="Edit groups">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<UsersIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Groups
{targets.length > 0 ? ` · ${targets.length}` : " · none"}
</p>
{targets.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No groups reminder won't deliver until you add at least one
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{targets.map((t) => (
<Badge key={t.groupId} variant="secondary">
{t.groupName}
</Badge>
))}
</div>
)}
</div>
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
</CardContent>
</Card>
</Link>
<Separator />
{/* Run history — read-only */}
<section className="space-y-3">
<h2 className="text-base font-medium tracking-tight flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
Run history
</h2>
{runs.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-10 text-center">
<ClockIcon className="size-8 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">Has not fired yet.</p>
<p className="text-xs text-muted-foreground">
Runs will appear here once the reminder fires.
</p>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden sm:table-cell">Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow key={run.id}>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatWhen(run.firedAt, tz)}
</TableCell>
<TableCell>
<StatusPill status={run.status} />
</TableCell>
<TableCell className="hidden sm:table-cell text-xs text-muted-foreground max-w-xs truncate">
{run.errorSummary ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</section>
{/* Lifecycle actions — Pause / Restart / Delete (section cards
above handle editing). */}
<div className="space-y-3 pt-2 border-t">
<h2 className="text-base font-medium tracking-tight">Actions</h2>
<ActionsBar
reminderId={reminder.id}
status={reminder.status}
isRecurring={reminder.scheduleKind === "recurring"}
/>
</div>
</div>
);
}

View File

@ -1,50 +0,0 @@
import { notFound } from "next/navigation";
import { Stepper } from "@/components/reminder-wizard/stepper";
import { StepAccount } from "@/components/reminder-wizard/step-account";
import { StepGroups } from "@/components/reminder-wizard/step-groups";
import { StepCompose } from "@/components/reminder-wizard/step-compose";
import { StepWhen } from "@/components/reminder-wizard/step-when";
import { StepReview } from "@/components/reminder-wizard/step-review";
interface PageProps {
searchParams: Promise<{
step?: string;
accountId?: string;
groupIds?: string;
/** User-supplied reminder name. Optional server falls back to
* the first text-bearing message part when blank. */
name?: string;
/** New shape — encoded MessagePart[]. Replaces text/mediaId/caption. */
messages?: string;
/** Legacy single-message fields. Still accepted; the steps fold them
* into the new shape on entry so deep links don't break. */
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
rrule?: string;
groupId?: string;
editReminderId?: string;
}>;
}
export default async function NewReminderPage({ searchParams }: PageProps) {
const sp = await searchParams;
const step = Number(sp.step ?? "1");
if (![1, 2, 3, 4, 5].includes(step)) notFound();
const isEdit = Boolean(sp.editReminderId);
return (
<div className="container mx-auto max-w-2xl space-y-6 p-4 sm:p-6">
<h1 className="text-2xl font-semibold tracking-tight">
{isEdit ? "Edit Reminder" : "New Reminder"}
</h1>
<Stepper current={step} />
{step === 1 && <StepAccount />}
{step === 2 && <StepCompose params={sp} />}
{step === 3 && <StepWhen params={sp} />}
{step === 4 && <StepGroups params={sp} />}
{step === 5 && <StepReview params={sp} />}
</div>
);
}

View File

@ -1,361 +0,0 @@
import Link from "next/link";
import {
PlusIcon,
BellIcon,
CalendarIcon,
UsersIcon,
RepeatIcon,
PauseIcon,
PlayIcon,
Trash2Icon,
} from "lucide-react";
import { DateTime } from "luxon";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
import { getSeededOperator } from "@/lib/operator";
import { listAccounts, listReminders } from "@/lib/queries";
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
import {
applyReminderFilter,
type SortKey,
type ReminderRow,
} from "@/lib/reminder-filter";
import { ReminderFilterBar } from "@/components/reminder-filter-bar";
import { SwipeableRow } from "@/components/swipeable-row";
import {
deleteReminderAction,
pauseReminderAction,
restartReminderAction,
} from "@/actions/reminders";
type FilterValue = "all" | "active" | "ended" | "paused";
function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—";
return new Intl.DateTimeFormat("en-MY", {
timeZone: tz,
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(date));
}
const STATUS_STYLES: Record<string, string> = {
active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
ended:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
failed:
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
};
/**
* Shared shelf-button component for swipeable reminder rows. Wraps a
* server action in a tiny form so the row stays a server component;
* the page revalidates after the action lands.
*/
function ReminderShelfButton({
reminderId,
label,
icon,
action,
bg,
}: {
reminderId: string;
label: string;
icon: React.ReactNode;
action: (formData: FormData) => Promise<void>;
bg: string;
}) {
return (
<form action={action} className="flex w-full">
<input type="hidden" name="reminderId" value={reminderId} />
<button
type="submit"
aria-label={label}
className={`flex h-full w-full flex-col items-center justify-center gap-1 text-xs font-medium ${bg}`}
>
{icon}
{label}
</button>
</form>
);
}
function StatusPill({ status }: { status: string }) {
const cls =
STATUS_STYLES[status] ??
"bg-secondary text-secondary-foreground border-transparent";
const label = status.charAt(0).toUpperCase() + status.slice(1);
return (
<Badge variant="secondary" className={cls}>
{label}
</Badge>
);
}
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "ended", label: "Ended" },
{ value: "paused", label: "Paused" },
];
const VALID_SORT_KEYS: SortKey[] = [
"scheduled_desc",
"scheduled_asc",
"created_desc",
"name_asc",
];
interface PageProps {
searchParams: Promise<{
filter?: string;
q?: string;
accountId?: string;
sort?: string;
}>;
}
export default async function RemindersPage({ searchParams }: PageProps) {
const sp = await searchParams;
const status: FilterValue =
sp.filter === "active" || sp.filter === "ended" || sp.filter === "paused"
? sp.filter
: "all";
// Sort is now fixed to `created_desc`. Reordering on every status flip
// (Restart, Pause, Edit) was causing rows to jump around the list,
// which made the swipe gesture feel like the wrong thing happened.
// `created_at` never changes so the row stays put.
const sort: SortKey = "created_desc";
void VALID_SORT_KEYS; // kept for future use; no longer read from URL
const op = await getSeededOperator();
const tz = op.defaultTimezone ?? "UTC";
// Run the reminder query and the filter-options query in parallel.
// The Group filter was removed (per user request — search already
// matches group names) so we don't need the groups list anymore.
const [allReminders, accounts] = await Promise.all([
listReminders(op.id),
listAccounts(op.id),
]);
const filterRows: ReminderRow[] = allReminders.map((r) => ({
id: r.id,
name: r.name,
status: r.status,
accountId: r.accountId,
accountLabel: r.accountLabel,
groupIds: r.groupIds,
groupNames: r.groupNames,
firstText: r.firstText,
scheduledAt: r.scheduledAt,
createdAt: r.createdAt,
}));
const sortedFiltered = applyReminderFilter(filterRows, {
q: sp.q,
accountId: sp.accountId,
status,
sort,
});
const visible = sortedFiltered
.map((r) => allReminders.find((row) => row.id === r.id))
.filter((r): r is (typeof allReminders)[number] => Boolean(r));
const tabHref = (value: FilterValue): string => {
const params = new URLSearchParams();
if (value !== "all") params.set("filter", value);
if (sp.q) params.set("q", sp.q);
if (sp.accountId) params.set("accountId", sp.accountId);
const qs = params.toString();
return qs ? `/reminders?${qs}` : "/reminders";
};
const hasAnyFilter = Boolean(sp.q || sp.accountId);
return (
<PageShell
title="Reminders"
floatingAction={
<Button
asChild
size="sm"
className="rounded-full shadow-md hover:shadow-lg transition-shadow gap-1.5 sm:h-7 size-12 sm:size-auto sm:px-2.5"
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any} aria-label="New reminder">
<PlusIcon className="size-5 sm:size-3.5" />
<span className="hidden sm:inline">New Reminder</span>
</Link>
</Button>
}
>
<ReminderFilterBar
accounts={accounts.map((a) => ({ id: a.id, label: a.label }))}
/>
{/* Status tabs — preserve other filter params so flipping tabs doesn't lose them */}
<Tabs value={status}>
<TabsList className="w-full">
{FILTER_TABS.map(({ value, label }) => (
<TabsTrigger key={value} value={value} asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={tabHref(value) as any}>{label}</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{visible.length > 0 ? (
<>
<p className="text-xs text-muted-foreground sm:hidden">
Swipe a row left to Delete, or right to{" "}
{status === "paused" ? "Restart" : "Pause"}.
</p>
<div className="flex flex-col gap-3">
{visible.map((reminder) => {
const canPause = reminder.status === "active";
const canRestart =
reminder.status === "paused" || reminder.status === "ended";
const cardBody = (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${reminder.id}` as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Card className="transition-shadow hover:shadow-md hover:ring-foreground/20">
<CardContent className="flex items-center gap-3 py-3 px-4">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<StatusPill status={reminder.status} />
<span className="text-sm font-medium leading-none truncate">
{reminder.name}
</span>
</div>
<p className="text-xs text-muted-foreground truncate">
{reminder.accountLabel}
{reminder.groupNames && ` · ${reminder.groupNames}`}
</p>
</div>
<div className="shrink-0 text-right space-y-1">
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" />
<span>{formatWhen(reminder.scheduledAt, tz)}</span>
</div>
{reminder.rrule && reminder.scheduledAt ? (
<div className="flex items-center justify-end gap-1 text-xs text-primary/80">
<RepeatIcon className="size-3 shrink-0" />
<span>
{describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
</span>
</div>
) : null}
{reminder.groupCount > 0 && (
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" />
<span>
{reminder.groupCount}{" "}
{reminder.groupCount === 1 ? "group" : "groups"}
</span>
</div>
)}
</div>
</CardContent>
</Card>
</Link>
);
// Right swipe → left shelf → Pause (active) / Restart (paused or
// ended). Left swipe → right shelf → Delete. For lifecycle
// states with no sensible secondary action (e.g. failed) we
// omit the left shelf so the row only swipes one direction.
const leftShelf =
canPause ? (
<ReminderShelfButton
reminderId={reminder.id}
label="Pause"
icon={<PauseIcon className="size-4" />}
action={pauseReminderAction}
bg="bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30"
/>
) : canRestart ? (
<ReminderShelfButton
reminderId={reminder.id}
label="Restart"
icon={<PlayIcon className="size-4" />}
action={restartReminderAction}
bg="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:bg-emerald-500/20 dark:text-emerald-400 dark:hover:bg-emerald-500/30"
/>
) : undefined;
return (
<SwipeableRow
// Key includes both id and status so a status change
// (Pause / Restart / Delete result) remounts the row,
// which resets its swipe offset back to closed. Without
// this, clicking a shelf button leaves the shelf open
// even after the row's content updates.
key={`${reminder.id}-${reminder.status}`}
leftActions={leftShelf}
rightActions={
<ReminderShelfButton
reminderId={reminder.id}
label="Delete"
icon={<Trash2Icon className="size-4" />}
action={deleteReminderAction}
bg="bg-destructive/15 text-destructive hover:bg-destructive/25"
/>
}
>
{cardBody}
</SwipeableRow>
);
})}
</div>
</>
) : (
<EmptyState
icon={BellIcon}
title={
allReminders.length === 0
? "No reminders yet."
: hasAnyFilter
? "No reminders match your filters."
: `No ${status} reminders yet.`
}
description={
allReminders.length === 0
? "Create a reminder to start sending scheduled WhatsApp messages."
: hasAnyFilter
? "Try clearing the filters or widening your search."
: "Reminders in other states aren't shown by this filter."
}
action={
allReminders.length === 0 ? (
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}>
<PlusIcon />
New Reminder
</Link>
</Button>
) : undefined
}
/>
)}
</PageShell>
);
}

View File

@ -1,65 +0,0 @@
import { getSeededOperator } from "@/lib/operator";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/theme-toggle";
import { NotificationsToggle } from "@/components/notifications-toggle";
import { PageShell } from "@/components/page-shell";
export default async function SettingsPage() {
const op = await getSeededOperator();
return (
<PageShell title="Settings" narrow>
<Card>
<CardHeader>
<CardTitle>Operator</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<Row label="Display name" value={op.displayName} />
<Separator />
<Row label="Operator ID" value={String(op.telegramUserId)} mono />
<Separator />
<Row label="Default timezone" value={op.defaultTimezone} mono />
<Separator />
<Row label="Role" value={op.role} mono />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Browser notifications when a reminder fires successfully or a
test message is sent. Uses the in-tab Notification API works
while the app is open. Background push is on the roadmap.
</CardDescription>
</CardHeader>
<CardContent>
<NotificationsToggle />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Theme</div>
<ThemeToggle />
</CardContent>
</Card>
<p className="text-center text-xs text-muted-foreground">
cm WhatsApp Bot · self-hosted
</p>
</PageShell>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-3">
<dt className="text-muted-foreground">{label}</dt>
<dd className={mono ? "font-mono text-xs" : ""}>{value}</dd>
</div>
);
}

View File

@ -1,55 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const renameMock = vi.fn();
vi.mock("@/actions/accounts", () => ({
renameAccountAction: (...args: unknown[]) => renameMock(...args),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
import { EditAccountLabelForm } from "./edit-label-form";
describe("EditAccountLabelForm — SSR layout", () => {
it("pre-fills the input with the existing label", () => {
const html = renderToStaticMarkup(
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
);
expect(html).toMatch(/<input[^>]*value="Personal"/);
});
it("renders a Save button", () => {
const html = renderToStaticMarkup(
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
);
expect(html).toMatch(/Save<\/button>/);
});
it("marks the input as required so empty submits don't reach the server", () => {
const html = renderToStaticMarkup(
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
);
expect(html).toMatch(/<input[^>]*required[^>]*aria-required="true"/);
});
it("caps input length to 60 chars (matches the server schema)", () => {
const html = renderToStaticMarkup(
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
);
expect(html).toMatch(/maxlength="60"/i);
});
});
describe("EditAccountLabelForm — submission delegates to renameAccountAction", () => {
beforeEach(() => renameMock.mockReset());
it("constructs the payload with accountId and trimmed label", async () => {
renameMock.mockResolvedValue({ ok: true });
await renameMock({ accountId: "a-1", label: "Updated name" });
expect(renameMock).toHaveBeenCalledWith({
accountId: "a-1",
label: "Updated name",
});
});
});

View File

@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AlertCircleIcon, Loader2Icon, SaveIcon, TagIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { renameAccountAction } from "@/actions/accounts";
const LABEL_MAX = 60;
interface EditAccountLabelFormProps {
accountId: string;
initialLabel: string;
}
export function EditAccountLabelForm({
accountId,
initialLabel,
}: EditAccountLabelFormProps) {
const router = useRouter();
const [label, setLabel] = useState<string>(initialLabel);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSave() {
const trimmed = label.trim();
if (!trimmed) {
setError("Give the account a name.");
return;
}
if (trimmed.length > LABEL_MAX) {
setError(`Name too long (max ${LABEL_MAX} characters).`);
return;
}
setSubmitting(true);
setError(null);
try {
const r = await renameAccountAction({ accountId, label: trimmed });
if (r.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/accounts/${accountId}` as any);
} else {
setError(r.error);
setSubmitting(false);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Unexpected error");
setSubmitting(false);
}
}
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="account-label" className="flex items-center gap-1.5">
<TagIcon className="size-3.5" />
Name
</Label>
<Input
id="account-label"
type="text"
autoFocus
maxLength={LABEL_MAX}
value={label}
required
aria-required="true"
onChange={(e) => {
setLabel(e.target.value);
setError(null);
}}
placeholder="e.g. Personal, Sales line, Backup phone"
/>
<p className="text-xs text-muted-foreground">
Shown in the accounts list, page headers, and activity log. WhatsApp
doesn&apos;t see this name.
</p>
</div>
{error && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" />
{error}
</div>
)}
<div className="flex justify-end">
<Button
type="button"
onClick={handleSave}
disabled={submitting}
className="gap-2"
>
{submitting ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SaveIcon className="size-4" />
)}
{submitting ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
}

View File

@ -1,51 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
type AccountStatus =
| "connected"
| "pending"
| "connecting"
| "disconnected"
| "logged_out"
| "banned";
const STATUS_LABEL: Record<AccountStatus, string> = {
connected: "Connected",
pending: "Pending",
connecting: "Connecting",
disconnected: "Disconnected",
logged_out: "Logged Out",
banned: "Banned",
};
const STATUS_CLASS: Record<AccountStatus, string> = {
connected:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
pending:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
connecting:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
disconnected:
"bg-amber-200/40 text-amber-600 dark:bg-amber-900/30 dark:text-amber-500 border-transparent",
logged_out:
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
banned:
"bg-red-700/20 text-red-800 dark:bg-red-900/40 dark:text-red-300 border-transparent",
};
interface AccountStatusBadgeProps {
status: string;
className?: string;
}
export function AccountStatusBadge({ status, className }: AccountStatusBadgeProps) {
const key = status as AccountStatus;
const label = STATUS_LABEL[key] ?? status;
const cls = STATUS_CLASS[key] ?? "bg-secondary text-secondary-foreground border-transparent";
return (
<Badge variant="secondary" className={cn(cls, className)}>
{label}
</Badge>
);
}

View File

@ -1,80 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// Server-action references in the swipeable row resolve via Next's
// React Server Components plumbing. Mock the module so SSR rendering
// goes through cleanly in a Node test runner.
vi.mock("@/actions/accounts", () => ({
pairAccountAction: vi.fn(),
unpairAccountAction: vi.fn(),
deleteAccountAction: vi.fn(),
}));
import { AccountSwipeableRow } from "./account-swipeable-row";
describe("AccountSwipeableRow", () => {
it("renders the row body inside a swipeable wrapper", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="unpaired">
<div data-testid="row-body">Personal</div>
</AccountSwipeableRow>,
);
expect(html).toContain('data-testid="swipeable-row"');
expect(html).toContain('data-testid="row-body"');
expect(html).toContain("Personal");
});
it("offers Pair on the left shelf when the account is not connected", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="unpaired">
<div />
</AccountSwipeableRow>,
);
expect(html).toMatch(/aria-label="Pair"/);
expect(html).not.toMatch(/aria-label="Unpair"/);
expect(html).toMatch(/lucide-link/);
});
it("offers Unpair on the left shelf when the account is connected", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="connected">
<div />
</AccountSwipeableRow>,
);
expect(html).toMatch(/aria-label="Unpair"/);
expect(html).not.toMatch(/aria-label="Pair"/);
});
it("packs Groups + Delete buttons into the right shelf", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="connected">
<div />
</AccountSwipeableRow>,
);
expect(html).toMatch(/aria-label="Groups"/);
expect(html).toMatch(/aria-label="Delete"/);
// Groups link points at the per-account groups page.
expect(html).toMatch(/href="\/accounts\/a-1\/groups"/);
});
it("widens the right shelf to fit two buttons (176px)", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="connected">
<div />
</AccountSwipeableRow>,
);
// The component overrides the default 88px shelf width with 176.
expect(html).toMatch(/width\s*:\s*176px/);
});
it("each shelf form carries the accountId in a hidden field", () => {
const html = renderToStaticMarkup(
<AccountSwipeableRow accountId="a-1" status="unpaired">
<div />
</AccountSwipeableRow>,
);
const inputs = html.match(/<input[^>]*name="accountId"[^>]*value="a-1"/g) ?? [];
// Pair (left shelf) + Delete (right shelf) = 2 forms.
expect(inputs.length).toBe(2);
});
});

View File

@ -1,125 +0,0 @@
"use client";
import Link from "next/link";
import {
LinkIcon,
UnlinkIcon,
UsersIcon,
Trash2Icon,
} from "lucide-react";
import { SwipeableRow } from "@/components/swipeable-row";
import {
pairAccountAction,
unpairAccountAction,
deleteAccountAction,
} from "@/actions/accounts";
interface AccountSwipeableRowProps {
accountId: string;
status: string;
children: React.ReactNode;
}
/**
* Mobile swipe affordance for /accounts rows.
*
* Drag right left shelf:
* Pair when status != "connected"
* Unpair when status == "connected"
*
* Drag left right shelf:
* Groups /accounts/[id]/groups
* Delete (destructive)
*
* The right shelf packs two buttons, so we widen it to 2× the default
* single-button shelf width.
*/
export function AccountSwipeableRow({
accountId,
status,
children,
}: AccountSwipeableRowProps) {
const isConnected = status === "connected";
return (
<SwipeableRow
rightShelfWidth={176}
leftActions={
isConnected ? (
<UnpairShelfButton accountId={accountId} />
) : (
<PairShelfButton accountId={accountId} />
)
}
rightActions={
<div className="flex w-full">
<GroupsShelfButton accountId={accountId} />
<DeleteShelfButton accountId={accountId} />
</div>
}
>
{children}
</SwipeableRow>
);
}
function PairShelfButton({ accountId }: { accountId: string }) {
return (
<form action={pairAccountAction} className="flex w-full">
<input type="hidden" name="accountId" value={accountId} />
<button
type="submit"
aria-label="Pair"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:bg-emerald-500/20 dark:text-emerald-400 dark:hover:bg-emerald-500/30 text-xs font-medium"
>
<LinkIcon className="size-4" />
Pair
</button>
</form>
);
}
function UnpairShelfButton({ accountId }: { accountId: string }) {
return (
<form action={unpairAccountAction} className="flex w-full">
<input type="hidden" name="accountId" value={accountId} />
<button
type="submit"
aria-label="Unpair"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30 text-xs font-medium"
>
<UnlinkIcon className="size-4" />
Unpair
</button>
</form>
);
}
function GroupsShelfButton({ accountId }: { accountId: string }) {
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<Link
href={`/accounts/${accountId}/groups` as any}
aria-label="Groups"
className="flex h-full w-1/2 flex-col items-center justify-center gap-1 bg-sky-500/15 text-sky-700 hover:bg-sky-500/25 dark:bg-sky-500/20 dark:text-sky-400 dark:hover:bg-sky-500/30 text-xs font-medium"
>
<UsersIcon className="size-4" />
Groups
</Link>
);
}
function DeleteShelfButton({ accountId }: { accountId: string }) {
return (
<form action={deleteAccountAction} className="flex w-1/2">
<input type="hidden" name="accountId" value={accountId} />
<button
type="submit"
aria-label="Delete"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-destructive/15 text-destructive hover:bg-destructive/25 text-xs font-medium"
>
<Trash2Icon className="size-4" />
Delete
</button>
</form>
);
}

View File

@ -1,147 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
import {
AccountsListView,
type AccountsListAccount,
} from "./accounts-list-view";
// next/link in node tests can't access the Next router context — render a
// plain anchor with the href so we can assert on it.
vi.mock("next/link", () => ({
default: ({ href, children, ...rest }: { href: string; children: ReactNode } & Record<string, unknown>) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
const mkAccount = (over: Partial<AccountsListAccount> = {}): AccountsListAccount => ({
id: "a-1",
label: "Personal",
status: "connected",
phoneNumber: "+60123456789",
lastConnectedAt: new Date("2026-05-01T10:00:00Z"),
...over,
});
function render(html: string) {
// Count occurrences of a substring.
return {
html,
count(needle: string): number {
let n = 0;
let i = 0;
while (true) {
const j = html.indexOf(needle, i);
if (j === -1) return n;
n++;
i = j + needle.length;
}
},
has(re: RegExp): boolean {
return re.test(html);
},
};
}
describe("AccountsListView", () => {
describe("layout — accounts present", () => {
it("renders exactly one card per account (no inline destructive triggers)", () => {
const accounts = [
mkAccount({ id: "a-1", label: "Personal" }),
mkAccount({ id: "a-2", label: "Work" }),
mkAccount({ id: "a-3", label: "Support" }),
];
const { count } = render(
renderToStaticMarkup(<AccountsListView accounts={accounts} />),
);
expect(count('data-testid="account-cell"')).toBe(3);
expect(count('data-testid="account-card"')).toBe(3);
});
it("does NOT render any Delete affordance on the overview", () => {
// Account-level destructive actions live on the detail page only.
const html = renderToStaticMarkup(
<AccountsListView accounts={[mkAccount({ label: "Sales" })]} />,
);
expect(html).not.toContain("Delete account");
expect(html).not.toContain("Remove Sales");
expect(html).not.toMatch(/data-testid="account-delete-card"/);
expect(html).not.toMatch(/aria-label="Delete /);
});
it("the whole card is the link target — no inline buttons", () => {
const html = renderToStaticMarkup(
<AccountsListView accounts={[mkAccount({ id: "abc-123", label: "Personal" })]} />,
);
// Wrapping anchor goes straight to the detail page.
expect(html).toMatch(/<a [^>]*href="\/accounts\/abc-123"/);
// Header CTA (Add Account) is the only <button>-like control —
// and even that is rendered as an <a> by our mocked Link wrapper.
// No inline form/button trigger inside any account-cell.
const cells = html.match(
/<a [^>]*data-testid="account-cell"[^>]*>[\s\S]*?<\/a>/g,
) ?? [];
expect(cells).toHaveLength(1);
expect(cells[0]).not.toContain("<button");
});
it("displays the phone number when paired, italic 'Not paired yet' otherwise", () => {
const paired = renderToStaticMarkup(
<AccountsListView accounts={[mkAccount({ phoneNumber: "+60123456789" })]} />,
);
expect(paired).toContain("+60123456789");
expect(paired).not.toContain("Not paired yet");
const unpaired = renderToStaticMarkup(
<AccountsListView accounts={[mkAccount({ phoneNumber: null })]} />,
);
expect(unpaired).toContain("Not paired yet");
});
it("renders the Add Account header link", () => {
const html = renderToStaticMarkup(
<AccountsListView accounts={[mkAccount()]} />,
);
expect(html).toContain("Add Account");
expect(html).toMatch(/href="\/accounts\/new"/);
});
it("includes accounts in transient states (pending, disconnected, unpaired)", () => {
// Regression: the overview was filtering out `pending` rows so
// freshly-paired or failed-pair accounts disappeared. The list now
// shows every status; the badge tells the operator what's going on.
const html = renderToStaticMarkup(
<AccountsListView
accounts={[
mkAccount({ id: "p", label: "Pending One", status: "pending", phoneNumber: null, lastConnectedAt: null }),
mkAccount({ id: "u", label: "Unpaired One", status: "unpaired", phoneNumber: null, lastConnectedAt: null }),
mkAccount({ id: "d", label: "Disconnected One", status: "disconnected", phoneNumber: "+60111222333" }),
mkAccount({ id: "c", label: "Connected One", status: "connected" }),
]}
/>,
);
// All four cards rendered.
expect((html.match(/data-testid="account-cell"/g) ?? []).length).toBe(4);
expect(html).toContain("Pending One");
expect(html).toContain("Unpaired One");
expect(html).toContain("Disconnected One");
expect(html).toContain("Connected One");
});
});
describe("layout — empty state", () => {
it("shows the empty-state card and hides the grid when no accounts", () => {
const html = renderToStaticMarkup(<AccountsListView accounts={[]} />);
expect(html).toContain('data-testid="accounts-empty"');
expect(html).not.toContain('data-testid="accounts-grid"');
expect(html).not.toContain('data-testid="account-cell"');
expect(html).toContain("No accounts paired yet.");
// The empty card still offers the Add Account CTA
expect(html).toMatch(/href="\/accounts\/new"/);
});
});
});

View File

@ -1,177 +0,0 @@
import Link from "next/link";
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PageShell } from "@/components/page-shell";
import { EmptyState } from "@/components/empty-state";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { AccountStatusBadge } from "@/components/account-status-badge";
import { AccountSwipeableRow } from "@/components/account-swipeable-row";
export interface AccountsListAccount {
id: string;
label: string;
status: string;
phoneNumber: string | null;
lastConnectedAt: Date | null;
}
interface AccountsListViewProps {
accounts: AccountsListAccount[];
}
/**
* Pure presentational view for /accounts. Renders one card per account
* that links to its detail page. Account-level destructive actions
* (Delete, Unpair, Re-pair) live on the detail page so this overview
* stays calm no inline trigger surfaces here.
*/
export function AccountsListView({ accounts }: AccountsListViewProps) {
return (
<PageShell
title="Accounts"
floatingAction={
<Button
asChild
size="sm"
className="rounded-full shadow-md hover:shadow-lg transition-shadow gap-1.5 sm:h-7 size-12 sm:size-auto sm:px-2.5"
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts/new" as any} aria-label="Add account">
<PlusIcon className="size-5 sm:size-3.5" />
<span className="hidden sm:inline">Add Account</span>
</Link>
</Button>
}
>
{accounts.length > 0 ? (
<>
{/* Mobile: swipeable single-column list. Drag-right reveals
Pair / Unpair, drag-left reveals Groups + Delete. */}
<div className="flex flex-col gap-2 sm:hidden">
<p className="text-xs text-muted-foreground">
Swipe right to {accounts.some((a) => a.status === "connected") ? "pair / unpair" : "pair"},
left to manage groups or delete.
</p>
{accounts.map((account) => (
<AccountSwipeableRow
key={account.id}
accountId={account.id}
status={account.status}
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link
href={`/accounts/${account.id}` as any}
data-testid="account-cell-mobile"
data-account-id={account.id}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
>
<Card
size="sm"
className="rounded-none border-0 ring-0 transition-shadow hover:shadow-sm"
>
<CardContent className="flex items-center justify-between gap-3 py-3 px-4">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{account.label}</p>
{account.phoneNumber ? (
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<SmartphoneIcon className="size-3 shrink-0" />
<span>{account.phoneNumber}</span>
</div>
) : (
<p className="mt-0.5 text-xs text-muted-foreground/60 italic">
Not paired yet
</p>
)}
</div>
<AccountStatusBadge status={account.status} />
</CardContent>
</Card>
</Link>
</AccountSwipeableRow>
))}
</div>
{/* Desktop: grid of clickable cards (no swipe click into the
detail page for the same actions). */}
<div
data-testid="accounts-grid"
className="hidden sm:grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{accounts.map((account) => (
<Link
key={account.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}` as any}
data-testid="account-cell"
data-account-id={account.id}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
>
<Card
data-testid="account-card"
className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base leading-snug">{account.label}</CardTitle>
<AccountStatusBadge status={account.status} />
</div>
</CardHeader>
<CardContent className="space-y-2">
{account.phoneNumber ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<SmartphoneIcon className="size-3.5 shrink-0" />
<span>{account.phoneNumber}</span>
</div>
) : (
<p className="text-sm text-muted-foreground/60 italic">Not paired yet</p>
)}
{account.lastConnectedAt ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" />
<span>
Last connected{" "}
{account.lastConnectedAt.toLocaleString("en-MY", {
timeZone: "Asia/Kuala_Lumpur",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
})}
</span>
</div>
) : null}
</CardContent>
</Card>
</Link>
))}
</div>
</>
) : (
<div data-testid="accounts-empty">
<EmptyState
icon={SmartphoneIcon}
title="No accounts paired yet."
description="Pair a WhatsApp account to start scheduling reminders."
action={
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts/new" as any}>
<PlusIcon />
Add Account
</Link>
</Button>
}
/>
</div>
)}
</PageShell>
);
}

View File

@ -1,308 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
// usePathname is the only knob that drives "active state" + the page-title
// derivation in MobileHeader. We swap it per test to verify each NAV_ITEM
// gets selected when its route matches.
const pathnameMock = vi.fn<() => string>(() => "/");
vi.mock("next/navigation", () => ({
usePathname: () => pathnameMock(),
}));
// next-themes pulls window APIs that don't exist under the SSR-only test
// environment; the ThemeToggle component is rendered inside Sidebar so we
// stub it to a deterministic placeholder we can grep for.
vi.mock("@/components/theme-toggle", () => ({
ThemeToggle: () => <div data-testid="theme-toggle">theme-toggle</div>,
}));
// Make the Sheet primitives transparent so the drawer's contents render
// inline and we can grep them. The real components defer rendering until
// the trigger is clicked (Radix portal); for a contract test we just want
// to confirm what's INSIDE the drawer.
vi.mock("@/components/ui/sheet", () => {
const passthrough = ({ children }: { children: ReactNode }) => <>{children}</>;
return {
Sheet: passthrough,
SheetTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
<>{children}</>
),
SheetContent: ({ children }: { children: ReactNode }) => (
<div data-testid="sheet-content">{children}</div>
),
SheetHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
SheetDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
SheetClose: passthrough,
};
});
import { AppShell } from "./app-shell";
import { NAV_ITEMS } from "./nav-config";
// ---------------------------------------------------------------------------
// MobileHeader contract
// ---------------------------------------------------------------------------
describe("AppShell — mobile header (SSR)", () => {
beforeEach(() => {
pathnameMock.mockReset();
pathnameMock.mockReturnValue("/");
});
it("renders a fixed top header that hides on sm+ breakpoints", () => {
const html = renderToStaticMarkup(
<AppShell>
<main>page</main>
</AppShell>,
);
// A `<header>` exists with both `fixed top-0` and `sm:hidden` so it
// covers the mobile viewport edge but yields to the sidebar on desktop.
expect(html).toMatch(/<header[^>]*class="[^"]*fixed top-0[^"]*sm:hidden/);
});
it("brand mark on the left links to /", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// The "cm" brand pill is an <a href="/"> with aria-label "Go home"
// so screen readers announce its purpose.
expect(html).toMatch(/aria-label="Go home"[^>]*href="\/"|href="\/"[^>]*aria-label="Go home"/);
// Brand text is the literal "cm" inside the pill (not the page title).
expect(html).toContain(">cm<");
});
it("page title in the centre reflects the active route", () => {
const cases: Array<{ path: string; expected: string }> = [
{ path: "/", expected: "Dashboard" },
{ path: "/accounts", expected: "Accounts" },
{ path: "/accounts/abc-123", expected: "Accounts" }, // sub-routes still match
{ path: "/reminders", expected: "Reminders" },
{ path: "/reminders/new", expected: "Reminders" },
{ path: "/activity", expected: "Activity" },
{ path: "/settings", expected: "Settings" },
];
for (const c of cases) {
pathnameMock.mockReturnValue(c.path);
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// The mobile header has a span with the title; the desktop sidebar
// doesn't include this title element. Check the title appears at
// least once (mobile header) AND specifically in the expected form.
expect(html).toContain(c.expected);
}
});
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
pathnameMock.mockReturnValue("/unknown-route");
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// Should fall back to the generic title in the centre (and also be
// present in the desktop sidebar header).
expect(html).toContain("WhatsApp Bot");
});
it("menu button on the right uses aria-label='Open menu'", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
expect(html).toMatch(/aria-label="Open menu"/);
});
});
// ---------------------------------------------------------------------------
// Menu drawer (Sheet) contents
// ---------------------------------------------------------------------------
describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", () => {
beforeEach(() => {
pathnameMock.mockReset();
pathnameMock.mockReturnValue("/");
});
it("renders one nav link per NAV_ITEM, in order", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// Find the substring inside the sheet wrapper to scope our assertions
// to the drawer (avoids matching the desktop sidebar).
const sheetSlice = extractSheet(html);
for (const item of NAV_ITEMS) {
expect(sheetSlice).toContain(`href="${item.href}"`);
expect(sheetSlice).toContain(item.label);
}
// Order check: each label appears in the drawer in NAV_ITEMS order.
let cursor = 0;
for (const item of NAV_ITEMS) {
const idx = sheetSlice.indexOf(item.label, cursor);
expect(idx).toBeGreaterThan(-1);
cursor = idx + item.label.length;
}
});
it("marks the active route's link with aria-current='page'", () => {
pathnameMock.mockReturnValue("/reminders");
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
const sheetSlice = extractSheet(html);
// The reminders link should carry aria-current; the others should not.
expect(sheetSlice).toMatch(/href="\/reminders"[^>]*aria-current="page"|aria-current="page"[^>]*href="\/reminders"/);
expect(sheetSlice).not.toMatch(/href="\/accounts"[^>]*aria-current="page"/);
expect(sheetSlice).not.toMatch(/href="\/activity"[^>]*aria-current="page"/);
});
it("Dashboard ('/') matches exactly, not as a prefix of every route", () => {
// Regression guard: NAV_ITEMS contains '/' as the dashboard href. A
// naïve `pathname.startsWith(href)` would mark Dashboard active on
// every page. The header uses an exact-match check for "/".
pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
const sheetSlice = extractSheet(html);
expect(sheetSlice).not.toMatch(/href="\/"[^>]*aria-current="page"/);
expect(sheetSlice).toMatch(/href="\/accounts"[^>]*aria-current="page"|aria-current="page"[^>]*href="\/accounts"/);
});
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
const sheetSlice = extractSheet(html);
expect(sheetSlice).not.toContain("theme-toggle");
});
it("drawer header carries the brand wording and a screen-reader description", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
const sheetSlice = extractSheet(html);
// Visible title carries the brand wording.
expect(sheetSlice).toContain("WhatsApp Bot");
// Description text is present (the actual sr-only class lives on the
// shadcn primitive, which the mock here doesn't reproduce — so we
// just assert the text is rendered, leaving a11y class testing to
// the primitive's own coverage).
expect(sheetSlice).toContain("Primary navigation menu");
});
});
// ---------------------------------------------------------------------------
// Desktop sidebar contract
// ---------------------------------------------------------------------------
describe("AppShell — desktop sidebar (SSR)", () => {
beforeEach(() => {
pathnameMock.mockReset();
pathnameMock.mockReturnValue("/");
});
it("renders the sidebar nav with every NAV_ITEM", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// Desktop sidebar starts with `hidden sm:flex` so it's invisible on mobile.
expect(html).toMatch(/<aside[^>]*class="[^"]*hidden sm:flex/);
for (const item of NAV_ITEMS) {
expect(html).toContain(item.label);
}
});
it("keeps the theme toggle in the sidebar footer", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must
// appear in the sidebar (we removed it from the mobile drawer).
expect(html).toContain('data-testid="theme-toggle"');
});
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// Scope to the sidebar: it's the <aside> element. Pull just the
// <aside>...</aside> slice so this assertion can't accidentally
// match the mobile-header brand link (which has aria-label="Go home").
const sidebarSlice = extractSidebar(html);
expect(sidebarSlice).toMatch(
/<a\b[^>]*href="\/"[^>]*aria-label="Go to dashboard"|<a\b[^>]*aria-label="Go to dashboard"[^>]*href="\/"/,
);
});
it("mobile header brand link uses 'Go home' (separate copy from sidebar)", () => {
// Make sure the two brand-link aria-labels stay distinct so screen-
// reader users on a wide-window split-screen don't hear two
// identical announcements when both are visible.
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
expect(html).toContain('aria-label="Go home"');
expect(html).toContain('aria-label="Go to dashboard"');
});
});
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
/**
* Slice off everything from the SheetContent marker onward. The
* AppShell renders <Sidebar /> first and <MobileHeader /> (which owns
* the Sheet) second, so anything after the marker belongs to the
* mobile drawer + its surrounding JSX (the closing tags). This avoids
* matching the desktop sidebar's nav links, which would otherwise
* trigger false positives.
*
* We can't reliably scope to "just the SheetContent div" without an
* HTML parser the slice includes a few closing tags from outer
* elements, but those don't introduce false matches for our
* assertions (they have no href / aria-current attributes).
*/
function extractSheet(html: string): string {
const open = html.indexOf('data-testid="sheet-content"');
if (open === -1) return "";
return html.slice(open);
}
/**
* Pull just the desktop <aside>...</aside> slice. The shell renders
* the sidebar first, then the mobile header, so the closing
* </aside> tag cleanly separates the two brand markup blocks.
*/
function extractSidebar(html: string): string {
const open = html.indexOf("<aside");
if (open === -1) return "";
const close = html.indexOf("</aside>", open);
return html.slice(open, close === -1 ? html.length : close + "</aside>".length);
}

View File

@ -1,208 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MenuIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { NAV_ITEMS } from "@/components/nav-config";
import { ThemeToggle } from "@/components/theme-toggle";
// ---------------------------------------------------------------------------
// Mobile header (sm:hidden)
//
// Single-row layout:
// ┌──┐ ┌────┐
// │cm│ Page title │menu│
// └──┘ └────┘
//
// The brand mark on the left links home. The page title (derived from
// the current nav route) gives the user a "you are here" cue without
// waiting for the page content to render. The menu button on the right
// opens a Sheet with the full nav list and the theme toggle.
// ---------------------------------------------------------------------------
function MobileHeader() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
// Close the drawer when the route changes (i.e. the user picked a nav
// item). Without this, navigating leaves the sheet open over the new
// page until the user dismisses it manually.
useEffect(() => {
setOpen(false);
}, [pathname]);
const currentItem = NAV_ITEMS.find(({ href }) =>
href === "/" ? pathname === "/" : pathname.startsWith(href),
);
const title = currentItem?.label ?? "WhatsApp Bot";
return (
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center justify-between border-b border-border bg-background/95 backdrop-blur-sm px-3 sm:hidden">
<Link
href="/"
aria-label="Go home"
className="flex size-9 items-center justify-center rounded-lg bg-primary text-xs font-bold uppercase text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
cm
</Link>
<span className="truncate text-sm font-semibold tracking-tight px-2">
{title}
</span>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Open menu"
className="size-9"
>
<MenuIcon className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="flex w-72 flex-col gap-0 p-0">
<SheetHeader className="gap-1 border-b border-border px-4 py-3">
<SheetTitle className="flex items-center gap-2">
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
>
cm
</span>
WhatsApp Bot
</SheetTitle>
<SheetDescription className="sr-only">
Primary navigation menu
</SheetDescription>
</SheetHeader>
<nav
aria-label="Primary navigation"
className="flex flex-col gap-0.5 p-2 flex-1"
>
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={key}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-h-[44px] items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted",
)}
>
<Icon
size={18}
strokeWidth={active ? 2.5 : 1.75}
aria-hidden
/>
{label}
</Link>
);
})}
</nav>
</SheetContent>
</Sheet>
</header>
);
}
// ---------------------------------------------------------------------------
// Sidebar (desktop only — hidden below sm)
// ---------------------------------------------------------------------------
function Sidebar() {
const pathname = usePathname();
return (
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
{/* Bot name / brand — clickable, returns to the dashboard. */}
<Link
href="/"
aria-label="Go to dashboard"
className="flex h-14 items-center gap-2 px-4 border-b border-sidebar-border shrink-0 hover:bg-sidebar-accent/40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
>
cm
</span>
<span className="text-sm font-semibold tracking-tight text-sidebar-foreground">
WhatsApp Bot
</span>
</Link>
{/* Nav items */}
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={key}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors min-h-[44px]",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
)}
>
<Icon size={18} strokeWidth={active ? 2.5 : 1.75} aria-hidden />
{label}
</Link>
);
})}
</nav>
{/* Footer: theme toggle */}
<div className="border-t border-sidebar-border p-3">
<ThemeToggle />
</div>
</aside>
);
}
// ---------------------------------------------------------------------------
// AppShell — the outer container
// ---------------------------------------------------------------------------
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<>
{/* Desktop sidebar */}
<Sidebar />
{/* Mobile header (single row: brand · title · menu) */}
<MobileHeader />
{/* Main content
Mobile: push down for the h-14 header (56px) plus a small gap
so page titles don't kiss the bottom edge of the nav.
Desktop: push right for the sidebar (sm:pl-56), no top offset. */}
<main className="min-h-dvh pt-16 sm:pl-56 sm:pt-0">
{children}
</main>
</>
);
}

View File

@ -1,52 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ActivityIcon } from "lucide-react";
import { EmptyState } from "./empty-state";
describe("EmptyState", () => {
it("renders the icon, title, and description", () => {
const html = renderToStaticMarkup(
<EmptyState
icon={ActivityIcon}
title="No activity yet."
description="Reminder fire events will appear here."
/>,
);
expect(html).toContain("No activity yet.");
expect(html).toContain("Reminder fire events will appear here.");
// The lucide icon component renders an <svg> with the lucide-activity class.
expect(html).toMatch(/<svg[^>]*lucide-activity/);
});
it("omits the description when it isn't passed", () => {
const html = renderToStaticMarkup(
<EmptyState icon={ActivityIcon} title="No archived runs." />,
);
expect(html).toContain("No archived runs.");
// No second <p> element for the helper text — the only <p> is the title.
expect((html.match(/<p\b/g) ?? []).length).toBe(1);
});
it("renders the action slot when provided", () => {
const html = renderToStaticMarkup(
<EmptyState
icon={ActivityIcon}
title="No reminders yet."
action={<button data-testid="cta">Schedule one</button>}
/>,
);
expect(html).toContain('data-testid="cta"');
expect(html).toContain("Schedule one");
});
it("centres the layout (icon → text → action stack)", () => {
const html = renderToStaticMarkup(
<EmptyState icon={ActivityIcon} title="x" action={<span>cta</span>} />,
);
// The CardContent uses flex-col items-center text-center for the
// canonical empty state layout. Lock that in so future tweaks
// can't accidentally drop the centring.
expect(html).toMatch(/flex-col items-center/);
expect(html).toMatch(/text-center/);
});
});

View File

@ -1,39 +0,0 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
interface EmptyStateProps {
/** Visual anchor usually a lucide icon component, rendered at
* size-10 in muted/40 so it reads as decorative rather than active. */
icon: LucideIcon;
/** One-line headline. Required empty states without one read as
* "is the page broken?" */
title: string;
/** Optional explainer below the headline. */
description?: string;
/** Optional CTA button or action link slot. */
action?: ReactNode;
}
/**
* Reusable empty-state card. Tabs render this when their list is
* empty (no accounts paired, no reminders scheduled, no activity
* yet). Centralises the icon / heading / helper / CTA layout so
* every empty surface in the app reads the same.
*/
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<Icon className="size-10 text-muted-foreground/40" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium">{title}</p>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
{action}
</CardContent>
</Card>
);
}

View File

@ -1,83 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { to12Hour, from12Hour, HourSelect } from "./hour-select";
describe("to12Hour", () => {
it("maps 0 → 12 AM (midnight)", () => {
expect(to12Hour(0)).toEqual({ hour12: 12, period: "AM" });
});
it("maps 12 → 12 PM (noon)", () => {
expect(to12Hour(12)).toEqual({ hour12: 12, period: "PM" });
});
it("maps morning hours (1..11) to AM, same digit", () => {
expect(to12Hour(1)).toEqual({ hour12: 1, period: "AM" });
expect(to12Hour(6)).toEqual({ hour12: 6, period: "AM" });
expect(to12Hour(11)).toEqual({ hour12: 11, period: "AM" });
});
it("maps afternoon/evening hours (13..23) to PM, digit minus 12", () => {
expect(to12Hour(13)).toEqual({ hour12: 1, period: "PM" });
expect(to12Hour(18)).toEqual({ hour12: 6, period: "PM" });
expect(to12Hour(23)).toEqual({ hour12: 11, period: "PM" });
});
});
describe("from12Hour", () => {
it("maps 12 AM → 0", () => {
expect(from12Hour(12, "AM")).toBe(0);
});
it("maps 12 PM → 12", () => {
expect(from12Hour(12, "PM")).toBe(12);
});
it("maps 1..11 AM identity", () => {
expect(from12Hour(1, "AM")).toBe(1);
expect(from12Hour(11, "AM")).toBe(11);
});
it("maps 1..11 PM as digit + 12", () => {
expect(from12Hour(1, "PM")).toBe(13);
expect(from12Hour(6, "PM")).toBe(18);
expect(from12Hour(11, "PM")).toBe(23);
});
it("round-trips with to12Hour for every 0..23 value", () => {
for (let h = 0; h <= 23; h++) {
const { hour12, period } = to12Hour(h);
expect(from12Hour(hour12, period)).toBe(h);
}
});
});
describe("HourSelect", () => {
it("renders both selects with twelve hour options and AM/PM", () => {
const html = renderToStaticMarkup(
<HourSelect value={6} onChange={() => {}} ariaPrefix="Delivery start" />,
);
// 12 hour options total
expect((html.match(/<option /g) ?? []).length).toBe(14); // 12 hours + AM + PM
expect(html).toContain('aria-label="Delivery start hour"');
expect(html).toContain('aria-label="Delivery start period"');
expect(html).toContain(">AM</option>");
expect(html).toContain(">PM</option>");
});
it("pre-selects the right hour and period from a 24-hour value", () => {
// 6 → 6 AM
const morning = renderToStaticMarkup(
<HourSelect value={6} onChange={() => {}} ariaPrefix="x" />,
);
expect(morning).toMatch(/value="6"\s+selected/);
expect(morning).toMatch(/value="AM"\s+selected/);
// 18 → 6 PM
const evening = renderToStaticMarkup(
<HourSelect value={18} onChange={() => {}} ariaPrefix="y" />,
);
expect(evening).toMatch(/value="6"\s+selected/);
expect(evening).toMatch(/value="PM"\s+selected/);
});
});

Some files were not shown because too many files have changed in this diff Show More