Compare commits
No commits in common. "670eaf493cdb39e3abc30cb9c4759e14cd3a8785" and "bf3586fe7b271d7453914b4302c182818ff84605" have entirely different histories.
670eaf493c
...
bf3586fe7b
@ -1,4 +1,7 @@
|
||||
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
|
||||
SESSIONS_DIR=/data/sessions
|
||||
MEDIA_DIR=/data/media
|
||||
@ -6,5 +9,5 @@ BOT_HEALTH_PORT=8081
|
||||
BOT_LOG_LEVEL=debug
|
||||
SEED_OPERATOR_TELEGRAM_ID=818380985
|
||||
SEED_OPERATOR_NAME="yiekheng (dev)"
|
||||
WEB_PORT=9000
|
||||
WEB_PORT=3000
|
||||
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -8,12 +8,6 @@ dist/
|
||||
.turbo/
|
||||
*.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
|
||||
# ARE committed to this private Gitea. Only ignore example overrides:
|
||||
.env.local
|
||||
@ -37,4 +31,3 @@ data/
|
||||
# test coverage
|
||||
coverage/
|
||||
.vitest-cache/
|
||||
session
|
||||
|
||||
125
README.md
125
README.md
@ -1,87 +1,41 @@
|
||||
# cm WhatsApp Reminder Bot
|
||||
|
||||
Self-hosted WhatsApp reminder bot. Pair multiple WhatsApp accounts via
|
||||
a browser-based PWA, schedule recurring reminders to groups, and watch
|
||||
the run history all from a phone home-screen icon.
|
||||
Self-hosted WhatsApp reminder bot. Pairs multiple WhatsApp accounts via Telegram-delivered QR codes and sends scheduled reminders to groups.
|
||||
|
||||
## Status
|
||||
|
||||
**Plans 1, 2, and 3 complete.** The web app at `wabot.04080616.xyz` is
|
||||
the primary control surface; the Telegram bot has been removed.
|
||||
**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/`).
|
||||
|
||||
What's working today:
|
||||
|
||||
- **Self-hosted Next.js 16 PWA** — installable on a phone home screen.
|
||||
Mobile-first single-row header with a slide-out drawer; desktop
|
||||
sidebar.
|
||||
- **Live QR pairing** — server-side Baileys session feeds the QR
|
||||
payload directly into the browser via Server-Sent Events. Scan,
|
||||
see "✅ Connected" within seconds, auto-redirect.
|
||||
- **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.
|
||||
- Single-operator Telegram bot with a whitelist + audit log of every command.
|
||||
- BotFather-style menu navigation: `/menu` opens a single message that edits in place as you navigate.
|
||||
- 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.
|
||||
- Browse paired accounts with 📒 Accounts. Tap an account → see groups, send a test text message, or unpair.
|
||||
- Group sync runs at pairing and on every Baileys `groups.upsert` / `groups.update` event, plus a manual 🔄 Refresh button. Removed groups are pruned automatically.
|
||||
- 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).
|
||||
|
||||
## Host requirements
|
||||
|
||||
Only Docker. No host Node, pnpm, or any other language toolchain —
|
||||
everything runs in containers via the long-lived `tools` sidecar.
|
||||
Only Docker. No host Node, pnpm, or any other language toolchain — everything runs in containers via the long-lived `tools` service.
|
||||
|
||||
## Architecture in one paragraph
|
||||
|
||||
Two app containers and one external dependency. `bot` (Node.js) holds
|
||||
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).
|
||||
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).
|
||||
|
||||
Full design spec:
|
||||
[`docs/superpowers/specs/2026-05-09-web-app-design.md`](docs/superpowers/specs/2026-05-09-web-app-design.md)
|
||||
Full design spec: [`docs/superpowers/specs/2026-05-03-whatsapp-bot-design.md`](docs/superpowers/specs/2026-05-03-whatsapp-bot-design.md)
|
||||
|
||||
## Quick start (dev)
|
||||
|
||||
Prerequisites: Docker, the `wabot` database + `waBot` role on
|
||||
`192.168.0.210` (with a `pg_hba.conf` line permitting
|
||||
`192.168.0.0/24`).
|
||||
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`.
|
||||
|
||||
```bash
|
||||
# 1. Configure env
|
||||
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
|
||||
|
||||
# 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 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 seed
|
||||
|
||||
# 4. Open the web app
|
||||
# Local: http://localhost:9000
|
||||
# LAN: http://<host-ip>:9000 (e.g. http://192.168.0.253:9000)
|
||||
# Public: https://wabot.04080616.xyz (whatever your reverse proxy serves)
|
||||
# 4. Watch the bot service
|
||||
NO_SUDO=1 scripts/dev.sh logs bot
|
||||
```
|
||||
|
||||
Pair an account: `/accounts` → "New Account" → enter a label →
|
||||
"Pair WhatsApp" → scan the QR with WhatsApp's "Linked Devices".
|
||||
In Telegram, message your dev bot `/menu`, tap **📡 Pair New**, reply with a label, scan the QR.
|
||||
|
||||
PWA install: phone Chrome → menu → "Install App" / "Add to Home
|
||||
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).
|
||||
`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`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `apps/bot/` — Baileys WhatsApp + pg-boss scheduler + LISTEN/NOTIFY
|
||||
command consumer
|
||||
- `apps/web/` — Next.js 16 App Router PWA
|
||||
- `apps/bot/` — Node service: Baileys WhatsApp + grammy Telegram + (later) pg-boss scheduler
|
||||
- `apps/web/` — Next.js dashboard (plan 3)
|
||||
- `packages/db/` — Drizzle schema and migrations
|
||||
- `packages/shared/` — cross-app helpers (rrule, media paths,
|
||||
timezones, WhatsApp media classifier)
|
||||
- `packages/shared/` — cross-app helpers (rrule, media paths, timezones)
|
||||
- `docs/superpowers/specs/` — design specs and manual test runbooks
|
||||
- `docs/superpowers/plans/` — implementation plans
|
||||
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`,
|
||||
`web.Dockerfile`)
|
||||
- `scripts/` — `dev.sh`, `db.sh`, `gen_auth_secret.sh`
|
||||
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`, `web.Dockerfile` placeholder)
|
||||
- `scripts/` — `dev.sh`, `db.sh`, `gen_auth_secret.sh`, plus stubs for plans 2/4
|
||||
|
||||
## Scripts
|
||||
|
||||
All `pnpm`/`tsx`/`drizzle-kit` invocations run inside the `tools`
|
||||
container, so no host Node is needed.
|
||||
All `pnpm`/`tsx`/`drizzle-kit` invocations run inside the `tools` container, so no host Node is needed.
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `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/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).
|
||||
|
||||
## Deferred
|
||||
## Next plan
|
||||
|
||||
- **Standalone media library** browser (currently media is uploaded
|
||||
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.
|
||||
`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.
|
||||
|
||||
@ -16,20 +16,15 @@
|
||||
"@cmbot/db": "workspace:*",
|
||||
"@cmbot/shared": "workspace:*",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc10",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"luxon": "^3.5.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"pg": "^8.13.0",
|
||||
"pg-boss": "^12.18.2",
|
||||
"grammy": "^1.31.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^11.3.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.7.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.0",
|
||||
|
||||
@ -3,6 +3,9 @@ import { parseEnv } from "./env.js";
|
||||
|
||||
const valid = {
|
||||
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",
|
||||
SESSIONS_DIR: "/data/sessions",
|
||||
MEDIA_DIR: "/data/media",
|
||||
@ -13,6 +16,8 @@ const valid = {
|
||||
describe("parseEnv", () => {
|
||||
it("parses a valid env", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@ -21,37 +26,11 @@ describe("parseEnv", () => {
|
||||
expect(() => parseEnv(rest)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty whitelist", () => {
|
||||
expect(() => parseEnv({ ...valid, TELEGRAM_OPERATOR_WHITELIST: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects malformed port", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,20 +4,18 @@ const numberFromString = z.string().regex(/^\d+$/).transform((s) => Number(s));
|
||||
|
||||
const envSchema = z.object({
|
||||
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),
|
||||
SESSIONS_DIR: z.string().min(1),
|
||||
MEDIA_DIR: z.string().min(1),
|
||||
BOT_HEALTH_PORT: numberFromString,
|
||||
BOT_LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
|
||||
|
||||
// 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>;
|
||||
|
||||
@ -1,34 +1,26 @@
|
||||
import { logger } from "./logger.js";
|
||||
import { pool } from "./db.js";
|
||||
import { startHealthServer, setSessionCountsProvider } from "./health.js";
|
||||
import { createTelegramBot } from "./telegram/bot.js";
|
||||
import { sessionManager } from "./whatsapp/session-manager.js";
|
||||
import { startBoss, stopBoss } from "./scheduler/pgboss-client.js";
|
||||
import { registerReminderJobs } from "./scheduler/reminder-jobs.js";
|
||||
import {
|
||||
startCommandConsumer,
|
||||
registerDefaultHandlers,
|
||||
} from "./ipc/command-consumer.js";
|
||||
import { sweepStalePendingAccounts } from "./ipc/pair-handler.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
logger.info("bot starting");
|
||||
const health = startHealthServer();
|
||||
setSessionCountsProvider(() => sessionManager.getCounts());
|
||||
|
||||
const boss = await startBoss();
|
||||
await registerReminderJobs(boss);
|
||||
const tg = createTelegramBot();
|
||||
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();
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
logger.info({ signal }, "shutting down");
|
||||
await stopConsumer();
|
||||
await tg.stop();
|
||||
await sessionManager.stopAll();
|
||||
await stopBoss();
|
||||
health.close();
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
@ -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)" };
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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)");
|
||||
}
|
||||
143
apps/bot/src/telegram/bot.ts
Normal file
143
apps/bot/src/telegram/bot.ts
Normal 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;
|
||||
}
|
||||
252
apps/bot/src/telegram/callbacks.ts
Normal file
252
apps/bot/src/telegram/callbacks.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
36
apps/bot/src/telegram/commands/accounts.ts
Normal file
36
apps/bot/src/telegram/commands/accounts.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
44
apps/bot/src/telegram/commands/groups.ts
Normal file
44
apps/bot/src/telegram/commands/groups.ts
Normal 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}`);
|
||||
}
|
||||
13
apps/bot/src/telegram/commands/help.ts
Normal file
13
apps/bot/src/telegram/commands/help.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
178
apps/bot/src/telegram/commands/pair.ts
Normal file
178
apps/bot/src/telegram/commands/pair.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/bot/src/telegram/commands/start.ts
Normal file
13
apps/bot/src/telegram/commands/start.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
56
apps/bot/src/telegram/commands/unpair.ts
Normal file
56
apps/bot/src/telegram/commands/unpair.ts
Normal 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.`);
|
||||
}
|
||||
248
apps/bot/src/telegram/menus.ts
Normal file
248
apps/bot/src/telegram/menus.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
apps/bot/src/telegram/middleware/audit.ts
Normal file
21
apps/bot/src/telegram/middleware/audit.ts
Normal 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();
|
||||
};
|
||||
37
apps/bot/src/telegram/middleware/whitelist.test.ts
Normal file
37
apps/bot/src/telegram/middleware/whitelist.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
14
apps/bot/src/telegram/middleware/whitelist.ts
Normal file
14
apps/bot/src/telegram/middleware/whitelist.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
43
apps/bot/src/telegram/state.ts
Normal file
43
apps/bot/src/telegram/state.ts
Normal 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;
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import type { WASocket, AnyMessageContent } from "@whiskeysockets/baileys";
|
||||
import type { WASocket } from "@whiskeysockets/baileys";
|
||||
import pino from "pino";
|
||||
|
||||
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 & {
|
||||
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[][]> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
socket: WASocket,
|
||||
groupJid: string,
|
||||
@ -28,73 +39,44 @@ async function ensureSessionsForGroup(
|
||||
}
|
||||
let ok = 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 {
|
||||
await internal.assertSessions(chunk, true);
|
||||
ok += chunk.length;
|
||||
} catch (err) {
|
||||
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 };
|
||||
}
|
||||
|
||||
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(
|
||||
socket: WASocket,
|
||||
groupJid: string,
|
||||
text: string,
|
||||
): Promise<{ messageId: string | undefined }> {
|
||||
return sendWithRetry(socket, groupJid, { text });
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,13 +40,6 @@ class SessionManager {
|
||||
private states = new Map<string, SessionState>();
|
||||
private listeners = new Set<SessionListener>();
|
||||
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 {
|
||||
this.listeners.add(listener);
|
||||
@ -99,10 +92,7 @@ class SessionManager {
|
||||
this.sessions.set(accountId, session);
|
||||
}
|
||||
|
||||
async stop(
|
||||
accountId: string,
|
||||
opts?: { intentional?: boolean },
|
||||
): Promise<void> {
|
||||
async stop(accountId: string): Promise<void> {
|
||||
const timer = this.reconnectTimers.get(accountId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
@ -110,12 +100,6 @@ class SessionManager {
|
||||
}
|
||||
const session = this.sessions.get(accountId);
|
||||
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();
|
||||
this.sessions.delete(accountId);
|
||||
}
|
||||
@ -151,54 +135,21 @@ class SessionManager {
|
||||
})
|
||||
.where(eq(whatsappAccounts.id, accountId));
|
||||
} 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 });
|
||||
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
|
||||
.update(whatsappAccounts)
|
||||
.set({ status: event.loggedOut ? "logged_out" : "disconnected" })
|
||||
.where(eq(whatsappAccounts.id, accountId));
|
||||
|
||||
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) {
|
||||
if (!event.loggedOut) {
|
||||
const timer = setTimeout(() => {
|
||||
this.reconnectTimers.delete(accountId);
|
||||
void this.stop(accountId).then(() => this.start(accountId));
|
||||
}, 5000);
|
||||
this.reconnectTimers.set(accountId, timer);
|
||||
} else {
|
||||
// Brand-new account that hasn't authenticated yet — let the
|
||||
// pair-handler clean up via its timeout.
|
||||
await this.stop(accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (event.type === "qr") {
|
||||
await db
|
||||
.update(whatsappAccounts)
|
||||
|
||||
@ -16,11 +16,7 @@ import { syncGroupsForAccount } from "./group-sync.js";
|
||||
export type SessionEvent =
|
||||
| { type: "qr"; payload: string }
|
||||
| { type: "open"; phoneNumber: string | undefined }
|
||||
// `restartRequired` is set when Baileys closes the socket with status
|
||||
// 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 };
|
||||
| { type: "close"; reason: number; loggedOut: boolean };
|
||||
|
||||
export type SessionEventHandler = (event: SessionEvent) => void | Promise<void>;
|
||||
|
||||
@ -50,11 +46,6 @@ export async function startSession(params: {
|
||||
auth: state,
|
||||
browser: Browsers.macOS("Safari"),
|
||||
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,
|
||||
});
|
||||
|
||||
@ -88,8 +79,7 @@ export async function startSession(params: {
|
||||
const reason =
|
||||
(update.lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)?.output?.statusCode ?? 0;
|
||||
const loggedOut = reason === DisconnectReason.loggedOut;
|
||||
const restartRequired = reason === DisconnectReason.restartRequired;
|
||||
void onEvent({ type: "close", reason, loggedOut, restartRequired });
|
||||
void onEvent({ type: "close", reason, loggedOut });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -4,6 +4,5 @@
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@ -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": {}
|
||||
}
|
||||
6
apps/web/next-env.d.ts
vendored
6
apps/web/next-env.d.ts
vendored
@ -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.
|
||||
@ -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);
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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 |
@ -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);
|
||||
});
|
||||
@ -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`);
|
||||
}
|
||||
@ -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}…` };
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 “{q}”</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 “Refresh Groups” 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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" },
|
||||
],
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user