Compare commits

..

No commits in common. "master" and "v1.0.0" have entirely different histories.

125 changed files with 573 additions and 15040 deletions

View File

@ -4,7 +4,7 @@ SESSIONS_DIR=/data/sessions
MEDIA_DIR=/data/media MEDIA_DIR=/data/media
BOT_HEALTH_PORT=8081 BOT_HEALTH_PORT=8081
BOT_LOG_LEVEL=debug BOT_LOG_LEVEL=debug
SEED_OPERATOR_USERNAME=admin SEED_OPERATOR_TELEGRAM_ID=818380985
SEED_OPERATOR_NAME="yiekheng (dev)" SEED_OPERATOR_NAME="yiekheng (dev)"
WEB_PORT=9000 WEB_PORT=9000
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c

7
.gitignore vendored
View File

@ -18,13 +18,6 @@ apps/web/public/swe-worker-*.js
# ARE committed to this private Gitea. Only ignore example overrides: # ARE committed to this private Gitea. Only ignore example overrides:
.env.local .env.local
.env.*.local .env.*.local
# Anything inside envs/ EXCEPT the example template — a real env
# file (envs/ENV) leaked once into commit 6893ca6 carrying the DB
# password and AUTH_SECRET. Whitelist .env.example explicitly so a
# future copy-paste of envs/.env.example into envs/ENV (or any other
# name) gets blocked at git add time.
envs/*
!envs/.env.example
# logs # logs
*.log *.log

125
README.md
View File

@ -6,36 +6,24 @@ the run history all from a phone home-screen icon.
## Status ## Status
**v1 production-ready.** The web app at `wabot.04080616.xyz` is the **Plans 1, 2, and 3 complete.** The web app at `wabot.04080616.xyz` is
primary control surface; the Telegram bot has been removed. the primary control surface; the Telegram bot has been removed.
What's working today: What's working today:
- **Username + password auth** with role-based access (admin / user).
HttpOnly + Secure session cookies, encrypted with AES-256-GCM (so a
leaked cookie reveals nothing about userId / role) and bound to the
`OPERATOR_TOKEN_VERSION` env so a single env bump kills every
outstanding session.
- **Three-layer login rate limit** — per-IP + per-username (lower-cased
so case-rotation doesn't help) + a global backstop, so a residential-
proxy attacker can't brute one account by hopping IPs.
- **Self-hosted Next.js 16 PWA** — installable on a phone home screen. - **Self-hosted Next.js 16 PWA** — installable on a phone home screen.
Mobile-first single-row header with a slide-out drawer; desktop Mobile-first single-row header with a slide-out drawer; desktop
sidebar. Login lives outside the shell on a bare-header surface. sidebar.
- **Live QR pairing** — server-side Baileys session feeds the QR - **Live QR pairing** — server-side Baileys session feeds the QR
payload directly into the browser via Server-Sent Events. Scan, payload directly into the browser via Server-Sent Events. Scan,
see "✅ Connected" within seconds, auto-redirect. see "✅ Connected" within seconds, auto-redirect.
- **Duplicate-pair detection** — scanning a QR with a phone already
linked to another account row surfaces a clear "already paired as
<label>" message instead of fighting Baileys for the device.
- **Multi-account, multi-group reminders** — 5-step wizard - **Multi-account, multi-group reminders** — 5-step wizard
(Account → Message → When → Groups → Review) plus per-section edit (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 pages so you don't have to walk the wizard end-to-end to fix one
field. Recurrence picker covers Daily / Weekly / Monthly / Yearly field. Active recurrence picker covers Daily / Weekly / Monthly /
with multi-rule support and per-rule fire-time pickers; the rendered Yearly with multi-rule support and per-rule fire-time pickers; the
description reads as plain English ("Every week on Mon, Wed, Fri at rendered description reads as plain English ("Every week on Mon,
09:00") not raw cron. Optional "Pause sending by" deadline that Wed, Fri at 09:00") not raw cron.
defaults OFF — operators have to opt in explicitly.
- **Multi-message stacks** — a reminder can carry multiple ordered - **Multi-message stacks** — a reminder can carry multiple ordered
parts (text + media), fired in sequence with a 1.5 s gap. Media parts (text + media), fired in sequence with a 1.5 s gap. Media
files swap at any time from the Edit Message page. files swap at any time from the Edit Message page.
@ -45,29 +33,19 @@ What's working today:
as a downloadable file instead of failing silently. as a downloadable file instead of failing silently.
- **Swipe-to-act rows** — on mobile, swipe a reminder or activity - **Swipe-to-act rows** — on mobile, swipe a reminder or activity
row left for Delete or right for Pause/Restart/Archive. iOS-Mail row left for Delete or right for Pause/Restart/Archive. iOS-Mail
style. Click vs drag is disambiguated by a 6-px tap threshold so a style.
swipe doesn't accidentally trigger the row's link.
- **Activity tab** — last 200 runs with status filters (Success / - **Activity tab** — last 200 runs with status filters (Success /
Paused / Failed / Archived). Partial runs surface under both Paused Partial / Failed / Skipped) plus an Archived tab. Archive a noisy
and Failed; Skipped runs collapse into Archived. Hard-delete and run to keep the main list readable; restore later. Hard-delete
archive both available; run history survives a reminder deletion. always available. Run history survives a reminder deletion.
- **Auto-reconnect on transient drops; restart-survival via Baileys - **Auto-reconnect on transient drops; restart-survival via Baileys
session persistence.** Pair once, the device stays linked across session persistence.** Pair once, the device stays linked across
container restarts. Logout-on-delete cleans the operator's container restarts.
linked-devices list on the WhatsApp side too. - **All actions audited.** Reminder run history queryable from the
- **Hardened pg-boss scheduling** — three-tier dedupe so a triple- UI; per-run target results (sent / failed / skipped) preserved
click Save or microsecond-spaced enqueue doesn't fire a reminder even when the underlying group is removed.
multiple times. Reschedule cancels stale jobs by singletonKey first
so a recurring next-fire never gets silently dropped.
- **Drizzle journal monotonicity guard**`pnpm migrate` refuses to
run if the `_journal.json` `when` timestamps aren't strictly
increasing (a recurring foot-gun where drizzle would silently skip
a freshly-generated migration). CI tests + the migrate runner both
enforce.
- **All actions audited.** Per-run target results (sent / failed /
skipped) preserved even when the underlying group is removed.
Test count: **482 web + 88 bot = 570** passing. Test count: **249 web + 31 shared + 26 bot = 306** passing.
## Host requirements ## Host requirements
@ -101,28 +79,24 @@ Prerequisites: Docker, the `wabot` database + `waBot` role on
# 1. Configure env # 1. Configure env
cp envs/.env.example .env.development cp envs/.env.example .env.development
# edit .env.development: real DATABASE_URL, plus the LAN host to expose # edit .env.development: real DATABASE_URL, plus the LAN host to expose
scripts/gen_auth_secret.sh --write # writes AUTH_SECRET to .env.development scripts/gen_auth_secret.sh --write
# 2. Bring up the stack, install deps # 2. Bring up the stack, install deps
NO_SUDO=1 scripts/dev.sh up NO_SUDO=1 scripts/dev.sh up
NO_SUDO=1 scripts/dev.sh pnpm install NO_SUDO=1 scripts/dev.sh pnpm install
# 3. Apply migrations and seed the bootstrap operator row # 3. Apply migrations and seed your operator row
NO_SUDO=1 scripts/db.sh migrate NO_SUDO=1 scripts/db.sh migrate
NO_SUDO=1 scripts/db.sh seed NO_SUDO=1 scripts/db.sh seed
# 4. Set the bootstrap admin password (NO password is set by seed) # 4. Open the web app
echo 'change-me-now' | scripts/set-password.sh admin
# 5. Open the web app and sign in as `admin` with the password above
# Local: http://localhost:9000 # Local: http://localhost:9000
# LAN: http://<host-ip>:9000 # LAN: http://<host-ip>:9000 (e.g. http://192.168.0.253:9000)
# Public: https://wabot.04080616.xyz # Public: https://wabot.04080616.xyz (whatever your reverse proxy serves)
``` ```
Inside the app: `/settings/users` → Add user → invite teammates with Pair an account: `/accounts` → "New Account" → enter a label →
`user` role; promote / demote / reset password / delete from the same "Pair WhatsApp" → scan the QR with WhatsApp's "Linked Devices".
page. The "Admin" nav entry is admin-only.
PWA install: phone Chrome → menu → "Install App" / "Add to Home PWA install: phone Chrome → menu → "Install App" / "Add to Home
Screen". Launches fullscreen. Screen". Launches fullscreen.
@ -130,22 +104,10 @@ Screen". Launches fullscreen.
`NO_SUDO=1` is the right setting if your user is in the `docker` `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`. group (the default for this repo). Drop it if you need `sudo docker`.
## Deploying
- **Local dev**`NO_SUDO=1 scripts/dev.sh up` (described in Quick
start above).
- **Portainer** — push images with `scripts/publish.sh`, then deploy
the [`docker-compose.portainer.yml`](docker-compose.portainer.yml)
stack via the Portainer UI. Full walk-through:
[`docs/deploy-portainer.md`](docs/deploy-portainer.md).
## Manual test runbook ## Manual test runbook
End-to-end checks that unit tests can't cover (live Baileys, End-to-end checks that unit tests can't cover (live Baileys,
WhatsApp delivery, swipe gestures): WhatsApp delivery, swipe gestures):
[`docs/runbook.md`](docs/runbook.md).
The earlier wizard-only checklist still lives at
[`docs/superpowers/specs/manual-test-web.md`](docs/superpowers/specs/manual-test-web.md). [`docs/superpowers/specs/manual-test-web.md`](docs/superpowers/specs/manual-test-web.md).
## Layout ## Layout
@ -156,14 +118,11 @@ The earlier wizard-only checklist still lives at
- `packages/db/` — Drizzle schema and migrations - `packages/db/` — Drizzle schema and migrations
- `packages/shared/` — cross-app helpers (rrule, media paths, - `packages/shared/` — cross-app helpers (rrule, media paths,
timezones, WhatsApp media classifier) timezones, WhatsApp media classifier)
- `docs/runbook.md` — manual end-to-end smoke checklist - `docs/superpowers/specs/` — design specs and manual test runbooks
- `docs/superpowers/specs/` — design specs and earlier manual test
runbooks
- `docs/superpowers/plans/` — implementation plans - `docs/superpowers/plans/` — implementation plans
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`, - `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`,
`web.Dockerfile`) `web.Dockerfile`)
- `scripts/``dev.sh`, `db.sh`, `gen_auth_secret.sh`, - `scripts/``dev.sh`, `db.sh`, `gen_auth_secret.sh`
`set-password.sh`, `create-user.sh`
## Scripts ## Scripts
@ -175,39 +134,17 @@ container, so no host Node is needed.
| `scripts/dev.sh up\|down\|logs\|status\|build\|exec\|pnpm\|shell\|restart-bot` | Stack lifecycle and tools-container shell | | `scripts/dev.sh up\|down\|logs\|status\|build\|exec\|pnpm\|shell\|restart-bot` | Stack lifecycle and tools-container shell |
| `scripts/db.sh migrate\|generate\|studio\|seed\|reset` | Drizzle migration helper | | `scripts/db.sh migrate\|generate\|studio\|seed\|reset` | Drizzle migration helper |
| `scripts/gen_auth_secret.sh [--write]` | Generate `AUTH_SECRET` (host-only, no Node needed) | | `scripts/gen_auth_secret.sh [--write]` | Generate `AUTH_SECRET` (host-only, no Node needed) |
| `scripts/set-password.sh <username>` | Set / reset a user's password (reads stdin) |
| `scripts/create-user.sh <username> <role>` | Create a user from CLI (admin / user) |
Set `NO_SUDO=1` if your user is in the docker group (recommended). Set `NO_SUDO=1` if your user is in the docker group (recommended).
## Auth + admin model
- One bootstrap operator (`admin`) is created by the seed; its
password is set via `scripts/set-password.sh admin` on first launch.
- Two roles: `admin` (full access including user management) and
`user` (everything except `/settings/users`). Role-based nav
filtering is enforced in middleware + the AppShell + every server
action that mutates user state.
- Every user gets an isolated workspace — accounts, reminders,
groups, and run history all scope by `operator_id`. The admin
panel is the only cross-tenant surface.
- Sessions: AES-256-GCM-encrypted cookie keyed off `AUTH_SECRET`,
HttpOnly + Secure-in-prod + SameSite=Lax, 30-day TTL. The
`OPERATOR_TOKEN_VERSION` env (defaults to `"1"`) is the kill switch
— bumping it invalidates every outstanding cookie globally on the
next request.
- Login rate limits: 10 / 5 min per-IP + 5 / 15 min per-username + a
100 / min global backstop. The error message is identical for all
three so the limit-which-tripped isn't leaked.
## Deferred ## Deferred
- **Standalone media library** browser (currently media is uploaded - **Standalone media library** browser (currently media is uploaded
per-reminder). per-reminder).
- **E2E browser tests** (Playwright) on the swipe and pairing flows. - **E2E browser tests** (Playwright) on the swipe and pairing flows.
- **Search-as-you-type in the wizard's groups picker** — at 3 000+ - **Auth** (passkeys / email-password) — bring back if URL exposure
groups per account the picker still loads the alphabetical becomes a concern. Today the app trusts whatever's in front of the
top-200; operators with >200 groups need to use the list page's reverse proxy.
search to find anything past 'L'. - **Multi-operator** — schema supports `operator_id` on every row,
- **Self-service password reset** (email link, etc.) — out of scope but the seed runs as a single operator and there's no /signup or
for v1; admins use the Users page. invite flow yet.

View File

@ -9,7 +9,6 @@ import {
registerDefaultHandlers, registerDefaultHandlers,
} from "./ipc/command-consumer.js"; } from "./ipc/command-consumer.js";
import { sweepStalePendingAccounts } from "./ipc/pair-handler.js"; import { sweepStalePendingAccounts } from "./ipc/pair-handler.js";
import { sweepStalePendingRuns } from "./scheduler/sweep-stale-runs.js";
async function main(): Promise<void> { async function main(): Promise<void> {
logger.info("bot starting"); logger.info("bot starting");
@ -23,7 +22,6 @@ async function main(): Promise<void> {
const stopConsumer = await startCommandConsumer(); const stopConsumer = await startCommandConsumer();
await sweepStalePendingAccounts(); await sweepStalePendingAccounts();
await sweepStalePendingRuns();
await sessionManager.resumeFromDb(); await sessionManager.resumeFromDb();
const shutdown = async (signal: string): Promise<void> => { const shutdown = async (signal: string): Promise<void> => {

View File

@ -3,26 +3,17 @@ import type { Notification } from "pg";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { handleStartPairing } from "./pair-handler.js"; import { handleStartPairing } from "./pair-handler.js";
import { handleUnpair, handleDelete } from "./unpair-handler.js"; import { handleUnpair } from "./unpair-handler.js";
import { handleSyncGroups } from "./sync-groups-handler.js"; import { handleSyncGroups } from "./sync-groups-handler.js";
import { handleSendTest } from "./send-test-handler.js"; import { handleSendTest } from "./send-test-handler.js";
import { import { handleScheduleReminder } from "./schedule-reminder-handler.js";
handleScheduleReminder,
handleResumeReminder,
} from "./schedule-reminder-handler.js";
export type BotCommand = export type BotCommand =
| { type: "account.start_pairing"; accountId: string } | { type: "account.start_pairing"; accountId: string }
| { type: "account.unpair"; accountId: string } | { type: "account.unpair"; accountId: string }
// Like unpair, but tells WhatsApp to drop this device from the
// user's linked-devices list first via socket.logout(). The web
// action calls this immediately before deleting the row so the
// operator's phone doesn't keep showing a phantom linked device.
| { type: "account.delete"; accountId: string }
| { type: "account.sync_groups"; accountId: string } | { type: "account.sync_groups"; accountId: string }
| { type: "group.send_test"; groupId: string; text: string } | { type: "group.send_test"; groupId: string; text: string }
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string } | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string };
| { type: "reminder.resume"; reminderId: string; runId: string };
type Handler = (cmd: BotCommand) => Promise<void>; type Handler = (cmd: BotCommand) => Promise<void>;
const handlers: { [K in BotCommand["type"]]?: (cmd: Extract<BotCommand, { type: K }>) => Promise<void> } = {}; const handlers: { [K in BotCommand["type"]]?: (cmd: Extract<BotCommand, { type: K }>) => Promise<void> } = {};
@ -79,9 +70,6 @@ export function registerDefaultHandlers(): void {
registerHandler("account.unpair", async (cmd) => { registerHandler("account.unpair", async (cmd) => {
await handleUnpair(cmd.accountId); await handleUnpair(cmd.accountId);
}); });
registerHandler("account.delete", async (cmd) => {
await handleDelete(cmd.accountId);
});
registerHandler("account.sync_groups", async (cmd) => { registerHandler("account.sync_groups", async (cmd) => {
await handleSyncGroups(cmd.accountId); await handleSyncGroups(cmd.accountId);
}); });
@ -91,7 +79,4 @@ export function registerDefaultHandlers(): void {
registerHandler("reminder.schedule", async (cmd) => { registerHandler("reminder.schedule", async (cmd) => {
await handleScheduleReminder(cmd.reminderId, cmd.scheduledAtIso); await handleScheduleReminder(cmd.reminderId, cmd.scheduledAtIso);
}); });
registerHandler("reminder.resume", async (cmd) => {
await handleResumeReminder(cmd.reminderId, cmd.runId);
});
} }

View File

@ -10,28 +10,8 @@ export type WebEvent =
| { type: "session.connected"; accountId: string; phoneNumber: string | null } | { type: "session.connected"; accountId: string; phoneNumber: string | null }
| { type: "session.disconnected"; accountId: string } | { type: "session.disconnected"; accountId: string }
| { type: "session.timeout"; accountId: string } | { type: "session.timeout"; accountId: string }
// Operator scanned the QR with a phone that's already linked to another
// account row. We park the new pairing instead of letting two account
// rows fight over the same WhatsApp device. existingLabel surfaces in
// the UI so the operator knows which account already owns the phone.
| {
type: "session.duplicate";
accountId: string;
phoneNumber: string;
existingLabel: string;
}
| { type: "groups.synced"; accountId: string; count: number } | { type: "groups.synced"; accountId: string; count: number }
| { | { type: "reminder.fired"; reminderId: string; runId: string; status: string }
type: "reminder.fired";
reminderId: string;
runId: string;
status: string;
// Optional delivered/total counts so the web side can render
// "X of Y groups delivered" in the paused-status notification
// body. Omitted on terminal-status events that don't need them.
sent?: number;
total?: number;
}
| { type: "reminder.failed"; reminderId: string; error: string } | { type: "reminder.failed"; reminderId: string; error: string }
// The web action enqueues a send_test via pg_notify and shows // The web action enqueues a send_test via pg_notify and shows
// "Sending…" optimistically. This event closes the loop. // "Sending…" optimistically. This event closes the loop.

View File

@ -10,23 +10,11 @@ import { renderQrPng } from "../whatsapp/qr-renderer.js";
import { syncGroupsForAccount } from "../whatsapp/group-sync.js"; import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
import { writeAuditLog } from "../audit.js"; import { writeAuditLog } from "../audit.js";
import { pgNotifyWeb } from "./notify.js"; import { pgNotifyWeb } from "./notify.js";
import {
decidePairListenerOnClose,
findDuplicateExistingAccount,
nextWarmingUpAfterEvent,
} from "./pair-state.js";
const PAIR_TIMEOUT_MS = 5 * 60 * 1000; const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
const offByAccount = new Map<string, () => void>(); const offByAccount = new Map<string, () => void>();
const lastQrPayload = new Map<string, string>(); const lastQrPayload = new Map<string, string>();
const pairTimeouts = new Map<string, NodeJS.Timeout>(); const pairTimeouts = new Map<string, NodeJS.Timeout>();
// "Warming" set: while present, the just-attached listener will ignore
// close events. Cleared the moment a qr/open arrives. This prevents the
// old session's close (broadcast asynchronously by sessionManager after
// our await sessionManager.stop() returns) from being mis-read as the
// NEW session timing out — which manifested as: get QR → go back →
// click Pair again → instantly see "Pairing timed out".
const pairingWarmingUp = new Set<string>();
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> { async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
const account = await db.query.whatsappAccounts.findFirst({ const account = await db.query.whatsappAccounts.findFirst({
@ -46,7 +34,6 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
pairTimeouts.delete(accountId); pairTimeouts.delete(accountId);
} }
lastQrPayload.delete(accountId); lastQrPayload.delete(accountId);
pairingWarmingUp.delete(accountId);
if (sessionManager.hasSession(accountId)) { if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId); await sessionManager.stop(accountId);
} }
@ -93,17 +80,10 @@ export async function handleStartPairing(accountId: string): Promise<void> {
.set({ lastQrPng: null }) .set({ lastQrPng: null })
.where(eq(whatsappAccounts.id, accountId)); .where(eq(whatsappAccounts.id, accountId));
// Mark the new attempt as warming up. Cleared by the first qr/open we
// observe; while set, any close event is treated as the leaked tail of
// the previous session being torn down (see comment near
// `pairingWarmingUp` declaration).
pairingWarmingUp.add(accountId);
const off = sessionManager.on(async (id, _state, event) => { const off = sessionManager.on(async (id, _state, event) => {
if (id !== accountId) return; if (id !== accountId) return;
try { try {
if (event.type === "qr") { if (event.type === "qr") {
pairingWarmingUp.delete(id);
// Dedupe by payload — Baileys can re-emit the same QR string in a // Dedupe by payload — Baileys can re-emit the same QR string in a
// burst. Different strings (a fresh QR) always pass through, so // burst. Different strings (a fresh QR) always pass through, so
// the user gets a new QR as soon as Baileys generates one. // the user gets a new QR as soon as Baileys generates one.
@ -122,7 +102,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
ts: Date.now(), ts: Date.now(),
}); });
} else if (event.type === "open") { } else if (event.type === "open") {
pairingWarmingUp.delete(id);
const t = pairTimeouts.get(id); const t = pairTimeouts.get(id);
if (t) { if (t) {
clearTimeout(t); clearTimeout(t);
@ -130,53 +109,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
} }
lastQrPayload.delete(id); lastQrPayload.delete(id);
offByAccount.delete(id); offByAccount.delete(id);
// Duplicate-pair guard. Operator scanned the QR with a phone
// that's already linked to another account row. Letting both
// rows claim the same WhatsApp device confuses Baileys and
// turns sends into a coin flip — abandon this pairing and
// surface a clear message to the UI.
const siblings = await db.query.whatsappAccounts.findMany({
where: (a, { eq: dEq }) => dEq(a.operatorId, account.operatorId),
columns: { id: true, phoneNumber: true, label: true },
});
const dup = findDuplicateExistingAccount({
currentAccountId: id,
currentPhoneNumber: event.phoneNumber,
siblings,
});
if (dup) {
logger.warn(
{
accountId: id,
phoneNumber: event.phoneNumber,
existingAccountId: dup.existingAccountId,
existingLabel: dup.existingLabel,
},
"pair: duplicate phone — abandoning new pairing",
);
// Stop the duplicate session, scrub the partial auth blob,
// and reset the row's status. We DO NOT logout() here — the
// original account's session remains valid and the operator
// hasn't actually added a new linked device on the phone yet
// (it'd just be the freshly-completed scan, which Baileys
// hasn't yet committed to the WhatsApp side).
await sessionManager.stop(id, { intentional: true });
await rm(join(env.SESSIONS_DIR, id), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null, phoneNumber: null })
.where(eq(whatsappAccounts.id, id));
await pgNotifyWeb({
type: "session.duplicate",
accountId: id,
phoneNumber: event.phoneNumber!,
existingLabel: dup.existingLabel,
});
off();
return;
}
const session = sessionManager.getSession(id); const session = sessionManager.getSession(id);
let synced = 0; let synced = 0;
if (session) { if (session) {
@ -202,42 +134,27 @@ export async function handleStartPairing(accountId: string): Promise<void> {
count: synced, count: synced,
}); });
off(); 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") { } else if (event.type === "close") {
const decision = decidePairListenerOnClose({ // During the pairing window, any other close means the QR window
warmingUp: pairingWarmingUp.has(id), // ended without a successful link — Baileys' default is to
restartRequired: event.restartRequired, // close after exhausting QR refs (~2.5 min). Surface this to
}); // the UI so the user gets a "pairing timed out" screen, and
if (decision === "ignore-leaked-close") { // park the row in a stable state so it shows up cleanly on
logger.info( // the accounts list with a "Re-pair" affordance.
{ accountId: id },
"pair: ignoring close from previous attempt while warming up",
);
return;
}
if (decision === "post-pair-restart") {
// 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 finishes the pairing. Keep the
// listener attached and don't surface a timeout to the UI.
//
// Re-arm the warming-up flag: the session-manager schedules a
// cleanup `stop().then(start())` to kick off the reconnect.
// That stop emits another close event that lands on this
// listener BEFORE the new open arrives — without warming-up,
// we'd treat it as a timeout and detach right when the user
// actually paired successfully. Cleared again on the next
// qr / open from the freshly-reopened session.
pairingWarmingUp.add(id);
logger.info(
{ accountId: id },
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
);
return;
}
// decision === "treat-as-timeout": ephemeral close on a live
// attempt. Park the row as `unpaired` and push session.timeout
// so the operator sees the "Re-pair" affordance.
const t = pairTimeouts.get(id); const t = pairTimeouts.get(id);
if (t) { if (t) {
clearTimeout(t); clearTimeout(t);

View File

@ -2,9 +2,6 @@ import { describe, it, expect } from "vitest";
import { import {
decideOnPairClose, decideOnPairClose,
decideOnPairTimeout, decideOnPairTimeout,
decidePairListenerOnClose,
findDuplicateExistingAccount,
nextWarmingUpAfterEvent,
shouldAutoReconnect, shouldAutoReconnect,
} from "./pair-state.js"; } from "./pair-state.js";
@ -85,225 +82,3 @@ describe("shouldAutoReconnect", () => {
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false); expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
}); });
}); });
describe("decidePairListenerOnClose (back→re-pair flicker regression)", () => {
it("ignores a close while warming up — even if also restartRequired", () => {
// The exact bug: stop() was awaited, listener attached, then the OLD
// session's close arrives and races our new listener. Warming-up
// wins over every other branch so the UI never sees a spurious
// session.timeout before the new QR is rendered.
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: false }),
).toBe("ignore-leaked-close");
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
).toBe("ignore-leaked-close");
});
it("treats a close on a live attempt (warmingUp=false) as a real timeout", () => {
// Refs exhausted, network blip, etc. — operator gets the
// "Pairing timed out" screen and a Re-pair affordance.
expect(
decidePairListenerOnClose({ warmingUp: false, restartRequired: false }),
).toBe("treat-as-timeout");
expect(decidePairListenerOnClose({ warmingUp: false })).toBe("treat-as-timeout");
});
it("preserves the restart-required (post-pair-success) branch when not warming up", () => {
// Status 515 close: the session-manager will reconnect and the next
// `open` finishes the pair. We must NOT push session.timeout here.
expect(
decidePairListenerOnClose({ warmingUp: false, restartRequired: true }),
).toBe("post-pair-restart");
});
it("warming-up overrides restartRequired so 515 from a stale session is also swallowed", () => {
// Defense-in-depth: if Baileys' restart-required close from the OLD
// session somehow leaks through, treating it as a real 515 would
// KEEP the listener attached forever (no reconnect comes from a
// session we just stopped). Ignore it entirely until a fresh qr/open.
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
).toBe("ignore-leaked-close");
});
});
describe("nextWarmingUpAfterEvent (pair-listener flag transitions)", () => {
it("first qr from the live session clears warming-up", () => {
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "qr" })).toBe(false);
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "qr" })).toBe(false);
});
it("first open from the live session clears warming-up", () => {
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "open" })).toBe(false);
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "open" })).toBe(false);
});
it("RE-ARMS warming-up on a restart-required close (post-pair-success)", () => {
// The regression: after the user scans, Baileys closes with status
// 515 and the session-manager schedules a stop().then(start())
// reconnect. That cleanup-stop emits a SECOND close that arrives
// before the new socket reopens. If warming-up isn't re-armed
// between the two closes, the second one resolves to
// 'treat-as-timeout' and detaches the listener right at the
// moment the user actually paired successfully — UI never gets
// session.connected.
expect(
nextWarmingUpAfterEvent({ warmingUp: false, event: "close", restartRequired: true }),
).toBe(true);
expect(
nextWarmingUpAfterEvent({ warmingUp: true, event: "close", restartRequired: true }),
).toBe(true);
});
it("plain close leaves warming-up unchanged", () => {
// The pair-handler decides what to DO with a non-restart close
// separately (decidePairListenerOnClose). The warming-up flag
// doesn't change as a side effect — the listener either detaches
// (treat-as-timeout) or already returned (ignore-leaked-close).
expect(
nextWarmingUpAfterEvent({ warmingUp: false, event: "close" }),
).toBe(false);
expect(
nextWarmingUpAfterEvent({ warmingUp: true, event: "close" }),
).toBe(true);
});
it("end-to-end successful-pair sequence: warming → qr → restart-required close → open", () => {
// Full lifecycle the helper has to thread correctly so the user
// sees 'Account connected!' instead of 'Pairing timed out'.
let warming = true; // freshly attached listener after a re-pair
// First QR arrives — clears the leak-protection flag.
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "qr" });
expect(warming).toBe(false);
// User scans → Baileys closes with restartRequired=true.
// Re-arms because session-manager will run another stop+start.
warming = nextWarmingUpAfterEvent({
warmingUp: warming,
event: "close",
restartRequired: true,
});
expect(warming).toBe(true);
// The cleanup-stop's second close arrives. The CALLER decides via
// decidePairListenerOnClose to ignore it (warmingUp=true wins).
expect(
decidePairListenerOnClose({ warmingUp: warming, restartRequired: false }),
).toBe("ignore-leaked-close");
// Flag stays armed because a plain close doesn't change it.
warming = nextWarmingUpAfterEvent({
warmingUp: warming,
event: "close",
restartRequired: false,
});
expect(warming).toBe(true);
// Fresh socket opens with the new credentials → success.
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "open" });
expect(warming).toBe(false);
});
});
describe("findDuplicateExistingAccount (one-phone-per-account guard)", () => {
const sibling = (id: string, phone: string | null, label: string) => ({
id,
phoneNumber: phone,
label,
});
it("flags a sibling that already holds this phone number", () => {
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "60123456789",
siblings: [
sibling("new", null, "scratch"),
sibling("existing", "60123456789", "Yiekheng-my"),
sibling("other", "60987654321", "WaBot Test"),
],
});
expect(r).toEqual({
existingAccountId: "existing",
existingLabel: "Yiekheng-my",
});
});
it("returns null when the phone is unique", () => {
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "60123456789",
siblings: [
sibling("new", null, "scratch"),
sibling("other", "60987654321", "WaBot"),
],
});
expect(r).toBeNull();
});
it("excludes the current account from comparison (a row's own phone isn't a 'duplicate')", () => {
// After session-manager.handleEvent runs first it has already
// written phone_number on the current row. The check must skip
// that row, otherwise EVERY successful pair would match itself
// and look like a duplicate.
const r = findDuplicateExistingAccount({
currentAccountId: "self",
currentPhoneNumber: "60123456789",
siblings: [sibling("self", "60123456789", "Self")],
});
expect(r).toBeNull();
});
it("returns null for null/empty/whitespace phone numbers (don't false-positive on unset)", () => {
const siblings = [
sibling("new", null, "scratch"),
sibling("a", null, "Old A"),
sibling("b", "", "Old B"),
sibling("c", " ", "Old C"),
];
expect(
findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: null,
siblings,
}),
).toBeNull();
expect(
findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "",
siblings,
}),
).toBeNull();
expect(
findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: " ",
siblings,
}),
).toBeNull();
});
it("normalises whitespace on both sides before comparing", () => {
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: " 60123456789 ",
siblings: [sibling("existing", "60123456789", "Existing")],
});
expect(r?.existingAccountId).toBe("existing");
});
it("picks the FIRST matching sibling when (somehow) two rows share the phone", () => {
// Defensive: this state shouldn't exist in production but the helper
// should at least be deterministic so the message is consistent.
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "60123456789",
siblings: [
sibling("first", "60123456789", "First"),
sibling("second", "60123456789", "Second"),
],
});
expect(r?.existingAccountId).toBe("first");
});
});

View File

@ -80,106 +80,3 @@ export function decideOnPairTimeout({ current }: { current: AccountStatus }): St
if (current !== "pending") return null; if (current !== "pending") return null;
return { next: "unpaired", clearQrPng: true }; return { next: "unpaired", clearQrPng: true };
} }
/**
* Decide how the pair-handler should react to a `close` event delivered
* to its listener. Three outcomes:
*
* - "ignore-leaked-close": the new attempt is still warming up and
* we're seeing the OLD session's tail close. Do nothing don't
* emit timeout to the UI, don't touch the DB row.
* - "post-pair-restart": status-515 close from a successful scan.
* The session-manager will reconnect; we keep the listener alive
* and wait for the subsequent `open` event.
* - "treat-as-timeout": a real ephemeral close on a live attempt
* (refs exhausted, etc.). Park the row as `unpaired` and push
* `session.timeout` to the UI.
*
* Captures the regression where, after the user pulled up a QR and
* navigated back, clicking Pair again would instantly flash "Pairing
* timed out" because the await on stop() returned before
* sessionManager.handleEvent finished broadcasting the old session's
* close and the new listener was already attached.
*/
export type PairListenerCloseDecision =
| "ignore-leaked-close"
| "post-pair-restart"
| "treat-as-timeout";
export function decidePairListenerOnClose(input: {
warmingUp: boolean;
restartRequired?: boolean;
}): PairListenerCloseDecision {
if (input.warmingUp) return "ignore-leaked-close";
if (input.restartRequired) return "post-pair-restart";
return "treat-as-timeout";
}
/**
* Step the pair-listener's warming-up flag forward through one Baileys
* event. Captures three rules in one place so they're test-locked:
*
* - First `qr` / `open` from the live session clears warming-up
* (we've seen real session activity, future closes are real).
* - `close + restartRequired` (post-pair-success / status 515)
* RE-ARMS warming-up. The session-manager will schedule a
* `stop().then(start())` reconnect; that stop emits a second close
* before the new socket reopens. Without re-arming, the leaked
* close from the cleanup-stop reaches us with warming-up=false and
* resolves to `treat-as-timeout` detaching the listener right at
* the moment the user actually paired successfully (regression).
* - Any other `close` keeps warming-up unchanged (the listener
* either ignored it because we're warming, or processed it as a
* real timeout / restart and is leaving the loop anyway).
*/
export function nextWarmingUpAfterEvent(input: {
warmingUp: boolean;
event: "qr" | "open" | "close";
restartRequired?: boolean;
}): boolean {
if (input.event === "qr" || input.event === "open") return false;
if (input.event === "close" && input.restartRequired) return true;
return input.warmingUp;
}
/**
* Decide whether a freshly-paired account is a duplicate of an
* existing account row owned by the same operator. The operator
* cannot legitimately link the same WhatsApp number to two account
* rows Baileys keeps one auth blob per phone and the second row
* would just hijack the first's session.
*
* Inputs:
* - `currentAccountId` the row that just received the open event
* - `currentPhoneNumber` the JID-derived phone string (or null)
* - `siblings` every other operator-owned account row
*
* Returns `null` if the phone is unique (proceed normally), or a
* descriptor with the existing-row's id+label so the caller can park
* the duplicate row and surface a clear "already linked" message to
* the UI. A null/empty phone never reports a duplicate (we'd be
* comparing apples and we'd block legitimate first pairs that
* haven't received the WID yet).
*/
export interface DuplicatePairInput {
currentAccountId: string;
currentPhoneNumber: string | null | undefined;
siblings: Array<{ id: string; phoneNumber: string | null; label: string }>;
}
export interface DuplicatePairFinding {
existingAccountId: string;
existingLabel: string;
}
export function findDuplicateExistingAccount(
input: DuplicatePairInput,
): DuplicatePairFinding | null {
const phone = (input.currentPhoneNumber ?? "").trim();
if (!phone) return null;
for (const s of input.siblings) {
if (s.id === input.currentAccountId) continue;
if ((s.phoneNumber ?? "").trim() === phone) {
return { existingAccountId: s.id, existingLabel: s.label };
}
}
return null;
}

View File

@ -1,16 +1,6 @@
import { getBoss } from "../scheduler/pgboss-client.js"; import { getBoss } from "../scheduler/pgboss-client.js";
import { import { scheduleReminderFire } from "../scheduler/reminder-jobs.js";
scheduleReminderFire,
enqueueReminderResume,
} from "../scheduler/reminder-jobs.js";
export async function handleScheduleReminder(reminderId: string, scheduledAtIso: string): Promise<void> { export async function handleScheduleReminder(reminderId: string, scheduledAtIso: string): Promise<void> {
await scheduleReminderFire(getBoss(), reminderId, new Date(scheduledAtIso)); await scheduleReminderFire(getBoss(), reminderId, new Date(scheduledAtIso));
} }
export async function handleResumeReminder(
reminderId: string,
runId: string,
): Promise<void> {
await enqueueReminderResume(getBoss(), reminderId, runId);
}

View File

@ -1,128 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Hoisted spies so the vi.mock factories can reach them.
const {
stopMock,
logoutAndStopMock,
rmMock,
findFirstMock,
writeAuditLogMock,
pgNotifyWebMock,
} = vi.hoisted(() => ({
stopMock: vi.fn(async () => undefined),
logoutAndStopMock: vi.fn(async () => undefined),
rmMock: vi.fn(async () => undefined),
findFirstMock: vi.fn(async () => ({ operatorId: "op-1", label: "WaBot" })),
writeAuditLogMock: vi.fn(async () => undefined),
pgNotifyWebMock: vi.fn(async () => undefined),
}));
vi.mock("node:fs/promises", () => ({
rm: (...args: unknown[]) => rmMock(...args),
}));
vi.mock("../db.js", () => ({
db: {
query: { whatsappAccounts: { findFirst: (...a: unknown[]) => findFirstMock(...a) } },
},
}));
vi.mock("../env.js", () => ({ env: { SESSIONS_DIR: "/data/sessions" } }));
vi.mock("../whatsapp/session-manager.js", () => ({
sessionManager: {
stop: (...a: unknown[]) => stopMock(...a),
logoutAndStop: (...a: unknown[]) => logoutAndStopMock(...a),
},
}));
vi.mock("../audit.js", () => ({
writeAuditLog: (...a: unknown[]) => writeAuditLogMock(...a),
}));
vi.mock("./notify.js", () => ({
pgNotifyWeb: (...a: unknown[]) => pgNotifyWebMock(...a),
}));
vi.mock("../logger.js", () => ({
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
import { handleUnpair, handleDelete } from "./unpair-handler.js";
beforeEach(() => {
stopMock.mockReset();
stopMock.mockResolvedValue(undefined);
logoutAndStopMock.mockReset();
logoutAndStopMock.mockResolvedValue(undefined);
rmMock.mockReset();
rmMock.mockResolvedValue(undefined);
findFirstMock.mockReset();
findFirstMock.mockResolvedValue({ operatorId: "op-1", label: "WaBot" });
writeAuditLogMock.mockReset();
writeAuditLogMock.mockResolvedValue(undefined);
pgNotifyWebMock.mockReset();
pgNotifyWebMock.mockResolvedValue(undefined);
});
describe("handleUnpair", () => {
it("stops the session WITHOUT logout, removes files, audits, notifies", async () => {
await handleUnpair("acct-A");
// The unpair flow MUST NOT call logoutAndStop — that would tell
// WhatsApp to drop the linked device, which the operator might
// re-pair shortly after. logoutAndStop is only for permanent
// delete.
expect(logoutAndStopMock).not.toHaveBeenCalled();
expect(stopMock).toHaveBeenCalledWith("acct-A", { intentional: true });
expect(rmMock).toHaveBeenCalled();
expect(writeAuditLogMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "account.unpaired", targetId: "acct-A" }),
);
expect(pgNotifyWebMock).toHaveBeenCalledWith({
type: "session.disconnected",
accountId: "acct-A",
});
});
});
describe("handleDelete (logout-before-teardown)", () => {
it("calls logoutAndStop BEFORE rm so WhatsApp drops the linked device first", async () => {
await handleDelete("acct-A");
expect(logoutAndStopMock).toHaveBeenCalledTimes(1);
expect(logoutAndStopMock).toHaveBeenCalledWith("acct-A");
expect(rmMock).toHaveBeenCalledTimes(1);
// Order: logout-and-stop must invoke before rm (otherwise the
// socket was torn down on disk before WhatsApp could be told to
// drop the linked device).
expect(logoutAndStopMock.mock.invocationCallOrder[0]).toBeLessThan(
rmMock.mock.invocationCallOrder[0]!,
);
});
it("does NOT call the plain stop() — that's reserved for unpair", async () => {
// Sanity guard: a refactor that swaps logoutAndStop for stop()
// would silently regress the linked-device cleanup. The test
// pins the contract.
await handleDelete("acct-A");
expect(stopMock).not.toHaveBeenCalled();
});
it("writes an account.deleted audit log carrying the row's label", async () => {
findFirstMock.mockResolvedValueOnce({ operatorId: "op-7", label: "Yiekheng-my" });
await handleDelete("acct-X");
expect(writeAuditLogMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "account.deleted",
operatorId: "op-7",
targetId: "acct-X",
payload: { label: "Yiekheng-my" },
}),
);
});
it("still completes when the audit-log lookup fails (best-effort)", async () => {
// The web action runs the cascade DELETE right after; if the row
// is gone before this handler reads it, the audit lookup throws.
// Delete must not strand on that.
findFirstMock.mockRejectedValueOnce(new Error("no such row"));
await expect(handleDelete("acct-A")).resolves.toBeUndefined();
expect(rmMock).toHaveBeenCalled();
expect(pgNotifyWebMock).toHaveBeenCalled();
});
});

View File

@ -39,41 +39,3 @@ export async function handleUnpair(accountId: string): Promise<void> {
} }
await pgNotifyWeb({ type: "session.disconnected", accountId }); await pgNotifyWeb({ type: "session.disconnected", accountId });
} }
/**
* Delete-account flow on the bot side. Distinct from unpair because
* we want WhatsApp to drop this device from the user's linked-devices
* list otherwise the phone keeps showing a phantom entry that has
* to be manually removed from WhatsApp's UI.
*
* Order is important:
* 1. socket.logout() over the still-connected socket WhatsApp
* removes the linked device on the server side.
* 2. close() the local Baileys session.
* 3. rm() the on-disk auth blob so the next pairing starts clean.
*
* Step 1 is best-effort if the socket is already torn down or the
* RPC fails the delete still proceeds. The web action then deletes
* the row (cascade FKs handle groups/reminders/runs).
*/
export async function handleDelete(accountId: string): Promise<void> {
await sessionManager.logoutAndStop(accountId);
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, label: true },
});
await writeAuditLog(db, {
operatorId: row?.operatorId ?? null,
source: "web",
action: "account.deleted",
targetType: "whatsapp_account",
targetId: accountId,
payload: { label: row?.label ?? null },
});
} catch (err) {
logger.warn({ err, accountId }, "delete: audit log failed (non-fatal)");
}
await pgNotifyWeb({ type: "session.disconnected", accountId });
}

View File

@ -18,24 +18,13 @@ const getReminderMock = vi.fn();
vi.mock("../reminders/crud.js", () => ({ vi.mock("../reminders/crud.js", () => ({
getReminderWithDetails: (...args: unknown[]) => getReminderMock(...args), getReminderWithDetails: (...args: unknown[]) => getReminderMock(...args),
})); }));
// Drizzle's chainable query builders are mocked just deeply enough to
// let fire-reminder's happy path (and the resume path) walk through.
const findExistingRunMock = vi.fn();
vi.mock("../db.js", () => ({ vi.mock("../db.js", () => ({
db: { db: {
insert: () => ({ insert: () => ({ values: () => ({ returning: async () => [{ id: "run-1" }] }) }),
values: () => ({
returning: async () => [{ id: "run-1" }],
}),
// Targets path: no .returning() chained.
values_no_returning: async () => undefined,
}),
update: () => ({ set: () => ({ where: async () => undefined }) }), update: () => ({ set: () => ({ where: async () => undefined }) }),
query: { query: {
whatsappGroups: { findMany: async () => [] }, whatsappGroups: { findMany: async () => [] },
mediaFiles: { findMany: async () => [] }, mediaFiles: { findMany: async () => [] },
reminderRunTargets: { findMany: async () => [] },
reminderRuns: { findFirst: (...args: unknown[]) => findExistingRunMock(...args) },
}, },
}, },
})); }));
@ -54,7 +43,6 @@ describe("fireReminder", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(accountMutex.run).mockClear(); vi.mocked(accountMutex.run).mockClear();
getReminderMock.mockReset(); getReminderMock.mockReset();
findExistingRunMock.mockReset();
}); });
it("acquires accountMutex keyed by accountId for active reminders", async () => { it("acquires accountMutex keyed by accountId for active reminders", async () => {
@ -68,8 +56,6 @@ describe("fireReminder", () => {
scheduleKind: "one_off", scheduleKind: "one_off",
rrule: null, rrule: null,
timezone: "Asia/Kuala_Lumpur", timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "Test", name: "Test",
}); });
@ -83,15 +69,13 @@ describe("fireReminder", () => {
getReminderMock.mockResolvedValue({ getReminderMock.mockResolvedValue({
id: "r-1", id: "r-1",
accountId: "acct-A", accountId: "acct-A",
status: "inactive", status: "ended",
targets: [], targets: [],
messages: [], messages: [],
createdBy: "op-1", createdBy: "op-1",
scheduleKind: "one_off", scheduleKind: "one_off",
rrule: null, rrule: null,
timezone: "Asia/Kuala_Lumpur", timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "Test", name: "Test",
}); });
@ -108,111 +92,6 @@ describe("fireReminder", () => {
expect(accountMutex.run).not.toHaveBeenCalled(); expect(accountMutex.run).not.toHaveBeenCalled();
}); });
it("BAILS OUT INSIDE the mutex when a concurrent job inserted a run while we were queued (TOCTOU repro)", async () => {
// Repro: three pg-boss jobs arrive in the same microsecond. All
// three pass the OUTER recent-run check (no run exists yet) and
// queue up on the per-account mutex. The first acquires, INSERTs
// a run, sends. The second acquires AFTER the first finished —
// its inner check now sees the just-inserted run and must bail,
// otherwise the message would be sent twice (or three times for
// the third job). Without the inner check this regression
// produced "qwerd msg three times" in production.
getReminderMock.mockResolvedValue({
id: "r-1",
accountId: "acct-A",
status: "active",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "Test",
});
// First call (outer check) returns no recent run → mutex acquired.
// Second call (inner check inside fireReminderInner) returns a
// freshly-inserted run from the concurrent winner, so the INSERT
// path bails. We never reach the .insert(reminderRuns) builder so
// the test passes by virtue of the inner-check log + early return.
findExistingRunMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
id: "run-just-inserted-by-the-other-worker",
reminderId: "r-1",
firedAt: new Date(),
status: "pending",
});
await fireReminder({ reminderId: "r-1" });
// The mutex DID get acquired (we got past the outer check), but
// the inner check should have stopped us before any side effects.
expect(accountMutex.run).toHaveBeenCalledTimes(1);
expect(findExistingRunMock).toHaveBeenCalledTimes(2);
});
it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => {
// Two pg-boss jobs landing within microseconds for the same
// reminder should NOT both fire. The first creates the run; the
// second sees that run is < DUPLICATE_FIRE_WINDOW_MS old and exits.
getReminderMock.mockResolvedValue({
id: "r-1",
accountId: "acct-A",
status: "active",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "Test",
});
// The duplicate-fire check shares the reminderRuns.findFirst mock.
// Return a fresh run (firedAt = "just now") to simulate the
// collision.
findExistingRunMock.mockResolvedValue({
id: "run-recent",
reminderId: "r-1",
firedAt: new Date(),
status: "pending",
});
await fireReminder({ reminderId: "r-1" });
expect(accountMutex.run).not.toHaveBeenCalled();
});
it("DOES acquire the mutex on a resume even when the reminder is paused", async () => {
// Resume path must allow status='paused' (and 'active') so the
// operator can drag a paused reminder back into delivery. Fresh
// fires still require status='active'; that's covered by the
// earlier "inactive" test.
getReminderMock.mockResolvedValue({
id: "r-1",
accountId: "acct-A",
status: "paused",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "Test",
});
findExistingRunMock.mockResolvedValue({ id: "run-existing" });
await fireReminder({ reminderId: "r-1", runId: "run-existing" });
expect(accountMutex.run).toHaveBeenCalledTimes(1);
expect(accountMutex.run).toHaveBeenCalledWith("acct-A", expect.any(Function));
});
it("uses different mutex keys for different accounts (cross-account isolation)", async () => { it("uses different mutex keys for different accounts (cross-account isolation)", async () => {
getReminderMock.mockResolvedValueOnce({ getReminderMock.mockResolvedValueOnce({
id: "r-A", id: "r-A",
@ -224,8 +103,6 @@ describe("fireReminder", () => {
scheduleKind: "one_off", scheduleKind: "one_off",
rrule: null, rrule: null,
timezone: "Asia/Kuala_Lumpur", timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "A", name: "A",
}); });
getReminderMock.mockResolvedValueOnce({ getReminderMock.mockResolvedValueOnce({
@ -238,8 +115,6 @@ describe("fireReminder", () => {
scheduleKind: "one_off", scheduleKind: "one_off",
rrule: null, rrule: null,
timezone: "Asia/Kuala_Lumpur", timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "B", name: "B",
}); });

View File

@ -12,12 +12,7 @@ import { readFile } from "node:fs/promises";
import { db } from "../db.js"; import { db } from "../db.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { sessionManager } from "../whatsapp/session-manager.js"; import { sessionManager } from "../whatsapp/session-manager.js";
import { import { absoluteMediaPath, nextOccurrence, resolveDeliveryKind } from "@cmbot/shared";
absoluteMediaPath,
nextOccurrence,
resolveDeliveryKind,
windowEndAt,
} from "@cmbot/shared";
import { env } from "../env.js"; import { env } from "../env.js";
import { writeAuditLog } from "../audit.js"; import { writeAuditLog } from "../audit.js";
import { getReminderWithDetails } from "../reminders/crud.js"; import { getReminderWithDetails } from "../reminders/crud.js";
@ -28,23 +23,7 @@ import { accountMutex } from "./per-key-mutex.js";
import { accountRateLimiter } from "./rate-limiter.js"; import { accountRateLimiter } from "./rate-limiter.js";
import { MediaUploadCache } from "./media-upload-cache.js"; import { MediaUploadCache } from "./media-upload-cache.js";
export type FireReminderPayload = { export type FireReminderPayload = { reminderId: string };
reminderId: string;
/** Optional resume hook. When present, fire-reminder ATTACHES to
* the existing run instead of creating a new one and only re-tries
* targets in `pending` status. Set by the resume server action. */
runId?: string;
};
/**
* Window in which two fire-reminder jobs for the same reminder are
* treated as duplicates. Generous enough to absorb real-world double-
* submits (the operator clicks Save twice; pg_notify floods the
* command-consumer; pg-boss policy didn't dedupe a microsecond-apart
* race) short enough that a deliberately rapid recurring schedule
* (e.g. every minute, in dev) still fires every occurrence.
*/
const DUPLICATE_FIRE_WINDOW_MS = 30_000;
/** Random delay between same-group message parts. Just enough for /** Random delay between same-group message parts. Just enough for
* visible ordering in the chat at WA's natural pace. */ * visible ordering in the chat at WA's natural pace. */
@ -85,128 +64,39 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
logger.warn({ reminderId: payload.reminderId }, "fire-reminder: reminder not found"); logger.warn({ reminderId: payload.reminderId }, "fire-reminder: reminder not found");
return; return;
} }
// Resumes are allowed even when the reminder's lifecycle status is if (reminder.status !== "active") {
// 'paused' — we WANT to take a paused reminder back to active mid- logger.info({ reminderId: reminder.id, status: reminder.status }, "fire-reminder: skipping (not active)");
// resume. Fresh fires still require status='active'.
if (!payload.runId && reminder.status !== "active") {
logger.info(
{ reminderId: reminder.id, status: reminder.status },
"fire-reminder: skipping (not active)",
);
return; return;
} }
// Defense-in-depth dedupe: if pg-boss enqueues two reminder.fire jobs
// for the same reminderId within microseconds (e.g. a duplicate
// schedule call slipped past the queue's singletonKey), the second
// worker would otherwise create a SECOND run and the same message
// gets sent twice. Bail out if a run for this reminder already exists
// and was created less than DUPLICATE_FIRE_WINDOW_MS ago.
if (!payload.runId) {
const recent = await db.query.reminderRuns.findFirst({
where: (r, { eq: dEq, and: dAnd, gt: dGt }) =>
dAnd(
dEq(r.reminderId, reminder.id),
dGt(r.firedAt, new Date(Date.now() - DUPLICATE_FIRE_WINDOW_MS)),
),
orderBy: (r, { desc }) => [desc(r.firedAt)],
});
if (recent) {
logger.warn(
{
reminderId: reminder.id,
recentRunId: recent.id,
recentFiredAt: recent.firedAt,
},
"fire-reminder: duplicate fire detected (a run for this reminder was just created), skipping",
);
return;
}
}
// Per-account mutex: two reminders on the SAME account take turns // Per-account mutex: two reminders on the SAME account take turns
// (running them concurrently would double the effective send rate // (running them concurrently would double the effective send rate
// and risk a ban). Different accounts run in parallel. // and risk a ban). Different accounts run in parallel.
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder, payload.runId)); await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder));
} }
async function fireReminderInner( async function fireReminderInner(
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>, reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
resumeRunId?: string,
): Promise<void> { ): Promise<void> {
// Resume path attaches to the existing run row; fresh path inserts a new one. const [run] = await db
let runId: string; .insert(reminderRuns)
if (resumeRunId) { .values({
const existing = await db.query.reminderRuns.findFirst({ reminderId: reminder.id,
where: (r, { eq: dEq }) => dEq(r.id, resumeRunId), reminderName: reminder.name,
}); status: "pending",
if (!existing) { })
logger.warn( .returning({ id: reminderRuns.id });
{ reminderId: reminder.id, resumeRunId }, const runId = run!.id;
"fire-reminder: resume target run missing",
);
return;
}
runId = existing.id;
// Flip the run back to in-flight so the UI stops showing it as paused.
await db
.update(reminderRuns)
.set({ status: "pending", errorSummary: null })
.where(eq(reminderRuns.id, runId));
} else {
// Re-check the dedupe window now that we're inside the per-account
// mutex. The outer check in fireReminder() is a fast-path bail-out
// but it's TOCTOU: three concurrent jobs can all read "no recent
// run" before any of them inserts, so the message gets sent 2-3
// times. Inside the mutex, the queue serialises us — by the time
// duplicate #2 reaches this point, duplicate #1 has already
// INSERTed and we'll find that row here.
const recent = await db.query.reminderRuns.findFirst({
where: (r, { eq: dEq, and: dAnd, gt: dGt }) =>
dAnd(
dEq(r.reminderId, reminder.id),
dGt(r.firedAt, new Date(Date.now() - DUPLICATE_FIRE_WINDOW_MS)),
),
orderBy: (r, { desc }) => [desc(r.firedAt)],
});
if (recent) {
logger.warn(
{
reminderId: reminder.id,
recentRunId: recent.id,
recentFiredAt: recent.firedAt,
},
"fire-reminder: duplicate fire detected inside mutex (a run was just inserted by a concurrent job), skipping",
);
return;
}
const [run] = await db
.insert(reminderRuns)
.values({
reminderId: reminder.id,
reminderName: reminder.name,
status: "pending",
})
.returning({ id: reminderRuns.id });
runId = run!.id;
}
const session = sessionManager.getSession(reminder.accountId); const session = sessionManager.getSession(reminder.accountId);
if (!session) { if (!session) {
logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected"); logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected");
if (!resumeRunId) { await markAllSkipped(runId, reminder, "account not connected");
await markAllSkipped(runId, reminder, "account not connected");
}
await db await db
.update(reminderRuns) .update(reminderRuns)
.set({ status: "skipped", errorSummary: "account not connected" }) .set({ status: "skipped", errorSummary: "account not connected" })
.where(eq(reminderRuns.id, runId)); .where(eq(reminderRuns.id, runId));
await pgNotifyWeb({ await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status: "skipped" });
type: "reminder.fired",
reminderId: reminder.id,
runId,
status: "skipped",
});
return; return;
} }
@ -225,9 +115,8 @@ async function fireReminderInner(
: []; : [];
const mediaById = new Map(mediaRows.map((m) => [m.id, m])); const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
// Pre-create run_target rows on the fresh path so the Activity tab // Pre-create run_target rows so the Activity tab shows progress mid-run.
// shows progress mid-run. Resume reuses the existing rows. if (reminder.targets.length > 0) {
if (!resumeRunId && reminder.targets.length > 0) {
await db.insert(reminderRunTargets).values( await db.insert(reminderRunTargets).values(
reminder.targets.map((t) => ({ reminder.targets.map((t) => ({
runId, runId,
@ -238,44 +127,11 @@ async function fireReminderInner(
); );
} }
// On resume, only the still-pending rows are processed. On a fresh // Per-run media upload cache. Each unique mediaId is prepared via
// fire that's every row since we just inserted them all as pending. // generateWAMessageContent ONCE (which uploads to WA's CDN through
const pendingRows = await db.query.reminderRunTargets.findMany({ // the socket's waUploadToServer); the resulting proto.Message is
where: (t, { eq: dEq, and: dAnd }) => dAnd(dEq(t.runId, runId), dEq(t.status, "pending")), // reused for every group via socket.relayMessage. For 1000 groups
}); // × 5 MB image, this turns 5 GB of upload into 5 MB.
const pendingGroupIds = new Set(pendingRows.map((r) => r.groupId));
const targetsToProcess = reminder.targets.filter((t) => pendingGroupIds.has(t.groupId));
// Already-sent / already-failed counts from prior run rounds (resume
// case). The final tally adds these to what THIS round produces.
const priorSentCount = resumeRunId
? (
await db.query.reminderRunTargets.findMany({
where: (t, { eq: dEq, and: dAnd }) =>
dAnd(dEq(t.runId, runId), dEq(t.status, "sent")),
})
).length
: 0;
const priorFailedCount = resumeRunId
? (
await db.query.reminderRunTargets.findMany({
where: (t, { eq: dEq, and: dAnd }) =>
dAnd(dEq(t.runId, runId), dEq(t.status, "failed")),
})
).length
: 0;
// Window-end timestamp. If the reminder fires AFTER today's deadline
// hour (cron miss-fired late, or it's already 7pm) this is in the
// past and the FIRST gate check trips immediately, ending the run
// as failed without sending anything.
const windowEnd = windowEndAt(
reminder.timezone,
reminder.deliveryWindowEndHour,
new Date(),
);
// Per-run media upload cache (one prepare call per unique mediaId).
const uploadCache = new MediaUploadCache<proto.IMessage>(async (mediaId) => { const uploadCache = new MediaUploadCache<proto.IMessage>(async (mediaId) => {
const media = mediaById.get(mediaId); const media = mediaById.get(mediaId);
if (!media) throw new Error(`media row missing: ${mediaId}`); if (!media) throw new Error(`media row missing: ${mediaId}`);
@ -301,26 +157,19 @@ async function fireReminderInner(
}); });
}); });
// Per-account rate limiter — gates each socket send. // 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); const rateLimiter = accountRateLimiter.get(reminder.accountId);
let sentCount = 0; let sentCount = 0;
let failedCount = 0; let failedCount = 0;
let skippedCount = 0; let skippedCount = 0;
let windowClosed = false;
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY); const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
await Promise.all( await Promise.all(
targetsToProcess.map((target) => reminder.targets.map((target) =>
groupConcurrency(async () => { groupConcurrency(async () => {
// Window-end gate. CRITICAL: leave the row as `pending` (NOT
// `skipped`) so the run can be resumed later.
if (Date.now() >= windowEnd.getTime()) {
windowClosed = true;
return;
}
const group = groupById.get(target.groupId); const group = groupById.get(target.groupId);
if (!group) { if (!group) {
await db await db
@ -338,6 +187,8 @@ async function fireReminderInner(
const start = Date.now(); const start = Date.now();
try { try {
// Once per group, before the first send. sendMessage handles
// sessions internally; relayMessage does not.
await ensureGroupSessions(session.socket, group.waGroupJid); await ensureGroupSessions(session.socket, group.waGroupJid);
let lastMessageId: string | undefined; let lastMessageId: string | undefined;
@ -391,37 +242,14 @@ async function fireReminderInner(
), ),
); );
// Compose the final status. Four shapes:
// paused : window closed mid-run with at least one row still pending
// AND we delivered at least one in this run or a prior round.
// Resumable. Sent rows stay sent, pending stays pending.
// success : every target sent.
// partial : every target attempted; some sent, some failed/skipped.
// failed : zero sent across all rounds, OR window closed before the
// first send (no progress to resume).
const total = reminder.targets.length; const total = reminder.targets.length;
const totalSent = priorSentCount + sentCount; let status: "success" | "partial" | "failed";
const totalFailed = priorFailedCount + failedCount;
const remainingPending = (
await db.query.reminderRunTargets.findMany({
where: (t, { eq: dEq, and: dAnd }) =>
dAnd(dEq(t.runId, runId), dEq(t.status, "pending")),
})
).length;
let status: "success" | "partial" | "failed" | "paused";
let errorSummary: string | null = null; let errorSummary: string | null = null;
if (windowClosed && remainingPending > 0 && totalSent > 0) { if (sentCount === total) {
status = "paused";
errorSummary = `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}). ${totalSent} of ${total} groups delivered, ${remainingPending} still pending. Resume from the Activity tab.`;
} else if (windowClosed && totalSent === 0) {
status = "failed";
errorSummary = `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}) before any group could be sent. The reminder fired too late in the day.`;
} else if (totalSent === total) {
status = "success"; status = "success";
} else if (totalSent > 0) { } else if (sentCount > 0) {
status = "partial"; status = "partial";
errorSummary = `${totalSent} of ${total} groups delivered (${totalFailed} failed, ${skippedCount} skipped).`; errorSummary = `${sentCount} of ${total} groups delivered (${failedCount} failed, ${skippedCount} skipped).`;
} else { } else {
status = "failed"; status = "failed";
errorSummary = total === 0 ? "No targets attached to reminder." : `All ${total} sends failed.`; errorSummary = total === 0 ? "No targets attached to reminder." : `All ${total} sends failed.`;
@ -432,57 +260,29 @@ async function fireReminderInner(
.set({ status, errorSummary }) .set({ status, errorSummary })
.where(eq(reminderRuns.id, runId)); .where(eq(reminderRuns.id, runId));
await pgNotifyWeb({ await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status });
type: "reminder.fired",
reminderId: reminder.id,
runId,
status,
sent: totalSent,
total,
});
// Lifecycle bookkeeping. Skip when the run is paused — the reminder if (reminder.scheduleKind === "one_off") {
// shouldn't end or re-arm while a resume is still possible. We also
// flip the reminder row itself to status='paused' so dashboards and
// the list view can reflect it.
if (status === "paused") {
await db await db
.update(reminders) .update(reminders)
.set({ status: "paused", updatedAt: new Date() }) .set({ status: "ended", updatedAt: new Date() })
.where(eq(reminders.id, reminder.id)); .where(eq(reminders.id, reminder.id));
logger.info( } else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
{ reminderId: reminder.id, runId, totalSent, remainingPending }, const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
"fire-reminder: paused — leaving lifecycle alone for resume", await db
); .update(reminders)
} else { .set({ lastFiredAt: new Date(), updatedAt: new Date() })
if (reminder.scheduleKind === "one_off") { .where(eq(reminders.id, reminder.id));
await db if (next) {
.update(reminders) try {
.set({ status: "inactive", updatedAt: new Date() }) await scheduleReminderFire(getBoss(), reminder.id, next);
.where(eq(reminders.id, reminder.id)); logger.info({ reminderId: reminder.id, next }, "fire-reminder: re-armed for next occurrence");
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) { } catch (err) {
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date()); logger.error({ err, reminderId: reminder.id }, "fire-reminder: failed to re-arm next occurrence");
await db
.update(reminders)
.set({
// If we're resuming a previously-paused reminder, lift it
// back to active so the next cron occurrence fires normally.
status: "active",
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: "inactive" }).where(eq(reminders.id, reminder.id));
} }
} 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));
} }
} }
@ -496,15 +296,7 @@ async function fireReminderInner(
}); });
logger.info( logger.info(
{ { reminderId: reminder.id, runId, status, sent: sentCount, failed: failedCount, skipped: skippedCount },
reminderId: reminder.id,
runId,
status,
sent: sentCount,
failed: failedCount,
skipped: skippedCount,
windowClosed,
},
"fire-reminder: done", "fire-reminder: done",
); );
} }

View File

@ -1,119 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// pg-boss + db mocks. Hoisted so the vi.mock factories below can refer
// to the spies — see https://vitest.dev/api/vi.html#vi-hoisted.
const {
bossSendMock,
dbExecuteMock,
} = vi.hoisted(() => ({
bossSendMock: vi.fn(async (..._args: unknown[]) => "new-job-id"),
dbExecuteMock: vi.fn(async (..._args: unknown[]) => ({ rows: [] as unknown[] })),
}));
vi.mock("../db.js", () => ({
db: { execute: (...a: unknown[]) => dbExecuteMock(...a) },
}));
vi.mock("../logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
// We don't import pg-boss directly — scheduleReminderFire receives a
// PgBoss instance as its first arg. Build a minimal stub that exposes
// just the .send method (and createQueue / work for registerReminderJobs
// if we ever wire it here).
const fakeBoss = {
send: bossSendMock,
} as unknown as Parameters<typeof scheduleReminderFire>[0];
import { scheduleReminderFire } from "./reminder-jobs.js";
const REMINDER_ID = "11111111-1111-1111-1111-111111111111";
const SINGLETON_KEY = `reminder:${REMINDER_ID}`;
const FIRE_AT = new Date("2026-05-10T12:20:00.000Z");
beforeEach(() => {
bossSendMock.mockReset();
bossSendMock.mockResolvedValue("new-job-id");
dbExecuteMock.mockReset();
dbExecuteMock.mockResolvedValue({ rows: [] });
});
describe("scheduleReminderFire — pre-send cancel of stale created jobs", () => {
it("ALWAYS runs the cancel-stale UPDATE before boss.send (regression: stately dedupe dropped reschedules)", async () => {
// Repro of the dropped-fire bug: the queue was on policy=stately
// and a prior schedule had left a 'created' job in pg-boss with
// the same singletonKey. The new send returned null and the
// user's 8:20 PM fire was silently lost. Under the fix, we MUST
// tombstone any prior created jobs FIRST so the new send wins
// even under standard policy.
dbExecuteMock.mockResolvedValueOnce({ rows: [{ id: "stale-1" }] });
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
// Order matters: cancel happens before send.
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
expect(bossSendMock).toHaveBeenCalledTimes(1);
expect(dbExecuteMock.mock.invocationCallOrder[0]).toBeLessThan(
bossSendMock.mock.invocationCallOrder[0]!,
);
expect(result).toBe("new-job-id");
});
it("scopes the cancel to state='created' only — active/completed jobs MUST survive", async () => {
// The cancel must NOT touch in-flight runs (state='active') nor
// historical fires (state='completed'). Otherwise we'd nuke the
// run that's currently sending and the user gets phantom 'failed'
// rows in the activity feed.
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
const sqlStmt = dbExecuteMock.mock.calls[0]![0];
// Drizzle's sql template returns an SQL object; serialise to inspect.
const text = JSON.stringify(sqlStmt);
expect(text).toMatch(/state\s*=\s*'?created'?/);
expect(text).not.toMatch(/state\s*=\s*'?active'?/);
expect(text).not.toMatch(/state\s*=\s*'?completed'?/);
});
it("targets only THIS reminder's singletonKey (doesn't cross-cancel other reminders)", async () => {
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
const sqlStmt = dbExecuteMock.mock.calls[0]![0];
const text = JSON.stringify(sqlStmt);
// The reminderId must appear in the WHERE clause's bound params
// (drizzle stores them in the serialised payload).
expect(text).toContain(REMINDER_ID);
});
it("passes the singleton key through to boss.send for diagnostics", async () => {
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
const [, , opts] = bossSendMock.mock.calls[0]!;
expect(opts).toMatchObject({
singletonKey: SINGLETON_KEY,
startAfter: FIRE_AT,
retryLimit: 3,
});
});
it("still sends when the cancel UPDATE returns zero rows (first-time schedule)", async () => {
// First time scheduling a reminder — no stale rows exist.
dbExecuteMock.mockResolvedValueOnce({ rows: [] });
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
expect(bossSendMock).toHaveBeenCalledTimes(1);
expect(result).toBe("new-job-id");
});
it("DEGRADES SAFELY: if the cancel UPDATE throws, the send still runs", async () => {
// pg connection blip during cancel must not strand the schedule.
// Worst case we end up with two created jobs and the
// handler-level recent-run dedupe drops the duplicate fire.
dbExecuteMock.mockRejectedValueOnce(new Error("connection reset"));
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
expect(bossSendMock).toHaveBeenCalledTimes(1);
expect(result).toBe("new-job-id");
});
it("returns boss.send's null when pg-boss itself rejects the send", async () => {
// Defense check: if pg-boss returns null for any reason (queue
// missing, future stately-style policy quirks, etc), surface that
// up so the caller's logger captures jobId: null.
bossSendMock.mockResolvedValueOnce(null);
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
expect(result).toBeNull();
});
});

View File

@ -1,39 +1,12 @@
import type { PgBoss } from "pg-boss"; import type { PgBoss } from "pg-boss";
import { sql } from "drizzle-orm";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { db } from "../db.js";
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js"; import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
export const REMINDER_FIRE_QUEUE = "reminder.fire"; export const REMINDER_FIRE_QUEUE = "reminder.fire";
export async function registerReminderJobs(boss: PgBoss): Promise<void> { export async function registerReminderJobs(boss: PgBoss): Promise<void> {
// 'standard' (the default) lets us enqueue a new fire even when an await boss.createQueue(REMINDER_FIRE_QUEUE);
// older one for the same singletonKey is still 'created'. We need
// that for the recurring/edit path: when a reminder is rescheduled,
// scheduleReminderFire() first cancels the stale 'created' job for
// this reminder and then sends a new one — under 'stately' the
// SECOND send returns null (it dedupes against the first across
// states), so a reschedule silently dropped the new fire and the
// reminder never fired at the new time. Duplicate-fire safety is
// covered at the handler level by the inner-mutex recent-run check
// in fire-reminder.ts (see DUPLICATE_FIRE_WINDOW_MS), which catches
// the microsecond-spaced send case 'stately' was supposed to guard.
await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "standard" });
// pg-boss v12's createQueue is idempotent and DOES NOT update the
// policy on an existing queue row. Earlier deployments forced
// policy='stately' here, which broke reschedules. Force-flip back to
// 'standard' on every boot so an old queue row doesn't strand us.
try {
await db.execute(
sql`UPDATE pgboss.queue SET policy = 'standard' WHERE name = ${REMINDER_FIRE_QUEUE} AND policy <> 'standard'`,
);
} catch (err) {
logger.warn(
{ err },
"reminder.fire: failed to force queue policy=standard (handler-level dedupe still applies)",
);
}
await boss.work<FireReminderPayload>( await boss.work<FireReminderPayload>(
REMINDER_FIRE_QUEUE, REMINDER_FIRE_QUEUE,
{ {
@ -61,33 +34,6 @@ export async function scheduleReminderFire(
reminderId: string, reminderId: string,
scheduledAt: Date, scheduledAt: Date,
): Promise<string | null> { ): Promise<string | null> {
const singletonKey = `reminder:${reminderId}`;
// Replace-then-send. Any 'created' (i.e. not yet started) job for
// this reminder is the stale next-fire from the previous schedule
// attempt; nuke it so the new schedule wins. Active/completed jobs
// are left alone — those represent in-flight or already-fired runs
// and the handler-level dedupe handles overlap.
try {
const cancelled = await db.execute(
sql`UPDATE pgboss.job
SET state = 'cancelled', completed_on = now()
WHERE name = ${REMINDER_FIRE_QUEUE}
AND singleton_key = ${singletonKey}
AND state = 'created'
RETURNING id`,
);
if (cancelled.rows.length > 0) {
logger.info(
{ reminderId, cancelled: cancelled.rows.length },
"reminder.fire: cancelled stale created jobs before reschedule",
);
}
} catch (err) {
// If the cancellation step fails, log but still try to send. Worst
// case we end up with two created jobs and the handler-level
// recent-run dedupe drops the duplicate fire.
logger.warn({ err, reminderId }, "reminder.fire: pre-send cancel failed");
}
const id = await boss.send( const id = await boss.send(
REMINDER_FIRE_QUEUE, REMINDER_FIRE_QUEUE,
{ reminderId }, { reminderId },
@ -96,41 +42,14 @@ export async function scheduleReminderFire(
retryLimit: 3, retryLimit: 3,
retryDelay: 30, retryDelay: 30,
retryBackoff: true, retryBackoff: true,
// Singleton key kept on the job row for diagnostics + the // Use the reminderId as a singleton key so re-scheduling cancels the old job
// pre-send cancel above, even though 'standard' policy doesn't singletonKey: `reminder:${reminderId}`,
// dedupe by it.
singletonKey,
}, },
); );
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled"); logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");
return id; return id;
} }
/**
* Re-enqueue a paused run so fire-reminder picks up the still-pending
* targets. Different singleton key from scheduleReminderFire so the
* resume doesn't clobber the next-occurrence scheduled job and vice
* versa.
*/
export async function enqueueReminderResume(
boss: PgBoss,
reminderId: string,
runId: string,
): Promise<string | null> {
const id = await boss.send(
REMINDER_FIRE_QUEUE,
{ reminderId, runId },
{
retryLimit: 3,
retryDelay: 30,
retryBackoff: true,
singletonKey: `reminder:resume:${runId}`,
},
);
logger.info({ reminderId, runId, jobId: id }, "reminder.fire: resume enqueued");
return id;
}
export async function cancelReminderFire(_boss: PgBoss, reminderId: string): Promise<void> { 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. // 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 // The scheduled job will still fire, but `fireReminder` exits early when the

View File

@ -1,76 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Corner case under test: fire-reminder writes the run row with
* status='pending' UP FRONT. If the bot is killed before it flips to
* a terminal status, the row sits at 'pending' indefinitely pg-boss
* won't retry (the job already ran). Activity surfaces, the dashboard
* counters, and the paused-banner all read the row at face value, so
* the operator sees a "stuck" run that never moves.
*
* sweepStalePendingRuns recovers from this on bot startup.
*/
// db.execute fan-out: build a list of {sql, return} pairs the test
// can assert on, and replay them in order. Ordering matters because
// the implementation does TWO updates (runs first, then targets) and
// the second one must only run if the first returned anything.
const executeMock = vi.fn();
vi.mock("../db.js", () => ({
db: {
execute: (...a: unknown[]) => executeMock(...a),
},
}));
import { sweepStalePendingRuns } from "./sweep-stale-runs.js";
beforeEach(() => {
executeMock.mockReset();
});
describe("sweepStalePendingRuns", () => {
it("returns 0 when no stale rows exist (skips the second UPDATE)", async () => {
executeMock.mockResolvedValueOnce({ rows: [] });
const r = await sweepStalePendingRuns();
expect(r).toEqual({ runs: 0, targets: 0 });
// Only the first UPDATE (runs) runs; no second UPDATE for targets.
expect(executeMock).toHaveBeenCalledTimes(1);
});
it("fires both UPDATEs when there ARE stale rows", async () => {
executeMock
.mockResolvedValueOnce({ rows: [{ id: "run-A" }, { id: "run-B" }] })
.mockResolvedValueOnce({ rows: [{ id: "t-1" }, { id: "t-2" }, { id: "t-3" }] });
const r = await sweepStalePendingRuns();
expect(r).toEqual({ runs: 2, targets: 3 });
expect(executeMock).toHaveBeenCalledTimes(2);
});
it("returns the actual swept counts so the caller can log them", async () => {
executeMock
.mockResolvedValueOnce({
rows: [{ id: "run-A" }, { id: "run-B" }, { id: "run-C" }],
})
.mockResolvedValueOnce({ rows: Array.from({ length: 17 }, (_, i) => ({ id: `t-${i}` })) });
const r = await sweepStalePendingRuns();
expect(r.runs).toBe(3);
expect(r.targets).toBe(17);
});
it("doesn't throw when the targets UPDATE returns no rows (run with no pending targets)", async () => {
// A stale run with zero pending targets is unusual but possible —
// the run row got the up-front insert but the per-target inserts
// never ran. Still a stale run, still gets cleared.
executeMock
.mockResolvedValueOnce({ rows: [{ id: "run-D" }] })
.mockResolvedValueOnce({ rows: [] });
const r = await sweepStalePendingRuns();
expect(r).toEqual({ runs: 1, targets: 0 });
});
});

View File

@ -1,64 +0,0 @@
import { sql } from "drizzle-orm";
import { db } from "../db.js";
import { logger } from "../logger.js";
/**
* Recover from "bot crashed / restarted mid-run" crashes.
*
* fire-reminder writes the run row with status='pending' UP FRONT so
* the Activity tab can show progress mid-run, then flips to a
* terminal status (success/partial/failed/paused/skipped) once it's
* done. If the bot dies between those two writes, the row sits at
* 'pending' forever pg-boss already marked the job 'completed', so
* it won't retry.
*
* This sweep runs at bot startup. It finds any 'pending' run older
* than `maxAgeMs` (default 5 minutes enough slack that a real
* mid-run rebalance to another worker isn't accidentally killed) and:
*
* Flips the run to 'failed' with a clear error_summary so the UI
* stops showing it as in-flight.
* Flips its pending run_target rows to 'skipped' with the same
* reason so per-group counts make sense.
*
* Does NOT touch the parent reminder's lifecycle status the row was
* 'active' when the run started and stays that way; the next
* occurrence (cron) or operator action will fire a fresh run.
*/
export async function sweepStalePendingRuns(
maxAgeMs: number = 5 * 60 * 1000,
): Promise<{ runs: number; targets: number }> {
const cutoffMs = Date.now() - maxAgeMs;
const cutoff = new Date(cutoffMs);
const runs = await db.execute(sql`
UPDATE reminder_runs
SET status = 'failed',
error_summary = 'Bot restarted before this run completed.'
WHERE status = 'pending'
AND fired_at < ${cutoff}
RETURNING id
`);
const runRows = runs.rows as Array<{ id: string }>;
if (runRows.length === 0) {
logger.info("sweep-stale-runs: no stale pending runs");
return { runs: 0, targets: 0 };
}
const ids = runRows.map((r) => r.id);
const targets = await db.execute(sql`
UPDATE reminder_run_targets
SET status = 'skipped',
error = 'bot restarted before this group could be sent'
WHERE status = 'pending'
AND run_id IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})
RETURNING id
`);
const targetCount = (targets.rows as Array<unknown>).length;
logger.warn(
{ runs: runRows.length, targets: targetCount, cutoff: cutoff.toISOString() },
"sweep-stale-runs: cleared stale pending runs",
);
return { runs: runRows.length, targets: targetCount };
}

View File

@ -7,45 +7,35 @@ import { logger } from "../logger.js";
export async function syncGroupsForAccount( export async function syncGroupsForAccount(
accountId: string, accountId: string,
socket: WASocket, socket: WASocket,
): Promise<{ synced: number; archived: number }> { ): Promise<{ synced: number; removed: number }> {
const meta = await socket.groupFetchAllParticipating(); const meta = await socket.groupFetchAllParticipating();
const entries = Object.values(meta); const entries = Object.values(meta);
const liveJids = entries.map((g) => g.id); const liveJids = entries.map((g) => g.id);
// Mark DB rows as archived when they're no longer in the live // Remove DB rows for groups that are no longer in the live participant list
// participant list (group deleted, bot removed, etc). We don't // (group was deleted, bot was removed, etc.). Only run the delete when we
// physically DELETE because reminder_targets.group_id is a NOT // got at least one live group back — an empty result is more likely a
// NULL FK to this row — a hard delete throws "violates foreign // transient WA fetch failure than a genuine "all groups gone" signal, and
// key constraint reminder_targets_group_id_whatsapp_groups_id_fk" // we don't want to nuke valid data on a hiccup.
// and aborts the WHOLE group-sync transaction (which then strands let removed: { id: string }[] = [];
// the post-pair open event and the operator sees it as a failed
// pairing). Soft-archive keeps reminders that targeted the group
// intact and gives the operator the option to clean them up
// explicitly later. Only run the sweep when we got at least one
// live group back — an empty result is usually a transient WA
// fetch failure and we don't want to mass-archive valid data.
let archived = 0;
if (liveJids.length > 0) { if (liveJids.length > 0) {
const rows = await db removed = await db
.update(whatsappGroups) .delete(whatsappGroups)
.set({ isArchived: true, lastSyncedAt: new Date() })
.where( .where(
and( and(
eq(whatsappGroups.accountId, accountId), eq(whatsappGroups.accountId, accountId),
notInArray(whatsappGroups.waGroupJid, liveJids), notInArray(whatsappGroups.waGroupJid, liveJids),
eq(whatsappGroups.isArchived, false),
), ),
) )
.returning({ id: whatsappGroups.id }); .returning({ id: whatsappGroups.id });
archived = rows.length;
} }
if (entries.length === 0) { if (entries.length === 0) {
logger.info( logger.info(
{ accountId }, { accountId },
"group-sync: empty fetch — skipping archive sweep (treating as transient)", "group-sync: empty fetch — skipping delete sweep (treating as transient)",
); );
return { synced: 0, archived: 0 }; return { synced: 0, removed: 0 };
} }
const rows = entries.map((g) => ({ const rows = entries.map((g) => ({
@ -66,16 +56,12 @@ export async function syncGroupsForAccount(
name: sql`excluded.name`, name: sql`excluded.name`,
participantCount: sql`excluded.participant_count`, participantCount: sql`excluded.participant_count`,
lastSyncedAt: sql`excluded.last_synced_at`, lastSyncedAt: sql`excluded.last_synced_at`,
// If a previously-archived group reappears in the live list
// (operator was re-added, group was un-deleted, etc.), flip
// the flag back so it shows up in the picker again.
isArchived: sql`excluded.is_archived`,
}, },
}); });
logger.info( logger.info(
{ accountId, count: rows.length, archived }, { accountId, count: rows.length, removed: removed.length },
"group-sync: synced", "group-sync: synced",
); );
return { synced: rows.length, archived }; return { synced: rows.length, removed: removed.length };
} }

View File

@ -120,44 +120,6 @@ class SessionManager {
this.sessions.delete(accountId); this.sessions.delete(accountId);
} }
/**
* Tell WhatsApp to remove this device from the linked-devices list,
* then close the socket. Used by the delete-account flow so the
* operator's phone doesn't keep showing a phantom "linked device"
* pointing at a row that no longer exists. Best-effort: if the
* socket is already torn down or the logout RPC fails (network
* blip, already-disconnected, etc.) we still proceed to close +
* teardown no point stranding the delete because WhatsApp didn't
* acknowledge.
*/
async logoutAndStop(accountId: string): Promise<void> {
const timer = this.reconnectTimers.get(accountId);
if (timer) {
clearTimeout(timer);
this.reconnectTimers.delete(accountId);
}
const session = this.sessions.get(accountId);
if (!session) return;
// Suppress reconnect/handleEvent bookkeeping for the close that
// logout() emits — the row is about to be deleted entirely so
// status writes are pointless.
this.intentionalStops.add(accountId);
try {
await session.socket.logout();
} catch (err) {
logger.warn(
{ err, accountId },
"session-manager: socket.logout() failed (continuing with teardown)",
);
}
try {
await session.close();
} catch (err) {
logger.warn({ err, accountId }, "session-manager: post-logout close failed");
}
this.sessions.delete(accountId);
}
async stopAll(): Promise<void> { async stopAll(): Promise<void> {
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id))); await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
} }

View File

@ -1,27 +0,0 @@
# Required
DATABASE_URL=postgres://user:pass@host:5432/dbname
# Auth — sign cookies. 64+ random chars. Generate via scripts/gen_auth_secret.sh.
AUTH_SECRET=replace-me
# Bump to invalidate all live sessions instantly. Leave at 1 normally.
OPERATOR_TOKEN_VERSION=1
# File-storage paths inside the bot container
DATA_DIR=/data
SESSIONS_DIR=/data/sessions
MEDIA_DIR=/data/media
# Bot fan-out tuning (see apps/bot/src/env.ts)
BOT_HEALTH_PORT=8081
BOT_LOG_LEVEL=info
BOT_FIRE_CONCURRENCY=8
BOT_GROUP_CONCURRENCY=3
BOT_MAX_SEND_PER_MINUTE=40
# Web
WEB_PORT=9000
# Seed (runs once via scripts/db.sh seed)
SEED_OPERATOR_USERNAME=admin
SEED_OPERATOR_NAME=Operator

View File

@ -21,7 +21,6 @@ const nextConfig: NextConfig = {
experimental: { experimental: {
typedRoutes: true, typedRoutes: true,
serverActions: { serverActions: {
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
// Default Server Action body limit is 1 MB — way under WhatsApp's // Default Server Action body limit is 1 MB — way under WhatsApp's
// 100 MB document cap. Lifted to 100 MB so document uploads reach // 100 MB document cap. Lifted to 100 MB so document uploads reach
// the action; the per-kind WhatsApp validator // the action; the per-kind WhatsApp validator

View File

@ -18,7 +18,6 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@serwist/next": "^9.5.11", "@serwist/next": "^9.5.11",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.0",
@ -45,7 +44,6 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^22.7.0", "@types/node": "^22.7.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",

View File

@ -172,16 +172,8 @@ export async function unpairAccountAction(formData: FormData): Promise<void> {
.update(whatsappAccounts) .update(whatsappAccounts)
.set({ status: "unpaired", phoneNumber: null }) .set({ status: "unpaired", phoneNumber: null })
.where(eq(whatsappAccounts.id, accountId)); .where(eq(whatsappAccounts.id, accountId));
// Soft-archive synced groups instead of DELETEing. Hard delete // Wipe synced groups too — they belong to a different WA login now.
// failed with "violates foreign key constraint await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId));
// reminder_targets_group_id_whatsapp_groups_id_fk" whenever any
// group had ever been used in a reminder, which aborted the
// unpair. Archived groups vanish from the picker; a re-pair flips
// them back via the on-conflict upsert in syncGroupsForAccount.
await db
.update(whatsappGroups)
.set({ isArchived: true })
.where(eq(whatsappGroups.accountId, accountId));
revalidatePath("/accounts"); revalidatePath("/accounts");
revalidatePath(`/accounts/${accountId}`); revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -201,12 +193,8 @@ export async function deleteAccountAction(formData: FormData): Promise<void> {
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
}); });
if (!account) return; if (!account) return;
// Tell the bot to logout() over the live socket FIRST (so WhatsApp // Stop any live session / clean session files first.
// drops this device from the operator's linked-devices list), then await pgNotifyBot({ type: "account.unpair", accountId });
// close + remove session files. Distinct from account.unpair which
// never calls logout — keeping linked-devices clean is specific to
// the delete flow.
await pgNotifyBot({ type: "account.delete", accountId });
// Cascade FKs handle groups, reminders, runs, run_targets, messages. // Cascade FKs handle groups, reminders, runs, run_targets, messages.
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId)); await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
revalidatePath("/accounts"); revalidatePath("/accounts");

View File

@ -1,367 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import bcrypt from "bcryptjs";
const {
cookiesSetMock,
cookiesDeleteMock,
findUserMock,
headersGetMock,
headerStore,
checkRateLimitMock,
redirectMock,
loggerMock,
} = vi.hoisted(() => ({
cookiesSetMock: vi.fn(),
cookiesDeleteMock: vi.fn(),
findUserMock: vi.fn(),
headersGetMock: vi.fn(() => "127.0.0.1"),
headerStore: new Map<string, string>(),
checkRateLimitMock: vi.fn(),
redirectMock: vi.fn((_path: string) => {
throw new Error("redirect");
}),
loggerMock: { warn: vi.fn(), info: vi.fn() },
}));
vi.mock("next/headers", () => ({
cookies: async () => ({ set: cookiesSetMock, delete: cookiesDeleteMock }),
headers: async () => ({
get: (k: string) => {
const key = k.toLowerCase();
if (key === "x-forwarded-for") return headersGetMock();
// Tests opt-in to setting origin/host/etc. via headerStore;
// unset = null which lets hasSameOriginRequest treat the
// request as same-origin (Origin omitted = same-origin per RFC).
return headerStore.get(key) ?? null;
},
}),
}));
vi.mock("next/navigation", () => ({
redirect: (path: string) => redirectMock(path),
}));
vi.mock("@/lib/db", () => ({
db: {
query: {
operators: { findFirst: (...a: unknown[]) => findUserMock(...a) },
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
}));
vi.mock("@/lib/logger", () => ({ logger: loggerMock }));
const SECRET = "test-secret-not-real";
beforeEach(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
cookiesSetMock.mockReset();
cookiesDeleteMock.mockReset();
findUserMock.mockReset();
checkRateLimitMock.mockReset();
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
redirectMock.mockReset();
redirectMock.mockImplementation((_path: string) => {
throw new Error("redirect");
});
loggerMock.warn.mockReset();
headerStore.clear();
});
import { loginAction, logoutAction } from "./auth";
const REAL_HASH = bcrypt.hashSync("correct-horse", 10);
const ADMIN_ROW = {
id: "11111111-1111-1111-1111-111111111111",
username: "admin",
role: "admin" as const,
displayName: "Admin",
defaultTimezone: "UTC",
passwordHash: REAL_HASH,
};
function fd(fields: Record<string, string>): FormData {
const f = new FormData();
for (const [k, v] of Object.entries(fields)) f.append(k, v);
return f;
}
describe("loginAction", () => {
it("issues a session cookie when credentials are correct", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prevEnv = process.env.NODE_ENV;
// @ts-expect-error - test override
process.env.NODE_ENV = "production";
try {
const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(
(e) => e,
);
// Successful login redirects, so the redirect mock throws.
expect((r as Error).message).toBe("redirect");
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
const [name, , attrs] = cookiesSetMock.mock.calls[0]!;
expect(name).toBe("session");
expect(attrs).toMatchObject({
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 30 * 86400,
});
} finally {
// @ts-expect-error - test restore
process.env.NODE_ENV = prevEnv;
}
});
it("sets secure=false on the cookie when NODE_ENV !== production", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prevEnv = process.env.NODE_ENV;
// @ts-expect-error - test override
process.env.NODE_ENV = "development";
try {
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
const [, , attrs] = cookiesSetMock.mock.calls[0]!;
expect(attrs).toMatchObject({ secure: false });
} finally {
// @ts-expect-error - test restore
process.env.NODE_ENV = prevEnv;
}
});
it("returns ok:false on wrong password and does NOT set a cookie", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const r = await loginAction(fd({ username: "admin", password: "wrong" }));
expect(r).toEqual({ ok: false, error: "Invalid username or password." });
expect(cookiesSetMock).not.toHaveBeenCalled();
expect(loggerMock.warn).toHaveBeenCalled();
});
it("returns ok:false on unknown username and STILL invokes bcrypt (timing equivalence)", async () => {
findUserMock.mockResolvedValue(undefined);
const cmpSpy = vi.spyOn(bcrypt, "compare");
await loginAction(fd({ username: "nobody", password: "irrelevant" }));
expect(cmpSpy).toHaveBeenCalled(); // compared against DUMMY_HASH
cmpSpy.mockRestore();
});
it("returns a clear error when the user has no password_hash set", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, passwordHash: null });
const r = await loginAction(fd({ username: "admin", password: "anything" }));
expect(r).toEqual({
ok: false,
error: "Set a password via scripts/set-password.sh before signing in.",
});
});
it("rejects empty username or password without hitting the DB", async () => {
const r = await loginAction(fd({ username: "", password: "x" }));
expect(r).toEqual({ ok: false, error: "Username and password are required." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("rejects username/password >256 chars without invoking bcrypt", async () => {
const cmpSpy = vi.spyOn(bcrypt, "compare");
const long = "x".repeat(300);
const r = await loginAction(fd({ username: long, password: long }));
expect(r).toEqual({ ok: false, error: "Input too long." });
expect(cmpSpy).not.toHaveBeenCalled();
cmpSpy.mockRestore();
});
it("matches username case-insensitively", async () => {
findUserMock.mockImplementation(async () => ADMIN_ROW);
await loginAction(fd({ username: "ADMIN", password: "correct-horse" })).catch(() => {});
expect(findUserMock).toHaveBeenCalled();
});
it("returns 429 when the rate limit is exhausted", async () => {
checkRateLimitMock.mockResolvedValue({ limited: true, count: 11 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("logs the failed attempt with username and ip but never the password", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headersGetMock.mockReturnValue("203.0.113.5, 10.0.0.1");
await loginAction(fd({ username: "admin", password: "wrong" }));
const [meta, msg] = loggerMock.warn.mock.calls[0]!;
expect(meta).toMatchObject({ username: "admin", ip: "203.0.113.5" });
expect(JSON.stringify(meta)).not.toContain("wrong");
expect(msg).toMatch(/login failed/i);
});
it("redirects to safeRedirect(next) on success", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
await loginAction(fd({
username: "admin",
password: "correct-horse",
next: "/dashboard",
})).catch(() => {});
expect(redirectMock).toHaveBeenCalledWith("/dashboard");
});
it("redirects to / when next is unsafe", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
await loginAction(fd({
username: "admin",
password: "correct-horse",
next: "//evil.com",
})).catch(() => {});
expect(redirectMock).toHaveBeenCalledWith("/");
});
});
describe("logoutAction", () => {
it("clears the session cookie and redirects to /login", async () => {
await logoutAction().catch(() => {});
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
expect(redirectMock).toHaveBeenCalledWith("/login");
});
it("is idempotent — clears the cookie even when no session exists", async () => {
// Real-world: a user with an expired cookie clicks Sign out. cookies.delete
// doesn't care about pre-existing state and we still issue the redirect.
cookiesDeleteMock.mockReset();
await logoutAction().catch(() => {});
expect(cookiesDeleteMock).toHaveBeenCalledTimes(1);
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
});
});
describe("loginAction — additional cases", () => {
it("issues a cookie that decrypts to role='user' for a non-admin user", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "user" });
await loginAction(fd({ username: "alice", password: "correct-horse" })).catch(() => {});
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
const [, cookieValue] = cookiesSetMock.mock.calls[0]!;
// The cookie is now AES-GCM encrypted, so we can't peel the payload
// off raw — decrypt with the same secret loginAction used. This
// also doubles as a confidentiality smoke test: 'user'/'alice'
// must NOT appear verbatim in the cookie bytes.
expect(cookieValue as string).not.toContain("alice");
expect(cookieValue as string).not.toContain("user");
const { verifySession } = await import("@/lib/auth-cookie");
const decoded = await verifySession(cookieValue as string, SECRET);
expect(decoded?.role).toBe("user");
expect(decoded?.userId).toBe(ADMIN_ROW.id);
});
it("rejects when the user row has an unrecognised role string", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "robot" });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Account is not enabled." });
expect(cookiesSetMock).not.toHaveBeenCalled();
});
it("returns ok:false when AUTH_SECRET is unset (server misconfig)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prev = process.env.AUTH_SECRET;
delete process.env.AUTH_SECRET;
try {
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Server is not configured for sign-in." });
expect(cookiesSetMock).not.toHaveBeenCalled();
} finally {
process.env.AUTH_SECRET = prev;
}
});
it("treats whitespace-only username as missing input", async () => {
const r = await loginAction(fd({ username: " ", password: "x" }));
expect(r).toEqual({ ok: false, error: "Username and password are required." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("uses three rate-limit layers: per-IP, per-username, global", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headersGetMock.mockReturnValue("198.51.100.42");
await loginAction(fd({ username: "Admin", password: "correct-horse" })).catch(() => {});
// Three checkRateLimit calls fired in parallel via Promise.all,
// in this order: ip / user / global.
expect(checkRateLimitMock).toHaveBeenCalledTimes(3);
const keys = checkRateLimitMock.mock.calls.map((c) => c[0] as string);
expect(keys[0]).toBe("login:198.51.100.42");
// Username key is normalised to lowercase so "Admin" and "admin"
// share the same bucket — otherwise an attacker rotating case
// would dodge per-username throttling.
expect(keys[1]).toBe("login-user:admin");
expect(keys[2]).toBe("login-global");
});
it("rejects login when the per-username limit alone is hit (rotating-IPs attacker)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
// First call (ip) passes, second (user) is over, third (global) passes.
checkRateLimitMock
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: true, count: 6 })
.mockResolvedValueOnce({ limited: false, count: 5 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
expect(findUserMock).not.toHaveBeenCalled();
// Logger captures which limit tripped so we can tune thresholds
// without leaking the answer to the attacker.
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
expect(meta).toMatchObject({ limit: "username" });
});
it("rejects login when the global limit alone is hit (everyone-pile-on backstop)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
checkRateLimitMock
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: true, count: 101 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
expect(meta).toMatchObject({ limit: "global" });
});
it("rejects a cross-origin POST before checking credentials", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "https://attacker.example");
headerStore.set("host", "wabot.04080616.xyz");
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
expect(checkRateLimitMock).not.toHaveBeenCalled();
expect(findUserMock).not.toHaveBeenCalled();
});
it("accepts a same-origin POST (Origin host matches Host header)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "https://wabot.04080616.xyz");
headerStore.set("host", "wabot.04080616.xyz");
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
// Got past the origin check → DB lookup ran.
expect(findUserMock).toHaveBeenCalled();
});
it("treats a missing Origin header as same-origin (legitimate native form POST)", async () => {
// Browsers don't always send Origin (e.g. plain top-level form
// submissions). Refusing those would brick login on some clients.
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.delete("origin");
headerStore.set("host", "wabot.04080616.xyz");
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
expect(findUserMock).toHaveBeenCalled();
});
it("rejects when Origin is malformed (non-URL string)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "not a url");
headerStore.set("host", "wabot.04080616.xyz");
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
});
it("still hits the DB and bcrypt on the unknown-user path so timing equivalence holds", async () => {
findUserMock.mockResolvedValue(undefined);
const cmpSpy = vi.spyOn(bcrypt, "compare");
await loginAction(fd({ username: "ghost", password: "anything" }));
// findFirst was called even though we know the user doesn't exist.
expect(findUserMock).toHaveBeenCalledTimes(1);
expect(cmpSpy).toHaveBeenCalled();
cmpSpy.mockRestore();
});
});

View File

@ -1,182 +0,0 @@
"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
COOKIE_NAME,
DEFAULT_TTL_SECONDS,
signSession,
type Role,
} from "@/lib/auth-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { safeRedirect } from "@/lib/safe-redirect";
import { logger } from "@/lib/logger";
export type LoginResult = { ok: true } | { ok: false; error: string };
const MAX_FIELD_LEN = 256;
// Precomputed bcryptjs hash of the throwaway string "x", cost 10.
// Compared against on the user-not-found path so timing matches the
// wrong-password path. Generating fresh per request would double the
// bcrypt work and create its own timing signal.
const DUMMY_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
async function clientIp(): Promise<string> {
const h = await headers();
const fwd = h.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0]!.trim();
return h.get("x-real-ip") ?? "unknown";
}
/**
* Compare the inbound Origin to the request's Host. Server Actions
* already get an Origin check via Next 16's
* `serverActions.allowedOrigins`, but that's a global config running
* the same comparison here is cheap belt-and-braces and lets us log
* mismatches with action-level context. Returns true when:
* - no Origin header is present (same-origin POSTs from the same
* server), OR
* - Origin's host matches the Host header (same-origin)
* Anything else (cross-origin POST, malformed Origin, etc.) false.
*/
async function hasSameOriginRequest(): Promise<boolean> {
const h = await headers();
const origin = h.get("origin");
if (!origin) return true; // RFC: same-origin requests may omit Origin
const host = h.get("host");
if (!host) return false;
try {
const u = new URL(origin);
return u.host === host;
} catch {
return false;
}
}
export async function loginAction(formData: FormData): Promise<LoginResult> {
const username = (formData.get("username") ?? "").toString();
const password = (formData.get("password") ?? "").toString();
const next = (formData.get("next") ?? "").toString();
if (!username.trim() || !password) {
return { ok: false, error: "Username and password are required." };
}
if (username.length > MAX_FIELD_LEN || password.length > MAX_FIELD_LEN) {
return { ok: false, error: "Input too long." };
}
// Action-level Origin check. Next 16's serverActions.allowedOrigins
// already gates this at the framework boundary, but doing it here
// with action context lets us log the mismatch and surface a clean
// error instead of relying on the global config alone.
if (!(await hasSameOriginRequest())) {
logger.warn({}, "login rejected: cross-origin request");
return { ok: false, error: "Cross-origin request blocked." };
}
const ip = await clientIp();
// Three-layer rate limit:
// per-IP — typical brute-forcer
// per-username — attacker who rotates IPs (X-Forwarded-For
// spoofing, residential proxy pool) but pounds
// a single account
// global — backstop. If the attacker controls enough
// IP+username combos to slip past the first two,
// this caps the total login attempts per minute
// across the install. Lock occurs at the FIRST
// limit hit; we don't reveal which one.
const usernameKey = username.trim().toLowerCase();
const [rlIp, rlUser, rlGlobal] = await Promise.all([
checkRateLimit(`login:${ip}`, { max: 10, windowSec: 300 }),
checkRateLimit(`login-user:${usernameKey}`, { max: 5, windowSec: 900 }),
checkRateLimit(`login-global`, { max: 100, windowSec: 60 }),
]);
if (rlIp.limited || rlUser.limited || rlGlobal.limited) {
logger.warn(
{
ip,
username: usernameKey,
limit: rlIp.limited ? "ip" : rlUser.limited ? "username" : "global",
},
"login rate-limited",
);
return { ok: false, error: "Too many attempts. Try again later." };
}
const row = await db.query.operators.findFirst({
where: (o) => sql`lower(${o.username}) = lower(${username})`,
});
// User exists but has no password configured: this is a server-side
// setup error, not a credential mismatch. Surface a distinct message
// so the operator knows to run scripts/set-password.sh. We still ran
// the DB lookup, so the username-enumeration concern is not relevant
// here (the attacker would already need a known username).
if (row && row.passwordHash === null) {
return {
ok: false,
error: "Set a password via scripts/set-password.sh before signing in.",
};
}
// Run bcrypt regardless to keep the user-not-found path timing-
// equivalent to the wrong-password path.
const hash = row?.passwordHash ?? DUMMY_HASH;
const ok = await bcrypt.compare(password, hash);
if (!row || !ok) {
logger.warn({ username, ip }, "login failed");
return { ok: false, error: "Invalid username or password." };
}
if (row.role !== "admin" && row.role !== "user") {
return { ok: false, error: "Account is not enabled." };
}
const secret = process.env.AUTH_SECRET;
if (!secret) {
logger.warn({}, "AUTH_SECRET unset — cannot issue cookie");
return { ok: false, error: "Server is not configured for sign-in." };
}
const v = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
const now = Math.floor(Date.now() / 1000);
const cookie = await signSession(
{
userId: row.id,
role: row.role as Role,
iat: now,
exp: now + DEFAULT_TTL_SECONDS,
v,
},
secret,
);
const jar = await cookies();
// Secure: only require https in production. In dev we hit
// http://localhost:9000 directly, and Firefox/Safari silently drop
// Set-Cookie when Secure is set on http origins (Chrome has a
// localhost exception, others don't), which manifested as the
// session cookie never being persisted across requests.
jar.set(COOKIE_NAME, cookie, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: DEFAULT_TTL_SECONDS,
});
// Typed-routes is on (next.config.ts experimental.typedRoutes); the
// `next` value is a runtime string from the form so we cast through any.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(safeRedirect(next) as any);
}
export async function logoutAction(): Promise<void> {
const jar = await cookies();
jar.delete(COOKIE_NAME);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/login" as any);
}

View File

@ -33,12 +33,6 @@ export async function sendTestAction(_prev: unknown, formData: FormData): Promis
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
} }
const groupId = parsed.data.groupId;
const groupRl = await checkRateLimit(`send-test:${groupId}`, { max: 3, windowSec: 60 });
if (groupRl.limited) {
return { ok: false, error: "Too many tests for this group. Try again later." };
}
const op = await getSeededOperator(); const op = await getSeededOperator();
const group = await db.query.whatsappGroups.findFirst({ const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, parsed.data.groupId), where: (g, { eq }) => eq(g.id, parsed.data.groupId),

View File

@ -1,211 +0,0 @@
/**
* Unit-tests the resume + cancel server actions in isolation. We mock
* the seeded operator, drizzle db, and the pgNotifyBot helper so the
* tests exercise the action's auth / status / lifecycle logic without
* a real Postgres connection.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const findRunMock = vi.fn();
const findReminderMock = vi.fn();
const findAccountMock = vi.fn();
const updateMock = vi.fn();
const transactionMock = vi.fn();
const pgNotifyMock = vi.fn();
vi.mock("@/lib/db", () => ({
db: {
query: {
reminderRuns: { findFirst: (...a: unknown[]) => findRunMock(...a) },
reminders: { findFirst: (...a: unknown[]) => findReminderMock(...a) },
whatsappAccounts: {
findFirst: (...a: unknown[]) => findAccountMock(...a),
},
},
update: () => ({
set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }),
}),
// The cancel action does its DB mutations inside a transaction.
// Run the callback against the same shape as `db` so its inner
// `tx.update(...).set(...).where(...)` calls land in updateMock.
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
transactionMock();
const tx = {
update: () => ({
set: () => ({
where: async (...a: unknown[]) => updateMock(...a),
}),
}),
};
return fn(tx);
},
},
}));
vi.mock("@/lib/operator", () => ({
getSeededOperator: async () => ({ id: "op-1" }),
}));
vi.mock("@/lib/notify", () => ({
pgNotifyBot: (...a: unknown[]) => pgNotifyMock(...a),
}));
// Rate limiter doesn't fire from these actions, but stub it anyway in
// case the implementation grows it later.
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: async () => ({ limited: false }),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/headers", () => ({ headers: async () => new Map() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
import {
resumeReminderRunAction,
cancelReminderRunAction,
} from "./reminders";
const PAUSED_RUN = { id: "11111111-1111-1111-1111-111111111111", reminderId: "r-1", status: "paused" };
const REMINDER = { id: "r-1", accountId: "acc-1", scheduleKind: "recurring" };
const REMINDER_ONE_OFF = { ...REMINDER, scheduleKind: "one_off" };
const ACCOUNT = { id: "acc-1", operatorId: "op-1" };
beforeEach(() => {
findRunMock.mockReset();
findReminderMock.mockReset();
findAccountMock.mockReset();
updateMock.mockReset();
transactionMock.mockReset();
pgNotifyMock.mockReset();
});
describe("resumeReminderRunAction", () => {
it("rejects a non-uuid runId", async () => {
const r = await resumeReminderRunAction({ runId: "not-a-uuid" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/Invalid/);
});
it("returns 'Run not found' when the run row is missing", async () => {
findRunMock.mockResolvedValue(undefined);
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
expect(r).toEqual({ ok: false, error: "Run not found" });
});
it("returns 'Reminder not found' when the run is orphaned", async () => {
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(undefined);
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
expect(r).toEqual({ ok: false, error: "Reminder not found" });
});
it("returns 'Run not yours' when another operator owns the account", async () => {
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(REMINDER);
findAccountMock.mockResolvedValue(undefined);
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
expect(r).toEqual({ ok: false, error: "Run not yours" });
});
it("rejects when run.status !== 'paused'", async () => {
findRunMock.mockResolvedValue({ ...PAUSED_RUN, status: "success" });
findReminderMock.mockResolvedValue(REMINDER);
findAccountMock.mockResolvedValue(ACCOUNT);
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/Cannot resume a success run/);
});
it("happy path: notifies the bot with reminder.resume and runId", async () => {
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(REMINDER);
findAccountMock.mockResolvedValue(ACCOUNT);
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
expect(r).toEqual({ ok: true });
expect(pgNotifyMock).toHaveBeenCalledTimes(1);
expect(pgNotifyMock).toHaveBeenCalledWith({
type: "reminder.resume",
reminderId: REMINDER.id,
runId: PAUSED_RUN.id,
});
});
});
describe("cancelReminderRunAction", () => {
it("rejects a non-uuid runId", async () => {
const r = await cancelReminderRunAction({ runId: "nope" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/Invalid/);
});
it("rejects when the run isn't paused", async () => {
findRunMock.mockResolvedValue({ ...PAUSED_RUN, status: "success" });
findReminderMock.mockResolvedValue(REMINDER);
findAccountMock.mockResolvedValue(ACCOUNT);
const r = await cancelReminderRunAction({ runId: PAUSED_RUN.id });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/Cannot cancel/);
});
it("happy path: opens a transaction and runs three updates (targets / run / reminder)", async () => {
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(REMINDER);
findAccountMock.mockResolvedValue(ACCOUNT);
const r = await cancelReminderRunAction({ runId: PAUSED_RUN.id });
expect(r).toEqual({ ok: true });
expect(transactionMock).toHaveBeenCalledTimes(1);
// Three separate set/where calls inside the tx: update targets,
// update run, update reminder lifecycle.
expect(updateMock).toHaveBeenCalledTimes(3);
// Cancel does NOT enqueue the bot — it's purely a DB-side operation.
expect(pgNotifyMock).not.toHaveBeenCalled();
});
it("recurring reminder: lifecycle goes back to active so the next occurrence fires", async () => {
// Use a tx-update spy that captures the SET payload.
const setSpy = vi.fn();
const { db } = await import("@/lib/db");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(db as any).transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
update: () => ({
set: (payload: unknown) => {
setSpy(payload);
return { where: async () => undefined };
},
}),
};
return fn(tx);
};
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(REMINDER); // recurring
findAccountMock.mockResolvedValue(ACCOUNT);
await cancelReminderRunAction({ runId: PAUSED_RUN.id });
// Last set call is on the reminders table — status flips to active.
const calls = setSpy.mock.calls;
const lastPayload = calls[calls.length - 1]?.[0] as Record<string, unknown>;
expect(lastPayload.status).toBe("active");
});
it("one-off reminder: lifecycle ends (no future occurrence to wait for)", async () => {
const setSpy = vi.fn();
const { db } = await import("@/lib/db");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(db as any).transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
update: () => ({
set: (payload: unknown) => {
setSpy(payload);
return { where: async () => undefined };
},
}),
};
return fn(tx);
};
findRunMock.mockResolvedValue(PAUSED_RUN);
findReminderMock.mockResolvedValue(REMINDER_ONE_OFF);
findAccountMock.mockResolvedValue(ACCOUNT);
await cancelReminderRunAction({ runId: PAUSED_RUN.id });
const calls = setSpy.mock.calls;
const lastPayload = calls[calls.length - 1]?.[0] as Record<string, unknown>;
expect(lastPayload.status).toBe("inactive");
});
});

View File

@ -6,13 +6,7 @@ import { headers } from "next/headers";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
reminders,
reminderTargets,
reminderMessages,
reminderRuns,
reminderRunTargets,
} from "@cmbot/db";
import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared"; import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
@ -271,7 +265,7 @@ const createReminderSchema = z
path: ["messages"], path: ["messages"],
}, },
) )
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 24), { .refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 18), {
message: "Delivery window start must be earlier than end", message: "Delivery window start must be earlier than end",
path: ["deliveryWindowStartHour"], path: ["deliveryWindowStartHour"],
}); });
@ -328,11 +322,7 @@ export async function createReminderAction(
timezone, timezone,
} = parsed.data; } = parsed.data;
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6; const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
// operator hasn't ticked the optional "Pause sending by" checkbox;
// fall back to 24 here so legacy payloads / direct API calls don't
// accidentally enable the deadline at 6pm.
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
const parts = resolveMessageParts(parsed.data); const parts = resolveMessageParts(parsed.data);
const op = await getSeededOperator(); const op = await getSeededOperator();
@ -446,11 +436,7 @@ export async function updateReminderAction(
timezone, timezone,
} = parsed.data; } = parsed.data;
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6; const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
// operator hasn't ticked the optional "Pause sending by" checkbox;
// fall back to 24 here so legacy payloads / direct API calls don't
// accidentally enable the deadline at 6pm.
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
const parts = resolveMessageParts(parsed.data); const parts = resolveMessageParts(parsed.data);
const op = await getSeededOperator(); const op = await getSeededOperator();
@ -552,141 +538,3 @@ export async function updateReminderAction(
revalidatePath(`/reminders/${reminderId}`); revalidatePath(`/reminders/${reminderId}`);
return { ok: true, reminderId }; return { ok: true, reminderId };
} }
// ---------------------------------------------------------------------------
// Resume / cancel a paused run
// ---------------------------------------------------------------------------
const runIdSchema = z.object({ runId: z.string().uuid() });
export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string };
/**
* Re-enqueue a paused reminder run. The bot picks it up, attaches to the
* existing run row, and only re-tries the rows still in `pending` state.
*
* Validates that the operator owns the underlying reminder + account
* pair and that the run is actually in 'paused' state anything else
* is a no-op (so a stale UI button doesn't double-fire a run).
*/
export async function resumeReminderRunAction(input: {
runId: string;
}): Promise<ResumeReminderRunResult> {
const ip =
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
if (rl.limited) {
return { ok: false, error: "Too many requests. Try again later." };
}
const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: "Invalid runId" };
}
const run = await db.query.reminderRuns.findFirst({
where: (r, { eq: dEq }) => dEq(r.id, parsed.data.runId),
});
if (!run || !run.reminderId) return { ok: false, error: "Run not found" };
const reminder = await db.query.reminders.findFirst({
where: (r, { eq: dEq }) => dEq(r.id, run.reminderId!),
});
if (!reminder) return { ok: false, error: "Reminder not found" };
// Operator must own the account the reminder belongs to.
const owned = await db.query.whatsappAccounts.findFirst({
where: (a, { eq: dEq, and: dAnd }) =>
dAnd(dEq(a.id, reminder.accountId), dEq(a.operatorId, op.id)),
});
if (!owned) return { ok: false, error: "Run not yours" };
if (run.status !== "paused") {
return { ok: false, error: `Cannot resume a ${run.status} run` };
}
await pgNotifyBot({
type: "reminder.resume",
reminderId: reminder.id,
runId: run.id,
});
revalidatePath("/activity");
revalidatePath(`/reminders/${reminder.id}`);
return { ok: true };
}
export type CancelReminderRunResult = { ok: true } | { ok: false; error: string };
/**
* Permanently end a paused run. Remaining `pending` targets become
* `skipped` with a clear "canceled by operator" reason; the run row
* resolves to `partial`. The reminder lifecycle is lifted out of
* 'paused' recurring goes back to 'active' so the next occurrence
* fires; one-off ends.
*/
export async function cancelReminderRunAction(input: {
runId: string;
}): Promise<CancelReminderRunResult> {
const ip =
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
if (rl.limited) {
return { ok: false, error: "Too many requests. Try again later." };
}
const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: "Invalid runId" };
}
const run = await db.query.reminderRuns.findFirst({
where: (r, { eq: dEq }) => dEq(r.id, parsed.data.runId),
});
if (!run || !run.reminderId) return { ok: false, error: "Run not found" };
const reminder = await db.query.reminders.findFirst({
where: (r, { eq: dEq }) => dEq(r.id, run.reminderId!),
});
if (!reminder) return { ok: false, error: "Reminder not found" };
const owned = await db.query.whatsappAccounts.findFirst({
where: (a, { eq: dEq, and: dAnd }) =>
dAnd(dEq(a.id, reminder.accountId), dEq(a.operatorId, op.id)),
});
if (!owned) return { ok: false, error: "Run not yours" };
if (run.status !== "paused") {
return { ok: false, error: `Cannot cancel a ${run.status} run` };
}
await db.transaction(async (tx) => {
// Pending → skipped with a clear cause.
await tx
.update(reminderRunTargets)
.set({ status: "skipped", error: "canceled by operator" })
.where(eq(reminderRunTargets.runId, run.id));
await tx
.update(reminderRuns)
.set({
status: "partial",
errorSummary:
"Canceled by operator before all groups received the message.",
})
.where(eq(reminderRuns.id, run.id));
// Lift the reminder out of 'paused'. Recurring goes back to active
// so the next occurrence can fire; one-off has no future occurrence.
await tx
.update(reminders)
.set({
status: reminder.scheduleKind === "recurring" ? "active" : "inactive",
updatedAt: new Date(),
})
.where(eq(reminders.id, reminder.id));
});
revalidatePath("/activity");
revalidatePath(`/reminders/${reminder.id}`);
return { ok: true };
}

View File

@ -1,192 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const {
requireAdminMock,
findUserMock,
findManyAdminsMock,
insertReturningMock,
updateMock,
deleteMock,
checkRateLimitMock,
revalidateMock,
} = vi.hoisted(() => ({
requireAdminMock: vi.fn(),
findUserMock: vi.fn(),
findManyAdminsMock: vi.fn(),
insertReturningMock: vi.fn(),
updateMock: vi.fn(),
deleteMock: vi.fn(),
checkRateLimitMock: vi.fn(),
revalidateMock: vi.fn(),
}));
vi.mock("@/lib/auth", async () => {
const actual = await vi.importActual<typeof import("@/lib/auth")>("@/lib/auth");
return {
...actual,
requireAdmin: () => requireAdminMock(),
};
});
vi.mock("@/lib/db", () => ({
db: {
query: {
operators: {
findFirst: (...a: unknown[]) => findUserMock(...a),
findMany: (...a: unknown[]) => findManyAdminsMock(...a),
},
},
insert: () => ({ values: () => ({ returning: async () => insertReturningMock() }) }),
update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }) }),
delete: () => ({ where: async (...a: unknown[]) => deleteMock(...a) }),
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
}));
vi.mock("next/cache", () => ({ revalidatePath: revalidateMock }));
vi.mock("next/headers", () => ({
headers: async () => ({ get: () => "127.0.0.1" }),
}));
beforeEach(() => {
requireAdminMock.mockReset();
findUserMock.mockReset();
findManyAdminsMock.mockReset();
insertReturningMock.mockReset();
updateMock.mockReset();
deleteMock.mockReset();
checkRateLimitMock.mockReset();
revalidateMock.mockReset();
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
});
const ADMIN = {
id: "11111111-1111-1111-1111-111111111111",
username: "admin",
role: "admin" as const,
};
const OTHER_ADMIN = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice" };
const USER = { ...ADMIN, id: "33333333-3333-3333-3333-333333333333", username: "bob", role: "user" as const };
import {
createUserAction,
setUserRoleAction,
resetUserPasswordAction,
deleteUserAction,
} from "./users";
describe("createUserAction", () => {
it("admin can create a user with role 'user'", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
insertReturningMock.mockResolvedValue([{ id: USER.id }]);
const r = await createUserAction({
username: "bob",
password: "longpw1",
role: "user",
});
expect(r).toEqual({ ok: true, userId: USER.id });
});
it("rejects username/password under length limits", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
const r = await createUserAction({ username: "a", password: "shortpw", role: "user" });
expect(r.ok).toBe(false);
});
});
describe("setUserRoleAction — self-demote guard", () => {
it("admin demoting themselves is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(ADMIN);
const r = await setUserRoleAction({ userId: ADMIN.id, role: "user" });
expect(r).toEqual({
ok: false,
error: "You can't demote your own account.",
});
expect(updateMock).not.toHaveBeenCalled();
});
it("admin demoting another admin is allowed when others remain", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(OTHER_ADMIN);
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); // 2 admins
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
expect(r).toEqual({ ok: true });
});
it("admin demoting the last remaining admin is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(OTHER_ADMIN);
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // only OTHER_ADMIN is an admin
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/last admin/i);
});
});
describe("deleteUserAction", () => {
it("admin deleting themselves is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(ADMIN);
const r = await deleteUserAction({ userId: ADMIN.id });
expect(r).toEqual({ ok: false, error: "You can't delete your own account." });
expect(deleteMock).not.toHaveBeenCalled();
});
it("admin deleting another user is allowed", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]);
const r = await deleteUserAction({ userId: USER.id });
expect(r).toEqual({ ok: true });
});
it("admin deleting the last admin is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(OTHER_ADMIN);
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // 1 admin total
const r = await deleteUserAction({ userId: OTHER_ADMIN.id });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/last admin/i);
});
});
describe("resetUserPasswordAction", () => {
it("admin can reset another user's password", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "alpha7!" });
expect(r).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalled();
});
it("rejects too-short passwords", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "ab1" });
expect(r.ok).toBe(false);
});
it("rejects letters-only passwords (no number or symbol)", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({
userId: USER.id,
newPassword: "abcdefghij",
});
expect(r).toEqual({
ok: false,
error: "Password must mix letters with numbers or symbols.",
});
});
it("rejects digits-only passwords", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({
userId: USER.id,
newPassword: "1234567890",
});
expect(r.ok).toBe(false);
});
});

View File

@ -1,139 +0,0 @@
"use server";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { operators } from "@cmbot/db";
import { db } from "@/lib/db";
import { requireAdmin } from "@/lib/auth";
import { checkRateLimit } from "@/lib/rate-limit";
import { validatePassword } from "@/lib/password-policy";
const MAX_FIELD_LEN = 256;
async function rateLimit(key: string): Promise<{ limited: boolean }> {
const h = await headers();
const ip =
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
return checkRateLimit(`${key}:${ip}`, { max: 5, windowSec: 60 });
}
export type CreateUserResult =
| { ok: true; userId: string }
| { ok: false; error: string };
export async function createUserAction(input: {
username: string;
password: string;
role: "admin" | "user";
}): Promise<CreateUserResult> {
await requireAdmin();
const rl = await rateLimit("create-user");
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
const u = input.username.trim();
if (u.length < 3 || u.length > MAX_FIELD_LEN) {
return { ok: false, error: "Username must be 3..256 chars." };
}
const pwCheck = validatePassword(input.password);
if (!pwCheck.ok) return pwCheck;
if (input.role !== "admin" && input.role !== "user") {
return { ok: false, error: "Role must be admin or user." };
}
const hash = await bcrypt.hash(input.password, 12);
const [row] = await db
.insert(operators)
.values({
username: u,
passwordHash: hash,
displayName: u,
role: input.role,
defaultTimezone: "Asia/Kuala_Lumpur",
})
.returning({ id: operators.id });
revalidatePath("/settings/users");
return { ok: true, userId: row!.id };
}
export type SetRoleResult = { ok: true } | { ok: false; error: string };
export async function setUserRoleAction(input: {
userId: string;
role: "admin" | "user";
}): Promise<SetRoleResult> {
const me = await requireAdmin();
if (input.userId === me.id && input.role !== "admin") {
return { ok: false, error: "You can't demote your own account." };
}
const target = await db.query.operators.findFirst({
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
});
if (!target) return { ok: false, error: "User not found." };
// If we're demoting an admin, make sure at least one admin remains.
if (target.role === "admin" && input.role !== "admin") {
const admins = await db.query.operators.findMany({
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
});
if (admins.length <= 1) {
return { ok: false, error: "Can't demote the last admin. Promote another user first." };
}
}
await db
.update(operators)
.set({ role: input.role })
.where(eq(operators.id, input.userId));
revalidatePath("/settings/users");
return { ok: true };
}
export type DeleteUserResult = { ok: true } | { ok: false; error: string };
export async function deleteUserAction(input: {
userId: string;
}): Promise<DeleteUserResult> {
const me = await requireAdmin();
if (input.userId === me.id) {
return { ok: false, error: "You can't delete your own account." };
}
const target = await db.query.operators.findFirst({
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
});
if (!target) return { ok: false, error: "User not found." };
if (target.role === "admin") {
const admins = await db.query.operators.findMany({
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
});
if (admins.length <= 1) {
return { ok: false, error: "Can't delete the last admin. Promote another user first." };
}
}
await db.delete(operators).where(eq(operators.id, input.userId));
revalidatePath("/settings/users");
return { ok: true };
}
export type ResetPasswordResult = { ok: true } | { ok: false; error: string };
export async function resetUserPasswordAction(input: {
userId: string;
newPassword: string;
}): Promise<ResetPasswordResult> {
await requireAdmin();
const rl = await rateLimit("reset-password");
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
const pwCheck = validatePassword(input.newPassword);
if (!pwCheck.ok) return pwCheck;
const target = await db.query.operators.findFirst({
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
});
if (!target) return { ok: false, error: "User not found." };
const hash = await bcrypt.hash(input.newPassword, 12);
await db
.update(operators)
.set({ passwordHash: hash })
.where(eq(operators.id, input.userId));
revalidatePath("/settings/users");
return { ok: true };
}

View File

@ -1,104 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { ChevronRightIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteAccountAction } from "@/actions/accounts";
interface DeleteAccountCardProps {
accountId: string;
accountLabel: string;
}
export function DeleteAccountCard({
accountId,
accountLabel,
}: DeleteAccountCardProps) {
const [open, setOpen] = useState(false);
const [pending, start] = useTransition();
function confirm() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await deleteAccountAction(fd);
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Card
role="button"
tabIndex={0}
aria-label="Delete account"
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
>
<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>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this account permanently?</DialogTitle>
<DialogDescription>
<strong>{accountLabel}</strong> will be removed along with its
synced groups, scheduled reminders, and all run history. This
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
size="sm"
disabled={pending}
onClick={confirm}
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<Trash2Icon className="size-4" />
)}
Yes, delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -4,6 +4,7 @@ import {
ArrowLeftIcon, ArrowLeftIcon,
SearchIcon, SearchIcon,
UsersIcon, UsersIcon,
RefreshCwIcon,
Users2Icon, Users2Icon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -15,7 +16,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listGroupsForAccount } from "@/lib/queries"; import { listGroupsForAccount } from "@/lib/queries";
import { RefreshGroupsClient } from "./refresh-groups-client";
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -57,7 +57,13 @@ export default async function GroupsListPage({ params, searchParams }: Props) {
</Badge> </Badge>
</div> </div>
<RefreshGroupsClient accountId={account.id} /> {/* 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> </div>
{/* Search */} {/* Search */}

View File

@ -1,68 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2Icon, RefreshCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEvents } from "@/hooks/use-events";
import { syncGroupsAction } from "@/actions/accounts";
interface RefreshGroupsClientProps {
accountId: string;
}
/**
* Two-stage refresh button:
* 1. Click server action pgNotifies the bot to start a sync.
* 2. Bot finishes emits `groups.synced` over SSE router.refresh()
* re-fetches the page so the new rows appear without the operator
* having to reload manually.
*
* The button stays in its "syncing" state until either the
* `groups.synced` event arrives for this account or 15 s pass (so a
* disconnected bot doesn't strand the spinner forever).
*/
export function RefreshGroupsClient({ accountId }: RefreshGroupsClientProps) {
const router = useRouter();
const [pending, start] = useTransition();
const [waiting, setWaiting] = useState(false);
useEvents({
"groups.synced": (data) => {
if (data.accountId !== accountId) return;
setWaiting(false);
router.refresh();
},
});
function trigger() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await syncGroupsAction(fd);
setWaiting(true);
// Belt-and-braces: if the bot is unreachable or the SSE channel
// drops, drop the spinner after 15 s instead of leaving it stuck.
window.setTimeout(() => setWaiting(false), 15_000);
});
}
const busy = pending || waiting;
return (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={busy}
onClick={trigger}
>
{busy ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
{busy ? "Syncing…" : "Refresh Groups"}
</Button>
);
}

View File

@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
UsersIcon, UsersIcon,
Trash2Icon,
ArrowLeftIcon, ArrowLeftIcon,
SmartphoneIcon, SmartphoneIcon,
CalendarIcon, CalendarIcon,
@ -9,6 +10,7 @@ import {
DatabaseIcon, DatabaseIcon,
PencilIcon, PencilIcon,
PowerIcon, PowerIcon,
PowerOffIcon,
ChevronRightIcon, ChevronRightIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -18,12 +20,23 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AccountStatusBadge } from "@/components/account-status-badge"; import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries"; import { getAccount } from "@/lib/queries";
import { pairAccountAction } from "@/actions/accounts"; import {
import { DeleteAccountCard } from "./delete-account-card"; unpairAccountAction,
import { UnpairAccountCard } from "./unpair-account-card"; pairAccountAction,
deleteAccountAction,
} from "@/actions/accounts";
interface AccountDetailPageProps { interface AccountDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -143,11 +156,102 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</Card> </Card>
</Link> </Link>
<UnpairAccountCard accountId={account.id} accountLabel={account.label} /> {/* 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>
</> </>
)} )}
<DeleteAccountCard accountId={account.id} accountLabel={account.label} /> {/* 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> </div>
<Card> <Card>

View File

@ -1,102 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { ChevronRightIcon, Loader2Icon, PowerOffIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { unpairAccountAction } from "@/actions/accounts";
interface UnpairAccountCardProps {
accountId: string;
accountLabel: string;
}
export function UnpairAccountCard({
accountId,
accountLabel,
}: UnpairAccountCardProps) {
const [open, setOpen] = useState(false);
const [pending, start] = useTransition();
function confirm() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await unpairAccountAction(fd);
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Card
role="button"
tabIndex={0}
aria-label="Unpair WhatsApp"
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<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>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair this account?</DialogTitle>
<DialogDescription>
<strong>{accountLabel}</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>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
size="sm"
disabled={pending}
onClick={confirm}
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PowerOffIcon className="size-4" />
)}
Yes, unpair
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -6,14 +6,21 @@ import {
ArchiveRestoreIcon, ArchiveRestoreIcon,
CheckCircle2Icon, CheckCircle2Icon,
MinusCircleIcon, MinusCircleIcon,
PauseCircleIcon,
PlayIcon,
Trash2Icon, Trash2Icon,
XCircleIcon, XCircleIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { import {
Table, Table,
TableBody, TableBody,
@ -29,11 +36,11 @@ import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries"; import { listActivityRuns } from "@/lib/queries";
import { import {
archiveRunAction, archiveRunAction,
clearHistoryAction,
deleteRunAction, deleteRunAction,
unarchiveRunAction, unarchiveRunAction,
} from "@/actions/history"; } from "@/actions/history";
import { SwipeableRow } from "@/components/swipeable-row"; import { SwipeableRow } from "@/components/swipeable-row";
import { ResumeRunButton } from "@/components/activity/resume-run-button";
function relativeTime(date: Date | string): string { function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
@ -55,12 +62,6 @@ const RUN_STATUS_CONFIG: Record<
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
icon: CheckCircle2Icon, icon: CheckCircle2Icon,
}, },
paused: {
label: "Paused",
className:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
icon: PauseCircleIcon,
},
partial: { partial: {
label: "Partial", label: "Partial",
className: className:
@ -96,24 +97,16 @@ function RunStatusBadge({ status }: { status: string }) {
); );
} }
type FilterValue = "success" | "paused" | "failed" | "archived"; type FilterValue = "all" | "success" | "partial" | "failed" | "skipped" | "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "success", label: "Success" }, { value: "success", label: "Success" },
{ value: "paused", label: "Paused" }, { value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" }, { value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
{ value: "archived", label: "Archived" }, { value: "archived", label: "Archived" },
]; ];
// Partial runs (some recipients ok, some failed) surface under BOTH the
// Paused and Failed tabs — the operator wants to see anything that didn't
// fully succeed on either page. Skipped runs collapse into Archived since
// they're effectively "history that the operator chose not to send".
const FILTER_STATUSES: Record<Exclude<FilterValue, "archived">, string[]> = {
success: ["success"],
paused: ["paused", "partial"],
failed: ["failed", "partial"],
};
interface PageProps { interface PageProps {
searchParams: Promise<{ filter?: string }>; searchParams: Promise<{ filter?: string }>;
} }
@ -174,42 +167,76 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const sp = await searchParams; const sp = await searchParams;
const filter: FilterValue = const filter: FilterValue =
sp.filter === "success" || sp.filter === "success" ||
sp.filter === "paused" || sp.filter === "partial" ||
sp.filter === "failed" || sp.filter === "failed" ||
sp.filter === "skipped" ||
sp.filter === "archived" sp.filter === "archived"
? sp.filter ? sp.filter
: "success"; : "all";
const showingArchived = filter === "archived"; const showingArchived = filter === "archived";
const op = await getSeededOperator(); const op = await getSeededOperator();
const runs = await listActivityRuns(op.id, { archived: showingArchived }); const runs = await listActivityRuns(op.id, { archived: showingArchived });
const filtered = const filtered =
filter === "archived" filter === "all" || filter === "archived"
? runs ? runs
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status)); : runs.filter((r) => r.status === filter);
const hasAny = runs.length > 0; const hasAny = runs.length > 0;
return ( return (
<PageShell title="Activity"> <PageShell
{/* Filter tabs span the full row and wrap onto a second line when the title="Activity"
viewport can't fit them all. Each trigger has a small basis so they action={
share space evenly while still keeping a readable label on mobile. */} 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}> <Tabs value={filter}>
<TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1"> <div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{FILTER_TABS.map(({ value, label }) => ( <TabsList>
<TabsTrigger {FILTER_TABS.map(({ value, label }) => (
key={value} <TabsTrigger key={value} value={value} asChild>
value={value} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
asChild <Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
className="h-8 grow basis-20" {label}
> </Link>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} </TabsTrigger>
<Link href={`/activity?filter=${value}` as any}> ))}
{label} </TabsList>
</Link> </div>
</TabsTrigger>
))}
</TabsList>
</Tabs> </Tabs>
{filtered.length > 0 ? ( {filtered.length > 0 ? (
@ -327,9 +354,6 @@ export default async function ActivityPage({ searchParams }: PageProps) {
</TableCell> </TableCell>
<TableCell className="text-right pr-2 whitespace-nowrap"> <TableCell className="text-right pr-2 whitespace-nowrap">
<div className="inline-flex items-center gap-0.5"> <div className="inline-flex items-center gap-0.5">
{run.status === "paused" && (
<ResumeRunButton runId={run.id} />
)}
<form <form
action={ action={
isArchived ? unarchiveRunAction : archiveRunAction isArchived ? unarchiveRunAction : archiveRunAction
@ -377,7 +401,11 @@ export default async function ActivityPage({ searchParams }: PageProps) {
<EmptyState <EmptyState
icon={ActivityIcon} icon={ActivityIcon}
title={ title={
showingArchived ? "No archived runs." : `No ${filter} runs yet.` filter === "all"
? "No activity yet."
: showingArchived
? "No archived runs."
: `No ${filter} runs yet.`
} }
description={ description={
hasAny hasAny

View File

@ -1,15 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
// Without these, `next build`'s "Collecting page data" pass invokes
// the GET handler in the build container — which has no
// DATABASE_URL — and the env access throws ZodError, killing the
// docker build. Marking the route force-dynamic + nodejs runtime
// tells Next to skip the build-time call and only run at request
// time.
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
interface RouteContext { interface RouteContext {
params: Promise<{ accountId: string }>; params: Promise<{ accountId: string }>;
} }

View File

@ -4,14 +4,12 @@ import { ThemeProvider } from "@/components/theme-provider";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { NotificationManager } from "@/components/notification-manager"; import { NotificationManager } from "@/components/notification-manager";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { getCurrentUser } from "@/lib/auth";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "cm WhatsApp Bot", title: "cm WhatsApp Bot",
description: "Self-hosted WhatsApp reminder bot", description: "Self-hosted WhatsApp reminder bot",
applicationName: "cm WhatsApp Bot", applicationName: "cm WhatsApp Bot",
robots: { index: false, follow: false },
// PWA wiring: the manifest comes from the dynamic route at // PWA wiring: the manifest comes from the dynamic route at
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is // src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
// emitted from public/, and `appleWebApp.capable` lets iOS treat the // emitted from public/, and `appleWebApp.capable` lets iOS treat the
@ -34,13 +32,7 @@ export const viewport: Viewport = {
], ],
}; };
export default async function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
// Pass the role into AppShell so the nav can hide admin-only entries
// for the 'user' role. On /login getCurrentUser returns null and
// AppShell short-circuits to the bare header anyway.
const me = await getCurrentUser();
const role = me?.role ?? null;
const username = me?.username ?? null;
return ( return (
// `suppressHydrationWarning` here is for *attribute* differences only. // `suppressHydrationWarning` here is for *attribute* differences only.
// Two sources legitimately mutate <html>/<body> attributes after the // Two sources legitimately mutate <html>/<body> attributes after the
@ -53,7 +45,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<html lang="en" suppressHydrationWarning className={GeistSans.className}> <html lang="en" suppressHydrationWarning className={GeistSans.className}>
<body suppressHydrationWarning> <body suppressHydrationWarning>
<ThemeProvider> <ThemeProvider>
<AppShell role={role} username={username}>{children}</AppShell> <AppShell>{children}</AppShell>
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
{/* SSE → browser notification bridge. Renders no DOM. */} {/* SSE → browser notification bridge. Renders no DOM. */}
<NotificationManager /> <NotificationManager />

View File

@ -1,101 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2Icon, LockIcon, HelpCircleIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { loginAction } from "@/actions/auth";
export function LoginFormClient({ next }: { next: string }) {
const [pending, start] = useTransition();
const [error, setError] = useState<string | null>(null);
function handle(formData: FormData) {
formData.append("next", next);
start(async () => {
setError(null);
const r = await loginAction(formData);
// On success, the action redirects (no return). If we land here,
// something failed and `r` is the error shape.
if (r && !r.ok) setError(r.error);
});
}
return (
<form action={handle} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
autoComplete="username"
autoFocus
required
maxLength={256}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
maxLength={256}
/>
</div>
{error && (
<div className="text-xs text-destructive">{error}</div>
)}
<Button type="submit" disabled={pending} className="w-full gap-2">
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<LockIcon className="size-4" />
)}
Sign in
</Button>
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="link"
size="sm"
className="w-full text-xs text-muted-foreground hover:text-foreground"
>
<HelpCircleIcon className="size-3.5" />
Forgot password?
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Forgot your password?</DialogTitle>
<DialogDescription>
Contact your administrator to reset it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" size="sm">
Got it
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
);
}

View File

@ -1,25 +0,0 @@
import { Card, CardContent } from "@/components/ui/card";
import { LoginFormClient } from "./login-form-client";
export const metadata = {
title: "Sign in",
};
interface PageProps {
searchParams: Promise<{ next?: string }>;
}
export default async function LoginPage({ searchParams }: PageProps) {
const sp = await searchParams;
const next = sp.next ?? "/";
return (
<div className="flex items-center justify-center px-4 py-8 min-h-[calc(100dvh-3.5rem)]">
<Card className="w-full max-w-sm">
<CardContent className="pt-6">
<LoginFormClient next={next} />
</CardContent>
</Card>
</div>
);
}

View File

@ -182,9 +182,9 @@ export default async function DashboardPage() {
/> />
<StatCard <StatCard
title="Reminders" title="Reminders"
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.inactiveReminders} / ${stats.totalReminders}`} value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.endedReminders} / ${stats.totalReminders}`}
icon={BellIcon} icon={BellIcon}
description="Active / Paused / Inactive / Total" description="Active / Paused / Ended / Total"
href="/reminders" href="/reminders"
/> />
</div> </div>
@ -217,7 +217,7 @@ export default async function DashboardPage() {
themselves are not affected. themselves are not affected.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter showCloseButton>
<form action={clearHistoryAction}> <form action={clearHistoryAction}>
<Button type="submit" variant="destructive" size="sm"> <Button type="submit" variant="destructive" size="sm">
<Trash2Icon /> <Trash2Icon />

View File

@ -41,9 +41,9 @@ describe("ActionsBar — card visibility by status", () => {
expect(html).not.toMatch(/aria-label="Pause"/); expect(html).not.toMatch(/aria-label="Pause"/);
}); });
it("inactive: shows Restart and Delete (no Pause)", () => { it("ended: shows Restart and Delete (no Pause)", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="inactive" isRecurring={false} />, <ActionsBar reminderId="r-1" status="ended" isRecurring={false} />,
); );
expect(html).toMatch(/aria-label="Restart"/); expect(html).toMatch(/aria-label="Restart"/);
expect(html).toMatch(/aria-label="Delete"/); expect(html).toMatch(/aria-label="Delete"/);

View File

@ -38,7 +38,7 @@ interface ActionsBarProps {
* on desktop, stacked on mobile: * on desktop, stacked on mobile:
* *
* - Pause only when status === "active" * - Pause only when status === "active"
* - Restart when status is "paused" or "inactive" * - Restart when status is "paused" or "ended"
* - Delete always available (terminal) * - Delete always available (terminal)
* *
* Each Dialog confirms before firing the corresponding server action. * Each Dialog confirms before firing the corresponding server action.
@ -46,7 +46,7 @@ interface ActionsBarProps {
*/ */
export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) { export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) {
const canPause = status === "active"; const canPause = status === "active";
const canRestart = status === "paused" || status === "inactive"; const canRestart = status === "paused" || status === "ended";
return ( return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
@ -177,7 +177,7 @@ function ConfirmCard(props: ConfirmCardProps) {
{error} {error}
</p> </p>
)} )}
<DialogFooter> <DialogFooter showCloseButton>
<form <form
action={async (fd: FormData) => { action={async (fd: FormData) => {
setSubmitting(true); setSubmitting(true);

View File

@ -30,7 +30,6 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries"; import { getReminderWithRuns } from "@/lib/queries";
import { PausedRunBanner } from "@/components/reminder-detail/paused-run-banner";
import { ActionsBar } from "./actions-bar"; import { ActionsBar } from "./actions-bar";
function formatWhen(date: Date | null, tz: string): string { function formatWhen(date: Date | null, tz: string): string {
@ -48,7 +47,7 @@ function formatWhen(date: Date | null, tz: string): string {
const STATUS_STYLES: Record<string, string> = { const STATUS_STYLES: Record<string, string> = {
active: active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
inactive: ended:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent", "bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused: paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent", "bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
@ -120,22 +119,6 @@ export default async function ReminderDetailPage({ params }: Props) {
</p> </p>
</div> </div>
{/* Most recent paused run gets a banner — Resume / Cancel are
one click away. Pause notifications deep-link here. */}
{(() => {
const pausedRun = runs.find((r) => r.status === "paused");
if (!pausedRun) return null;
return (
<PausedRunBanner
runId={pausedRun.id}
sent={pausedRun.sent}
total={pausedRun.total}
windowEndHour={reminder.deliveryWindowEndHour}
timezone={reminder.timezone}
/>
);
})()}
<Separator /> <Separator />
{/* Name click to edit. Required field, the operator's {/* Name click to edit. Required field, the operator's
@ -230,28 +213,12 @@ export default async function ReminderDetailPage({ params }: Props) {
</p> </p>
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p> <p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
{reminder.rrule && reminder.scheduledAt ? ( {reminder.rrule && reminder.scheduledAt ? (
// Single-line summary with mid-string ellipsis. Long <p className="flex items-center gap-1.5 text-xs text-primary/80">
// descriptions ("Every month on days 4, 6, 11, 13, 18, <RepeatIcon className="size-3 shrink-0" />
// 20 +2 more at 11:32") truncate cleanly via `truncate` {describeRecurrence(
// (overflow-hidden + text-ellipsis + whitespace-nowrap)
// so the card height stays predictable. The native
// browser tooltip on `title` lets the operator read
// the full string without leaving the page; the edit
// form is the canonical full view.
<p
className="flex items-center gap-1.5 text-xs text-primary/80"
title={describeRecurrence(
specFromRrule(reminder.rrule), specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)} )}
>
<RepeatIcon className="size-3 shrink-0" />
<span className="truncate min-w-0">
{describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
</span>
</p> </p>
) : ( ) : (
<p className="text-xs text-muted-foreground">One-off</p> <p className="text-xs text-muted-foreground">One-off</p>

View File

@ -32,7 +32,7 @@ import {
restartReminderAction, restartReminderAction,
} from "@/actions/reminders"; } from "@/actions/reminders";
type FilterValue = "all" | "active" | "inactive" | "paused"; type FilterValue = "all" | "active" | "ended" | "paused";
function formatWhen(date: Date | null, tz: string): string { function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—"; if (!date) return "—";
@ -48,7 +48,7 @@ function formatWhen(date: Date | null, tz: string): string {
const STATUS_STYLES: Record<string, string> = { const STATUS_STYLES: Record<string, string> = {
active: active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
inactive: ended:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent", "bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused: paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent", "bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
@ -104,7 +104,7 @@ function StatusPill({ status }: { status: string }) {
const FILTER_TABS: { value: FilterValue; label: string }[] = [ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
{ value: "active", label: "Active" }, { value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" }, { value: "ended", label: "Ended" },
{ value: "paused", label: "Paused" }, { value: "paused", label: "Paused" },
]; ];
@ -127,7 +127,7 @@ interface PageProps {
export default async function RemindersPage({ searchParams }: PageProps) { export default async function RemindersPage({ searchParams }: PageProps) {
const sp = await searchParams; const sp = await searchParams;
const status: FilterValue = const status: FilterValue =
sp.filter === "active" || sp.filter === "inactive" || sp.filter === "paused" sp.filter === "active" || sp.filter === "ended" || sp.filter === "paused"
? sp.filter ? sp.filter
: "all"; : "all";
// Sort is now fixed to `created_desc`. Reordering on every status flip // Sort is now fixed to `created_desc`. Reordering on every status flip
@ -225,7 +225,7 @@ export default async function RemindersPage({ searchParams }: PageProps) {
{visible.map((reminder) => { {visible.map((reminder) => {
const canPause = reminder.status === "active"; const canPause = reminder.status === "active";
const canRestart = const canRestart =
reminder.status === "paused" || reminder.status === "inactive"; reminder.status === "paused" || reminder.status === "ended";
const cardBody = ( const cardBody = (
<Link <Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -247,30 +247,15 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</p> </p>
</div> </div>
{/* Right meta column. Capped at ~14rem so a long <div className="shrink-0 text-right space-y-1">
recurrence description ("Every month on days <div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
4, 6, 7, 11, 13, 14 +6 more at 11:32") can't
starve the reminder name on the left. min-w-0
+ truncate on each span ellipsises overflow
inside the cap. Title tooltip preserves the
full text on hover. */}
<div className="min-w-0 max-w-[34%] sm:max-w-[9.5rem] text-right space-y-1">
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" /> <CalendarIcon className="size-3 shrink-0" />
<span className="truncate"> <span>{formatWhen(reminder.scheduledAt, tz)}</span>
{formatWhen(reminder.scheduledAt, tz)}
</span>
</div> </div>
{reminder.rrule && reminder.scheduledAt ? ( {reminder.rrule && reminder.scheduledAt ? (
<div <div className="flex items-center justify-end gap-1 text-xs text-primary/80">
className="flex min-w-0 items-center justify-end gap-1 text-xs text-primary/80"
title={describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
>
<RepeatIcon className="size-3 shrink-0" /> <RepeatIcon className="size-3 shrink-0" />
<span className="truncate"> <span>
{describeRecurrence( {describeRecurrence(
specFromRrule(reminder.rrule), specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
@ -279,9 +264,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</div> </div>
) : null} ) : null}
{reminder.groupCount > 0 && ( {reminder.groupCount > 0 && (
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground"> <div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" /> <UsersIcon className="size-3 shrink-0" />
<span className="truncate"> <span>
{reminder.groupCount}{" "} {reminder.groupCount}{" "}
{reminder.groupCount === 1 ? "group" : "groups"} {reminder.groupCount === 1 ? "group" : "groups"}
</span> </span>

View File

@ -1,5 +0,0 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return { rules: [{ userAgent: "*", disallow: "/" }] };
}

View File

@ -7,7 +7,6 @@ import { PageShell } from "@/components/page-shell";
export default async function SettingsPage() { export default async function SettingsPage() {
const op = await getSeededOperator(); const op = await getSeededOperator();
const isAdmin = op.role === "admin";
return ( return (
<PageShell title="Settings" narrow> <PageShell title="Settings" narrow>
<Card> <Card>
@ -15,15 +14,13 @@ export default async function SettingsPage() {
<CardTitle>Operator</CardTitle> <CardTitle>Operator</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
<Row label="Username" value={op.username} mono /> <Row label="Display name" value={op.displayName} />
<Separator />
<Row label="Operator ID" value={String(op.telegramUserId)} mono />
<Separator /> <Separator />
<Row label="Default timezone" value={op.defaultTimezone} mono /> <Row label="Default timezone" value={op.defaultTimezone} mono />
{isAdmin && ( <Separator />
<> <Row label="Role" value={op.role} mono />
<Separator />
<Row label="Role" value={op.role} mono />
</>
)}
</CardContent> </CardContent>
</Card> </Card>
@ -50,6 +47,10 @@ export default async function SettingsPage() {
<ThemeToggle /> <ThemeToggle />
</CardContent> </CardContent>
</Card> </Card>
<p className="text-center text-xs text-muted-foreground">
cm WhatsApp Bot · self-hosted
</p>
</PageShell> </PageShell>
); );
} }

View File

@ -1,95 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2Icon, UserPlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createUserAction } from "@/actions/users";
export function AddUserFormClient() {
const [pending, start] = useTransition();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState(false);
function submit() {
start(async () => {
setError(null);
setOk(false);
const r = await createUserAction({
username: username.trim(),
password,
role,
});
if (!r.ok) {
setError(r.error);
return;
}
setUsername("");
setPassword("");
setRole("user");
setOk(true);
});
}
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="new-username">Username</Label>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
maxLength={256}
placeholder="alice"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">Password</Label>
<Input
id="new-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
maxLength={256}
placeholder="≥6 chars · letters + number/symbol"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-role">Role</Label>
<select
id="new-role"
value={role}
onChange={(e) => setRole(e.target.value === "admin" ? "admin" : "user")}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<div className="flex items-center justify-end gap-2">
{error && <p className="mr-auto text-xs text-destructive">{error}</p>}
{ok && (
<p className="mr-auto text-xs text-emerald-600 dark:text-emerald-400">
User created.
</p>
)}
<Button type="button" size="sm" disabled={pending} onClick={submit}>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<UserPlusIcon className="size-4" />
)}
Add user
</Button>
</div>
</div>
);
}

View File

@ -1,62 +0,0 @@
import { requireAdmin } from "@/lib/auth";
import { db } from "@/lib/db";
import { PageShell } from "@/components/page-shell";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { UserRowClient } from "./user-row-client";
import { AddUserFormClient } from "./add-user-form-client";
export default async function UsersPage() {
const me = await requireAdmin();
const rows = await db.query.operators.findMany({
orderBy: (o, { asc }) => [asc(o.username)],
});
const adminCount = rows.filter((r) => r.role === "admin").length;
return (
<PageShell title="Users">
<Card>
<CardHeader>
<CardTitle>Add user</CardTitle>
<CardDescription>
Create a sign-in account. Passwords must be at least 10
characters.
</CardDescription>
</CardHeader>
<CardContent>
<AddUserFormClient />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>All users</CardTitle>
<CardDescription>
Promote a user to admin, demote them back, reset their
password, or delete the account. The last admin cannot be
demoted or deleted.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{rows.map((u) => (
<UserRowClient
key={u.id}
user={{
id: u.id,
username: u.username,
role: u.role === "admin" ? "admin" : "user",
}}
isSelf={u.id === me.id}
isLastAdmin={u.role === "admin" && adminCount === 1}
/>
))}
</CardContent>
</Card>
</PageShell>
);
}

View File

@ -1,197 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import {
Loader2Icon,
Trash2Icon,
KeyIcon,
ArrowUpIcon,
ArrowDownIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import {
setUserRoleAction,
resetUserPasswordAction,
deleteUserAction,
} from "@/actions/users";
import { validatePassword } from "@/lib/password-policy";
interface UserRowClientProps {
user: { id: string; username: string; role: "admin" | "user" };
isSelf: boolean;
/** True when this row is the only remaining admin. Disables demote+delete. */
isLastAdmin: boolean;
}
export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps) {
const [pending, start] = useTransition();
const [error, setError] = useState<string | null>(null);
const [resetVisible, setResetVisible] = useState(false);
const [resetPw, setResetPw] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
function run<T extends { ok: boolean; error?: string }>(promise: Promise<T>) {
start(async () => {
setError(null);
const r = await promise;
if (!r.ok) setError(r.error ?? "Failed");
});
}
const isAdmin = user.role === "admin";
// The role-toggle button is disabled if:
// - flipping yourself (admin self-demotion is rejected server-side too)
// - this row is the last remaining admin and would become a user
const roleToggleDisabled = pending || isSelf || (isAdmin && isLastAdmin);
const deleteDisabled = pending || isSelf || isLastAdmin;
return (
<div className="flex flex-col gap-3 rounded-lg border p-4">
{/* Row 1 identity: username on the left, role badge + "you"
chip on the right, all on one line. */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<p className="text-sm font-medium truncate">
{user.username}
</p>
{isSelf && (
<span className="text-xs text-muted-foreground shrink-0">you</span>
)}
</div>
<Badge
variant="secondary"
className={
isAdmin
? "shrink-0 bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent"
: "shrink-0 bg-slate-200/60 text-slate-600 dark:bg-slate-700/40 dark:text-slate-300 border-transparent"
}
>
{user.role}
</Badge>
</div>
{/* Row 2 — actions: Promote/Demote, Reset, Delete, right-aligned. */}
<div className="flex flex-wrap justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="ghost"
disabled={roleToggleDisabled}
onClick={() =>
run(
setUserRoleAction({
userId: user.id,
role: isAdmin ? "user" : "admin",
}),
)
}
>
{isAdmin ? (
<ArrowDownIcon className="size-3.5" />
) : (
<ArrowUpIcon className="size-3.5" />
)}
{isAdmin ? "Demote" : "Promote"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending}
onClick={() => setResetVisible((v) => !v)}
>
<KeyIcon className="size-3.5" />
Reset
</Button>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive"
disabled={deleteDisabled}
>
<Trash2Icon className="size-3.5" />
Delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete user @{user.username}?</DialogTitle>
<DialogDescription>
This permanently removes the account. They will be
signed out on their next request and cannot sign in
again. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
size="sm"
disabled={pending}
onClick={() => {
setDeleteOpen(false);
run(deleteUserAction({ userId: user.id }));
}}
>
{pending ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Delete user
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{resetVisible && (
<div className="flex gap-2">
<Input
type="password"
placeholder="New password (≥6 chars · letters + number/symbol)"
value={resetPw}
onChange={(e) => setResetPw(e.target.value)}
maxLength={256}
/>
<Button
type="button"
size="sm"
disabled={pending || !validatePassword(resetPw).ok}
onClick={() => {
run(
resetUserPasswordAction({
userId: user.id,
newPassword: resetPw,
}),
);
setResetPw("");
setResetVisible(false);
}}
>
Save
</Button>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}

View File

@ -1,32 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/actions/reminders", () => ({
resumeReminderRunAction: vi.fn(),
}));
import { ResumeRunButton } from "./resume-run-button";
describe("ResumeRunButton", () => {
it("renders an icon button with aria-label='Resume run'", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" />);
expect(html).toMatch(/aria-label="Resume run"/);
expect(html).toMatch(/lucide-play/);
});
it("uses emerald accent so paused rows clearly offer 'go again'", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" />);
expect(html).toMatch(/text-emerald-700/);
});
it("compact variant uses size=icon-sm so it fits inline in the table", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" variant="compact" />);
// shadcn button forwards size into a data-size attr.
expect(html).toMatch(/data-size="icon-sm"/);
});
it("default variant uses size=sm for a standalone surface", () => {
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" variant="default" />);
expect(html).toMatch(/data-size="sm"/);
});
});

View File

@ -1,54 +0,0 @@
"use client";
import { useTransition, useState } from "react";
import { Loader2Icon, PlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { resumeReminderRunAction } from "@/actions/reminders";
interface ResumeRunButtonProps {
runId: string;
/** Style hint "compact" suits inline rows, "default" suits the
* paused-detail banner which renders its own size already. */
variant?: "compact" | "default";
}
/**
* Small wrapper around resumeReminderRunAction so paused rows in the
* Activity tab can offer "Resume" without each row rolling its own
* useTransition / error handling. Cancel uses the detail banner
* it's the rarer path.
*/
export function ResumeRunButton({ runId, variant = "compact" }: ResumeRunButtonProps) {
const [pending, start] = useTransition();
const [error, setError] = useState<string | null>(null);
const onClick = () =>
start(async () => {
setError(null);
const r = await resumeReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
return (
<div className="inline-flex flex-col items-end gap-0.5">
<Button
type="button"
size={variant === "compact" ? "icon-sm" : "sm"}
variant="ghost"
onClick={onClick}
disabled={pending}
aria-label="Resume run"
className="text-emerald-700 hover:text-emerald-800 dark:text-emerald-400 dark:hover:text-emerald-300"
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PlayIcon className="size-4" />
)}
</Button>
{error && (
<span className="text-[10px] text-destructive whitespace-nowrap">{error}</span>
)}
</div>
);
}

View File

@ -55,7 +55,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("renders a fixed top header that hides on sm+ breakpoints", () => { it("renders a fixed top header that hides on sm+ breakpoints", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<main>page</main> <main>page</main>
</AppShell>, </AppShell>,
); );
@ -66,7 +66,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("brand mark on the left links to /", () => { it("brand mark on the left links to /", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -90,7 +90,7 @@ describe("AppShell — mobile header (SSR)", () => {
for (const c of cases) { for (const c of cases) {
pathnameMock.mockReturnValue(c.path); pathnameMock.mockReturnValue(c.path);
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -104,7 +104,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => { it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
pathnameMock.mockReturnValue("/unknown-route"); pathnameMock.mockReturnValue("/unknown-route");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -115,7 +115,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("menu button on the right uses aria-label='Open menu'", () => { it("menu button on the right uses aria-label='Open menu'", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -134,7 +134,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("renders one nav link per NAV_ITEM, in order", () => { it("renders one nav link per NAV_ITEM, in order", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -157,7 +157,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("marks the active route's link with aria-current='page'", () => { it("marks the active route's link with aria-current='page'", () => {
pathnameMock.mockReturnValue("/reminders"); pathnameMock.mockReturnValue("/reminders");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -174,7 +174,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
// every page. The header uses an exact-match check for "/". // every page. The header uses an exact-match check for "/".
pathnameMock.mockReturnValue("/accounts"); pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -185,7 +185,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("does NOT include a theme toggle in the mobile drawer (per request)", () => { it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -195,7 +195,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("drawer header carries the brand wording and a screen-reader description", () => { it("drawer header carries the brand wording and a screen-reader description", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -221,7 +221,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
it("renders the sidebar nav with every NAV_ITEM", () => { it("renders the sidebar nav with every NAV_ITEM", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -232,22 +232,21 @@ describe("AppShell — desktop sidebar (SSR)", () => {
} }
}); });
it("renders a Sign out button in the sidebar footer", () => { it("keeps the theme toggle in the sidebar footer", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
// Theme toggle was dropped from the shell per request; the footer // The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must
// now carries the Sign out affordance + the signed-in username. // appear in the sidebar (we removed it from the mobile drawer).
expect(html).toContain('aria-label="Sign out"'); expect(html).toContain('data-testid="theme-toggle"');
expect(html).toContain("admin");
}); });
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => { it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
pathnameMock.mockReturnValue("/accounts"); pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -265,7 +264,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
// reader users on a wide-window split-screen don't hear two // reader users on a wide-window split-screen don't hear two
// identical announcements when both are visible. // identical announcements when both are visible.
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell role="admin" username="admin"> <AppShell>
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -274,79 +273,6 @@ describe("AppShell — desktop sidebar (SSR)", () => {
}); });
}); });
// ---------------------------------------------------------------------------
// Role-gated nav (admin panel)
// ---------------------------------------------------------------------------
describe("AppShell — role-based nav filtering", () => {
beforeEach(() => {
pathnameMock.mockReset();
pathnameMock.mockReturnValue("/");
});
it("shows the Admin entry in BOTH the sidebar and drawer when role=admin", () => {
const html = renderToStaticMarkup(
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
expect(html).toContain('href="/settings/users"');
// A label appears in both the sidebar and the drawer; either way the
// count must be >=2 (sidebar copy + drawer copy).
const occurrences = (html.match(/href="\/settings\/users"/g) ?? []).length;
expect(occurrences).toBeGreaterThanOrEqual(2);
});
it("hides the Admin entry from BOTH surfaces when role=user", () => {
const html = renderToStaticMarkup(
<AppShell role="user" username="alice">
<div />
</AppShell>,
);
expect(html).not.toContain('href="/settings/users"');
});
it("hides the Admin entry when role=null (defense-in-depth: unauthenticated)", () => {
pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup(
<AppShell role={null} username={null}>
<div />
</AppShell>,
);
expect(html).not.toContain('href="/settings/users"');
});
it("does NOT hide entries that have no visibleTo restriction for either role", () => {
const adminHtml = renderToStaticMarkup(
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
const userHtml = renderToStaticMarkup(
<AppShell role="user" username="alice">
<div />
</AppShell>,
);
for (const item of NAV_ITEMS) {
if (item.visibleTo) continue;
expect(adminHtml).toContain(`href="${item.href}"`);
expect(userHtml).toContain(`href="${item.href}"`);
}
});
it("/login route renders the bare header — no nav, no drawer, regardless of role", () => {
pathnameMock.mockReturnValue("/login");
const html = renderToStaticMarkup(
<AppShell role={null} username={null}>
<div />
</AppShell>,
);
expect(html).not.toContain("<aside");
expect(html).not.toContain('data-testid="sheet-content"');
expect(html).not.toContain('href="/settings/users"');
expect(html).toContain("WhatsApp Bot");
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// helpers // helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,12 +1,11 @@
"use client"; "use client";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react"; import { MenuIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { logoutAction } from "@/actions/auth";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -15,13 +14,8 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { import { NAV_ITEMS } from "@/components/nav-config";
NAV_ITEMS, import { ThemeToggle } from "@/components/theme-toggle";
navItemsForRole,
pickActiveNavKey,
type NavItem,
type NavRole,
} from "@/components/nav-config";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mobile header (sm:hidden) // Mobile header (sm:hidden)
@ -36,51 +30,8 @@ import {
// waiting for the page content to render. The menu button on the right // 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. // opens a Sheet with the full nav list and the theme toggle.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// --------------------------------------------------------------------------- function MobileHeader() {
// Sign-out button used by both the desktop sidebar footer and the mobile
// drawer footer. Server-action under the hood: clears the session
// cookie and redirects to /login. Disabled while in flight so a
// double-click doesn't fire two redirects.
// ---------------------------------------------------------------------------
function SignOutButton({ username }: { username: string | null }) {
const [pending, start] = useTransition();
return (
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
{username && (
<p className="text-xs text-muted-foreground truncate">
Signed in as <em className="italic font-medium text-foreground">{username}</em>
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
onClick={() => start(() => logoutAction())}
aria-label="Sign out"
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<LogOutIcon className="size-4" />
)}
Sign out
</Button>
</div>
);
}
function MobileHeader({
items,
username,
}: {
items: NavItem[];
username: string | null;
}) {
const pathname = usePathname(); const pathname = usePathname();
const activeKey = pickActiveNavKey(items, pathname);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Close the drawer when the route changes (i.e. the user picked a nav // Close the drawer when the route changes (i.e. the user picked a nav
@ -90,10 +41,6 @@ function MobileHeader({
setOpen(false); setOpen(false);
}, [pathname]); }, [pathname]);
// Use the full list (not the role-filtered one) for the title lookup
// so the page title still shows up correctly when a 'user' role hits
// a route they wouldn't normally see in the nav (e.g. arrives via a
// direct link), even though they can't navigate there from the menu.
const currentItem = NAV_ITEMS.find(({ href }) => const currentItem = NAV_ITEMS.find(({ href }) =>
href === "/" ? pathname === "/" : pathname.startsWith(href), href === "/" ? pathname === "/" : pathname.startsWith(href),
); );
@ -143,10 +90,10 @@ function MobileHeader({
<nav <nav
aria-label="Primary navigation" aria-label="Primary navigation"
className="flex flex-col gap-0.5 p-2" className="flex flex-col gap-0.5 p-2 flex-1"
> >
{items.map(({ key, href, label, icon: Icon }) => { {NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
const active = activeKey === key; const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return ( return (
<Link <Link
key={key} key={key}
@ -170,10 +117,6 @@ function MobileHeader({
); );
})} })}
</nav> </nav>
<div className="mt-auto border-t border-border p-3">
<SignOutButton username={username} />
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</header> </header>
@ -183,15 +126,8 @@ function MobileHeader({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sidebar (desktop only — hidden below sm) // Sidebar (desktop only — hidden below sm)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function Sidebar({ function Sidebar() {
items,
username,
}: {
items: NavItem[];
username: string | null;
}) {
const pathname = usePathname(); const pathname = usePathname();
const activeKey = pickActiveNavKey(items, pathname);
return ( 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"> <aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
@ -214,7 +150,7 @@ function Sidebar({
{/* Nav items */} {/* Nav items */}
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1"> <nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
{items.map(({ key, href, label, icon: Icon }) => { {NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href); const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return ( return (
<Link <Link
@ -236,74 +172,29 @@ function Sidebar({
})} })}
</nav> </nav>
{/* Footer: signed-in user + sign-out */} {/* Footer: theme toggle */}
<div className="border-t border-sidebar-border p-3"> <div className="border-t border-sidebar-border p-3">
<SignOutButton username={username} /> <ThemeToggle />
</div> </div>
</aside> </aside>
); );
} }
// ---------------------------------------------------------------------------
// Bare header for unauthenticated routes (/login). No sidebar, no mobile
// menu, no nav — just the centered brand mark + name. The user explicitly
// asked for nothing else here so the sign-in screen feels like a separate
// surface from the authenticated app.
// ---------------------------------------------------------------------------
function BareHeader() {
return (
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center justify-center border-b border-border bg-background/95 backdrop-blur-sm px-3">
<div 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>
<span className="text-sm font-semibold tracking-tight">
WhatsApp Bot
</span>
</div>
</header>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AppShell — the outer container // AppShell — the outer container
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface AppShellProps { interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
/** Role of the signed-in user, or null when unauthenticated. */
role: NavRole | null;
/** Username of the signed-in user, surfaced in the footer + sign-out hint. */
username: string | null;
} }
export function AppShell({ children, role, username }: AppShellProps) { export function AppShell({ children }: AppShellProps) {
const pathname = usePathname();
const isAuthRoute = pathname === "/login";
if (isAuthRoute) {
return (
<>
<BareHeader />
<main className="min-h-dvh pt-14">{children}</main>
</>
);
}
// Treat unauthenticated render of a protected route (shouldn't happen
// because middleware redirects, but defense-in-depth) as 'user': hides
// the admin-only entries.
const items = navItemsForRole(role ?? "user");
return ( return (
<> <>
{/* Desktop sidebar */} {/* Desktop sidebar */}
<Sidebar items={items} username={username} /> <Sidebar />
{/* Mobile header (single row: brand · title · menu) */} {/* Mobile header (single row: brand · title · menu) */}
<MobileHeader items={items} username={username} /> <MobileHeader />
{/* Main content {/* Main content
Mobile: push down for the h-14 header (56px) plus a small gap Mobile: push down for the h-14 header (56px) plus a small gap

View File

@ -1,119 +0,0 @@
import { describe, it, expect } from "vitest";
import { NAV_ITEMS, navItemsForRole, pickActiveNavKey } from "./nav-config";
describe("navItemsForRole", () => {
it("includes every NAV_ITEM for an admin", () => {
const items = navItemsForRole("admin");
expect(items).toHaveLength(NAV_ITEMS.length);
for (const original of NAV_ITEMS) {
expect(items.find((i) => i.key === original.key)).toBeDefined();
}
});
it("hides admin-only entries for the 'user' role", () => {
const items = navItemsForRole("user");
const keys = items.map((i) => i.key);
expect(keys).not.toContain("admin");
});
it("returns the un-restricted entries (no visibleTo) for the 'user' role", () => {
const items = navItemsForRole("user");
const keys = items.map((i) => i.key);
expect(keys).toEqual(
expect.arrayContaining(["dashboard", "accounts", "reminders", "activity", "settings"]),
);
});
it("admin nav entry routes to /settings/users", () => {
const admin = NAV_ITEMS.find((i) => i.key === "admin");
expect(admin).toBeDefined();
expect(admin!.href).toBe("/settings/users");
expect(admin!.visibleTo).toEqual(["admin"]);
});
});
describe("pickActiveNavKey (longest-match active highlight)", () => {
// Use the real NAV_ITEMS so a future href change doesn't silently
// re-introduce the regression.
const adminItems = navItemsForRole("admin");
const userItems = navItemsForRole("user");
it("highlights ONLY the Admin entry on /settings/users (not Settings too)", () => {
// Repro of the user-reported regression. Naïve startsWith would
// light up both Settings (/settings) and Admin (/settings/users)
// because both prefixes match. The longest-match rule must pick
// the Admin entry alone.
const active = pickActiveNavKey(adminItems, "/settings/users");
expect(active).toBe("admin");
});
it("highlights Settings on /settings exact, with Admin NOT lit", () => {
const active = pickActiveNavKey(adminItems, "/settings");
expect(active).toBe("settings");
});
it("highlights Settings on a subpath that is NOT /settings/users", () => {
// Admin nav is admin-only; this test is just to confirm the
// longest-match still picks Settings when no admin descendant
// claims the path.
const active = pickActiveNavKey(adminItems, "/settings/profile");
expect(active).toBe("settings");
});
it("highlights Dashboard ONLY on '/' exact (not on every route)", () => {
expect(pickActiveNavKey(adminItems, "/")).toBe("dashboard");
expect(pickActiveNavKey(adminItems, "/accounts")).toBe("accounts");
expect(pickActiveNavKey(adminItems, "/reminders/abc-123")).toBe("reminders");
});
it("returns null when nothing matches (e.g. a route hidden from this role)", () => {
// /settings/users isn't visible to a 'user' role, so the helper
// must NOT highlight it as Settings just because /settings is a
// prefix — we'd be claiming an item is active when the user can't
// navigate to it from this nav.
expect(pickActiveNavKey(userItems, "/settings/users")).toBe("settings");
// Neither item's href matches a totally foreign route.
expect(pickActiveNavKey(adminItems, "/elsewhere")).toBeNull();
});
it("does NOT match a sibling that shares a prefix string", () => {
// /settingsfoo is NOT a child of /settings — startsWith would
// mistakenly mark Settings active. The strict descendant check
// (`href + '/'`) prevents that.
expect(pickActiveNavKey(adminItems, "/settingsfoo")).toBeNull();
});
it("each pathname highlights AT MOST one nav key (defense check)", () => {
// Walk a small representative set of routes and confirm we never
// light up two items at once. This is the contract the JSX in
// app-shell.tsx relies on.
const probes = [
"/",
"/accounts",
"/accounts/abc",
"/reminders",
"/reminders/abc",
"/activity",
"/activity?filter=success",
"/settings",
"/settings/users",
"/settings/users/something",
"/login",
"/elsewhere",
];
for (const path of probes) {
const matchCount = adminItems.filter((item) => {
if (item.href === "/") return path === "/";
return path === item.href || path.startsWith(item.href + "/");
}).length;
// If two prefixes both match, pickActiveNavKey must collapse
// them to one — that's the whole point of the helper.
const active = pickActiveNavKey(adminItems, path);
if (matchCount === 0) {
expect(active).toBeNull();
} else {
expect(active).not.toBeNull();
}
}
});
});

View File

@ -1,22 +1,11 @@
import { import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react";
Home,
Smartphone,
Calendar,
Activity,
Settings,
ShieldCheck,
} from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
export type NavRole = "admin" | "user";
export interface NavItem { export interface NavItem {
key: string; key: string;
href: string; href: string;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
/** When set, only roles listed here will see this nav entry. */
visibleTo?: NavRole[];
} }
export const NAV_ITEMS: NavItem[] = [ export const NAV_ITEMS: NavItem[] = [
@ -24,54 +13,5 @@ export const NAV_ITEMS: NavItem[] = [
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone }, { key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar }, { key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
{ key: "activity", href: "/activity", label: "Activity", icon: Activity }, { key: "activity", href: "/activity", label: "Activity", icon: Activity },
{
key: "admin",
href: "/settings/users",
label: "Admin",
icon: ShieldCheck,
visibleTo: ["admin"],
},
{ key: "settings", href: "/settings", label: "Settings", icon: Settings }, { key: "settings", href: "/settings", label: "Settings", icon: Settings },
]; ];
export function navItemsForRole(role: NavRole): NavItem[] {
return NAV_ITEMS.filter(
(item) => item.visibleTo === undefined || item.visibleTo.includes(role),
);
}
/**
* Pick the SINGLE active nav item for a given pathname. Solves the
* "Admin and Settings both highlighted on /settings/users" bug:
* naïve `pathname.startsWith(href)` matches BOTH /settings/users (the
* Admin entry) AND /settings (its parent). Two items lit up at once
* looks broken.
*
* Rules:
* - The Dashboard ('/') item only matches an exact pathname match;
* otherwise it would shadow every other route.
* - All other items match either an exact pathname or a strict
* descendant path (`<href>/...`). `pathname.startsWith(href)` on
* its own would also match `/settingsfoo`, which is wrong.
* - When two non-root items both match (parent + child), pick the
* LONGEST href so the more specific entry wins.
*
* Returns the active item's `key`, or null if no item matches (e.g.
* the user navigated to a route that isn't in the visible nav).
*/
export function pickActiveNavKey(items: NavItem[], pathname: string): string | null {
let best: NavItem | null = null;
for (const item of items) {
if (item.href === "/") {
if (pathname === "/") best = item;
continue;
}
const isMatch =
pathname === item.href || pathname.startsWith(item.href + "/");
if (!isMatch) continue;
if (!best || item.href.length > best.href.length) {
best = item;
}
}
return best?.key ?? null;
}

View File

@ -18,8 +18,7 @@ type PairingState =
| { phase: "waiting" } | { phase: "waiting" }
| { phase: "qr"; qrUrl: string } | { phase: "qr"; qrUrl: string }
| { phase: "connected"; phoneNumber: string } | { phase: "connected"; phoneNumber: string }
| { phase: "timeout" } | { phase: "timeout" };
| { phase: "duplicate"; phoneNumber: string; existingLabel: string };
interface PairLiveProps { interface PairLiveProps {
accountId: string; accountId: string;
@ -113,15 +112,6 @@ export function PairLive({ accountId, label }: PairLiveProps) {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
setPairingState({ phase: "timeout" }); setPairingState({ phase: "timeout" });
}, },
"session.duplicate": (data) => {
if (data.accountId !== accountId) return;
if (timerRef.current) clearInterval(timerRef.current);
setPairingState({
phase: "duplicate",
phoneNumber: data.phoneNumber,
existingLabel: data.existingLabel,
});
},
}); });
// Auto-redirect on connected // Auto-redirect on connected
@ -244,35 +234,6 @@ export function PairLive({ accountId, label }: PairLiveProps) {
</Button> </Button>
</div> </div>
)} )}
{pairingState.phase === "duplicate" && (
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-amber-500/15">
<XCircleIcon className="size-8 text-amber-600 dark:text-amber-400" />
</div>
<div className="space-y-1">
<p className="text-base font-semibold">Phone already linked</p>
<p className="text-xs text-muted-foreground">
<span className="font-mono">
+{pairingState.phoneNumber.replace(/^\+/, "")}
</span>{" "}
is already paired to{" "}
<span className="font-medium text-foreground">
{pairingState.existingLabel}
</span>
. Each WhatsApp number can only be linked to one account here.
Unpair the existing account first, or scan with a different
phone.
</p>
</div>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${accountId}` as any}>
Back to accounts
</Link>
</Button>
</div>
)}
</div> </div>
); );
} }

View File

@ -1,71 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/actions/reminders", () => ({
resumeReminderRunAction: vi.fn(),
cancelReminderRunAction: vi.fn(),
}));
import { PausedRunBanner } from "./paused-run-banner";
describe("PausedRunBanner — SSR layout", () => {
it("renders Resume + Cancel buttons inside the banner", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
sent={412}
total={1000}
windowEndHour={18}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toContain('data-testid="paused-run-banner"');
expect(html).toContain('data-testid="paused-resume"');
expect(html).toContain('data-testid="paused-cancel"');
expect(html).toMatch(/Resume<\/button>/);
expect(html).toMatch(/Cancel run<\/button>/);
});
it("shows X of Y groups delivered when sent + total are present", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
sent={412}
total={1000}
windowEndHour={18}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toContain("412 of 1000 groups delivered");
// Surfaces the window-end deadline so the operator knows why.
expect(html).toContain("18:00 (Asia/Kuala_Lumpur)");
// And the remaining count drives the CTA copy.
expect(html).toContain("send the remaining 588");
});
it("falls back to a generic body when sent / total aren't supplied", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
windowEndHour={18}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toMatch(/delivery window closed before/i);
expect(html).not.toContain("groups delivered");
});
it("uses amber styling so the banner reads as 'attention, not error'", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="r-1"
sent={1}
total={2}
windowEndHour={18}
timezone="UTC"
/>,
);
expect(html).toMatch(/border-amber-500/);
expect(html).toMatch(/bg-amber-500/);
});
});

View File

@ -1,118 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import {
AlertCircleIcon,
PlayIcon,
XIcon,
Loader2Icon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
resumeReminderRunAction,
cancelReminderRunAction,
} from "@/actions/reminders";
interface PausedRunBannerProps {
runId: string;
/** Best-effort sent count for the body copy. Falls back to a
* generic message when undefined. */
sent?: number;
/** Best-effort total target count. */
total?: number;
/** Deadline hour the bot stopped at. Shown in the body copy. */
windowEndHour: number;
/** Operator timezone (for the deadline label). */
timezone: string;
}
/**
* Amber callout shown above the reminder detail view when the most
* recent run is in 'paused' state. Two interactive choices:
* Resume re-enqueues the run via the bot.
* Cancel run stops the run cleanly (remaining pending skipped).
*
* Pause notifications deep-link the operator into this surface.
*/
export function PausedRunBanner({
runId,
sent,
total,
windowEndHour,
timezone,
}: PausedRunBannerProps) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const onResume = () =>
startTransition(async () => {
setError(null);
const r = await resumeReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
const onCancel = () =>
startTransition(async () => {
setError(null);
const r = await cancelReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
const remaining =
typeof sent === "number" && typeof total === "number"
? Math.max(0, total - sent)
: null;
return (
<div
className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-4 space-y-3"
data-testid="paused-run-banner"
>
<div className="flex items-start gap-2">
<AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="space-y-1 text-sm">
<p className="font-medium">Reminder paused</p>
<p className="text-xs text-muted-foreground">
{typeof sent === "number" && typeof total === "number"
? `${sent} of ${total} groups delivered.`
: "The delivery window closed before all groups got the message."}{" "}
The deadline was {windowEndHour}:00 ({timezone}).{" "}
{remaining !== null && remaining > 0
? `Resume to send the remaining ${remaining}, or cancel the run.`
: "Resume to keep going, or cancel the run."}
</p>
</div>
</div>
{error && (
<div className="text-xs text-destructive">{error}</div>
)}
<div className="flex gap-2">
<Button
size="sm"
onClick={onResume}
disabled={pending}
className="gap-2"
data-testid="paused-resume"
>
{pending ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<PlayIcon className="size-3.5" />
)}
Resume
</Button>
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={pending}
className="gap-2"
data-testid="paused-cancel"
>
<XIcon className="size-3.5" />
Cancel run
</Button>
</div>
</div>
);
}

View File

@ -52,15 +52,7 @@ export function EditWhenForm({
const [date, setDate] = useState(initial.date); const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time); const [time, setTime] = useState(initial.time);
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec); const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec);
// Optional deadline: 24 (next-day midnight) is the off-sentinel — const [deliveryEndHour, setDeliveryEndHour] = useState<number>(initialDeliveryEndHour);
// hour=24 makes windowEndAt return tomorrow's start, effectively
// "no deadline today". Existing rows at 24 land with the toggle
// OFF; rows at any other value land toggled ON with that value.
const initialUseDeadline = initialDeliveryEndHour !== 24;
const [useDeadline, setUseDeadline] = useState<boolean>(initialUseDeadline);
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(
initialUseDeadline ? initialDeliveryEndHour : 18,
);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -116,7 +108,7 @@ export function EditWhenForm({
scheduledAtIso, scheduledAtIso,
rrule, rrule,
timezone, timezone,
deliveryWindowEndHour: useDeadline ? deliveryEndHour : 24, deliveryWindowEndHour: deliveryEndHour,
}); });
if (r.ok) { if (r.ok) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -170,40 +162,23 @@ export function EditWhenForm({
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} /> <RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
<div className="space-y-2"> <div className="space-y-1.5">
<label className="flex items-center gap-3 rounded-lg border border-input bg-card px-3 py-2.5 cursor-pointer select-none transition-colors hover:bg-accent/40"> <Label className="flex items-center gap-1.5">
<input <ClockIcon className="size-3.5" />
type="checkbox" Pause sending by
checked={useDeadline} <span className="text-xs font-normal text-muted-foreground">(optional)</span>
onChange={(e) => { </Label>
setUseDeadline(e.target.checked); <div className="flex flex-wrap items-center gap-2">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}
onChange={(h) => {
setDeliveryEndHour(h);
setError(null); setError(null);
}} }}
className="size-5 rounded border-input accent-primary"
aria-label="Set a delivery deadline"
/> />
<span className="flex-1 flex items-center gap-1.5 text-sm font-medium"> <span className="text-xs text-muted-foreground">({timezone})</span>
<ClockIcon className="size-3.5" /> </div>
Pause sending by
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
</span>
<span className="text-xs text-muted-foreground">
{useDeadline ? "Set" : "Off"}
</span>
</label>
{useDeadline && (
<div className="flex flex-wrap items-center gap-2 pl-3">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}
onChange={(h) => {
setDeliveryEndHour(h);
setError(null);
}}
/>
<span className="text-xs text-muted-foreground">({timezone})</span>
</div>
)}
</div> </div>
{error && ( {error && (

View File

@ -99,7 +99,7 @@ export function ReminderFilterBar({ accounts }: FilterBarProps) {
id="filter-account" id="filter-account"
value={initial.accountId} value={initial.accountId}
onChange={(e) => setParam("accountId", e.target.value)} onChange={(e) => setParam("accountId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:max-w-xs rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<option value="">All accounts</option> <option value="">All accounts</option>
{accounts.map((a) => ( {accounts.map((a) => (

View File

@ -82,12 +82,8 @@ export function ReviewSubmitClient({
const groupCount = groupIds ? groupIds.split(",").filter(Boolean).length : 0; const groupCount = groupIds ? groupIds.split(",").filter(Boolean).length : 0;
const fireAt = new Date(scheduledAt); const fireAt = new Date(scheduledAt);
// Treat hour=24 (or unset) as "no deadline". The pill goes neutral. const endHour = deliveryEndHour ?? 18;
const hasDeadline = const wEnd = windowEndAt(timezone, endHour, fireAt);
deliveryEndHour !== undefined && deliveryEndHour !== 24;
const wEnd = hasDeadline
? windowEndAt(timezone, deliveryEndHour!, fireAt)
: undefined;
return ( return (
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">

View File

@ -3,20 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server";
import { RunEtaPill } from "./run-eta-pill"; import { RunEtaPill } from "./run-eta-pill";
describe("RunEtaPill", () => { describe("RunEtaPill", () => {
it("renders a neutral ETA when windowEndAt is omitted (no deadline)", () => {
const html = renderToStaticMarkup(
<RunEtaPill
targetCount={500}
fireAt={new Date("2026-05-13T09:00:00.000+08:00")}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toContain('data-testid="eta-pill-neutral"');
expect(html).toMatch(/min/);
expect(html).not.toMatch(/Fits before deadline/);
expect(html).not.toMatch(/Likely to pause/);
});
it("renders nothing for zero targets", () => { it("renders nothing for zero targets", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<RunEtaPill <RunEtaPill

View File

@ -4,10 +4,7 @@ import { estimateRunDuration } from "@/lib/run-eta";
interface RunEtaPillProps { interface RunEtaPillProps {
targetCount: number; targetCount: number;
fireAt: Date; fireAt: Date;
/** Optional. When omitted (or when the operator picked "no windowEndAt: Date;
* deadline"), the pill renders a neutral ETA without the
* green/amber fit indicator. */
windowEndAt?: Date;
timezone: string; timezone: string;
} }
@ -30,6 +27,8 @@ export function RunEtaPill({
targetCount, targetCount,
fireAt, fireAt,
}); });
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
const finishLocal = new Intl.DateTimeFormat("en-GB", { const finishLocal = new Intl.DateTimeFormat("en-GB", {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
@ -37,23 +36,6 @@ export function RunEtaPill({
timeZone: timezone, timeZone: timezone,
}).format(estimatedFinishAt); }).format(estimatedFinishAt);
// No deadline → neutral ETA, no green/amber comparison.
if (!windowEndAt) {
return (
<div
className="flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground"
data-testid="eta-pill-neutral"
>
<ClockIcon className="size-3.5" />
<span>
~{durationMinutes} min · finishes ~{finishLocal}
</span>
</div>
);
}
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
if (fits) { if (fits) {
return ( return (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400"> <div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">

View File

@ -45,16 +45,8 @@ export function WhenFormClient({
const [date, setDate] = useState(initial.date); const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time); const [time, setTime] = useState(initial.time);
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE); const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE);
// Deadline is optional. We model it as two states: a checkbox that
// turns it on/off, and the picked hour (only meaningful when the
// checkbox is on). 24 (next-day midnight) is the off-sentinel sent
// to the server — windowEndAt treats it as "end of today" so the
// bot's window-end gate effectively never trips for short runs.
const initialUseDeadline =
initialDeliveryEndHour !== undefined && initialDeliveryEndHour !== 24;
const [useDeadline, setUseDeadline] = useState<boolean>(initialUseDeadline);
const [deliveryEndHour, setDeliveryEndHour] = useState<number>( const [deliveryEndHour, setDeliveryEndHour] = useState<number>(
initialUseDeadline ? (initialDeliveryEndHour ?? 18) : 18, initialDeliveryEndHour ?? 18,
); );
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -85,8 +77,7 @@ export function WhenFormClient({
if (passThroughParams.name) sp.set("name", passThroughParams.name); if (passThroughParams.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight). sp.set("deliveryEndHour", String(deliveryEndHour));
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
return; return;
@ -130,8 +121,7 @@ export function WhenFormClient({
if (passThroughParams.name) sp.set("name", passThroughParams.name); if (passThroughParams.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight). sp.set("deliveryEndHour", String(deliveryEndHour));
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
} }
@ -178,43 +168,24 @@ export function WhenFormClient({
{/* Deadline fire time is the implicit start; this only sets when {/* Deadline fire time is the implicit start; this only sets when
the bot must stop. Long fan-outs that don't finish before the the bot must stop. Long fan-outs that don't finish before the
deadline are paused so the operator can resume them later. deadline are paused so the operator can resume them later. */}
The whole control is opt-in: tick the box to surface the hour <div className="space-y-1.5">
picker, untick to remove the deadline entirely. */} <Label className="flex items-center gap-1.5">
<div className="space-y-2"> <ClockIcon className="size-3.5" />
<label className="flex items-center gap-3 rounded-lg border border-input bg-card px-3 py-2.5 cursor-pointer select-none transition-colors hover:bg-accent/40"> Pause sending by
<input <span className="text-xs font-normal text-muted-foreground">(optional)</span>
type="checkbox" </Label>
checked={useDeadline} <div className="flex flex-wrap items-center gap-2">
onChange={(e) => { <HourSelect
setUseDeadline(e.target.checked); ariaPrefix="Delivery deadline"
value={deliveryEndHour}
onChange={(h) => {
setDeliveryEndHour(h);
setError(null); setError(null);
}} }}
className="size-5 rounded border-input accent-primary"
aria-label="Set a delivery deadline"
/> />
<span className="flex-1 flex items-center gap-1.5 text-sm font-medium"> <span className="text-xs text-muted-foreground">({timezone})</span>
<ClockIcon className="size-3.5" /> </div>
Pause sending by
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
</span>
<span className="text-xs text-muted-foreground">
{useDeadline ? "Set" : "Off"}
</span>
</label>
{useDeadline && (
<div className="flex flex-wrap items-center gap-2 pl-3">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}
onChange={(h) => {
setDeliveryEndHour(h);
setError(null);
}}
/>
<span className="text-xs text-muted-foreground">({timezone})</span>
</div>
)}
</div> </div>
{error && ( {error && (

View File

@ -1,86 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
// next/navigation is touched by useRouter — stub it.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
// next/link → transparent <a> so the markup we assert on stays simple.
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: { href: string; children: ReactNode } & Record<string, unknown>) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
import { WhenFormClient } from "./when-form-client";
const baseProps = {
accountId: "acc-1",
groupIds: "g-1",
timezone: "Asia/Kuala_Lumpur",
initialDefaultIso: "2026-05-13T09:00:00.000+08:00",
passThroughParams: { name: "test", messages: "x" },
};
/**
* The "Pause sending by" deadline is opt-in. The checkbox controls
* whether the HourSelect is rendered at all; when off, the form
* sends 24 (next-day midnight) to the server, which makes the bot's
* window-end gate effectively never trip. These tests lock in the
* SSR markup for the three states (off by default, off when the
* stored value is 24, on when the stored value is something else).
*/
describe("WhenFormClient — deadline checkbox", () => {
it("defaults to UNCHECKED for a fresh reminder (no initialDeliveryEndHour)", () => {
const html = renderToStaticMarkup(<WhenFormClient {...baseProps} />);
// Checkbox is rendered but not checked.
expect(html).toMatch(
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*>/,
);
expect(html).not.toMatch(
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
);
// No HourSelect rendered while the box is unchecked.
expect(html).not.toMatch(/aria-label="Delivery deadline hour"/);
});
it("starts UNCHECKED when initialDeliveryEndHour is 24 (the off-sentinel)", () => {
const html = renderToStaticMarkup(
<WhenFormClient {...baseProps} initialDeliveryEndHour={24} />,
);
expect(html).not.toMatch(
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
);
expect(html).not.toMatch(/aria-label="Delivery deadline hour"/);
});
it("starts CHECKED + reveals the hour picker when initialDeliveryEndHour is set to a real hour", () => {
const html = renderToStaticMarkup(
<WhenFormClient {...baseProps} initialDeliveryEndHour={18} />,
);
// Checkbox is checked.
expect(html).toMatch(
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
);
// The hour + period selects render under the checkbox.
expect(html).toMatch(/aria-label="Delivery deadline hour"/);
expect(html).toMatch(/aria-label="Delivery deadline period"/);
// Pre-selected hour matches the initial value (18 → 6 PM).
expect(html).toMatch(/value="6"\s+selected/);
expect(html).toMatch(/value="PM"\s+selected/);
});
it("offers a clear (optional) hint next to the label", () => {
const html = renderToStaticMarkup(<WhenFormClient {...baseProps} />);
expect(html).toContain("Pause sending by");
expect(html).toContain("(optional)");
});
});

View File

@ -67,11 +67,6 @@ export function SwipeableRow({
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null); const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
// Tracks whether the pointer crossed the click-vs-drag threshold during
// the current gesture. If it did, we swallow the synthetic click that
// browsers fire on pointerup — otherwise a swipe on a Link-wrapped row
// both swipes the shelf open AND navigates to the link target.
const dragMoved = useRef(false);
// Close the shelf when the user taps anywhere outside an open row. // Close the shelf when the user taps anywhere outside an open row.
useEffect(() => { useEffect(() => {
@ -97,17 +92,12 @@ export function SwipeableRow({
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) { function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
if (e.button !== 0 && e.pointerType === "mouse") return; if (e.button !== 0 && e.pointerType === "mouse") return;
dragStart.current = { x: e.clientX, baseOffset: offset }; dragStart.current = { x: e.clientX, baseOffset: offset };
dragMoved.current = false;
setDragging(true); setDragging(true);
} }
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) { function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging || !dragStart.current) return; if (!dragging || !dragStart.current) return;
const dx = e.clientX - dragStart.current.x; const dx = e.clientX - dragStart.current.x;
// 6 px is the standard threshold below which a touch counts as a tap
// rather than a drag. Cross it once and the gesture commits to drag
// for the rest of the pointer's lifetime.
if (Math.abs(dx) > 6) dragMoved.current = true;
setOffset(clamp(dragStart.current.baseOffset + dx)); setOffset(clamp(dragStart.current.baseOffset + dx));
} }
@ -123,28 +113,6 @@ export function SwipeableRow({
rightWidth, rightWidth,
}), }),
); );
if (dragMoved.current) {
// The browser fires a synthetic `click` on the element under the
// pointer right after pointerup. If our row body wraps a <Link>,
// that click navigates away. Add a one-shot capture-phase handler
// that swallows the next click ANYWHERE in the row container
// before it can reach the anchor's onClick.
const swallow = (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();
};
const node = containerRef.current;
if (node) {
node.addEventListener("click", swallow, { capture: true, once: true });
// Defensive: if for some reason no click fires (e.g. pointerup
// outside the element), strip the listener after a tick so it
// doesn't accidentally eat a future legitimate click.
window.setTimeout(() => {
node.removeEventListener("click", swallow, { capture: true });
}, 350);
}
}
dragMoved.current = false;
} }
return ( return (
@ -182,14 +150,6 @@ export function SwipeableRow({
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp} onPointerCancel={handlePointerUp}
// Anchors (and <img>) are natively draggable. When children
// contain a <Link> wrapping the card, the browser hijacks the
// pointer for a "drag link" operation as soon as the user
// moves horizontally, so the swipe gesture never reaches our
// pointer handlers. Suppress native drag here once and the
// whole row body is unblocked.
onDragStart={(e) => e.preventDefault()}
draggable={false}
style={{ style={{
transform: `translateX(${offset}px)`, transform: `translateX(${offset}px)`,
transition: dragging ? "none" : "transform 200ms ease-out", transition: dragging ? "none" : "transform 200ms ease-out",

View File

@ -8,25 +8,4 @@ const envSchema = z.object({
}); });
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;
export const env = envSchema.parse(process.env);
// Lazy parse via Proxy. Next.js's `next build` does a
// "Collecting page data" pass that imports every route module —
// including api/events/route.ts which depends on this env. With a
// top-level `envSchema.parse(process.env)` the parse ran during
// the build container, where DATABASE_URL isn't (and shouldn't be)
// set, and Zod aborted the build with:
// ZodError: DATABASE_URL: Required
// Deferring the parse until first property access lets the build
// finish (no consumer accesses env during page-data collection)
// while still failing loudly at runtime if the var is missing.
let cached: Env | null = null;
function read(): Env {
if (cached) return cached;
cached = envSchema.parse(process.env);
return cached;
}
export const env: Env = new Proxy({} as Env, {
get(_t, prop) {
return read()[prop as keyof Env];
},
}) as Env;

View File

@ -9,19 +9,8 @@ export type WebEventMap = {
"session.connected": { accountId: string; phoneNumber: string | null }; "session.connected": { accountId: string; phoneNumber: string | null };
"session.disconnected": { accountId: string }; "session.disconnected": { accountId: string };
"session.timeout": { accountId: string }; "session.timeout": { accountId: string };
"session.duplicate": {
accountId: string;
phoneNumber: string;
existingLabel: string;
};
"groups.synced": { accountId: string; count: number }; "groups.synced": { accountId: string; count: number };
"reminder.fired": { "reminder.fired": { reminderId: string; runId: string; status: string };
reminderId: string;
runId: string;
status: string;
sent?: number;
total?: number;
};
"reminder.failed": { reminderId: string; error: string }; "reminder.failed": { reminderId: string; error: string };
"send_test.done": { groupId: string; ok: boolean; error: string | null }; "send_test.done": { groupId: string; ok: boolean; error: string | null };
}; };

View File

@ -1,135 +0,0 @@
import { describe, it, expect, beforeAll } from "vitest";
import {
signSession,
verifySession,
COOKIE_NAME,
DEFAULT_TTL_SECONDS,
type SessionPayload,
} from "./auth-cookie";
const SECRET = "test-secret-not-used-anywhere-real";
const NOW = 1_700_000_000; // 2023-11-14 — fixed clock for determinism
beforeAll(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
});
const validPayload = (): SessionPayload => ({
userId: "11111111-1111-1111-1111-111111111111",
role: "admin",
iat: NOW,
exp: NOW + DEFAULT_TTL_SECONDS,
v: 1,
});
describe("auth-cookie (AES-256-GCM)", () => {
it("signSession + verifySession round-trips a valid payload", async () => {
const cookie = await signSession(validPayload(), SECRET);
const verified = await verifySession(cookie, SECRET, NOW);
expect(verified).toEqual(validPayload());
});
it("uses a fresh IV per call so two encryptions of the same payload differ", async () => {
// Repeating the IV under AES-GCM is catastrophic (it leaks the XOR
// of plaintexts and the auth key). Lock in that signSession draws
// a new nonce every time — the byte-for-byte cookies must not match
// even when the inputs are identical.
const a = await signSession(validPayload(), SECRET);
const b = await signSession(validPayload(), SECRET);
expect(a).not.toBe(b);
// Both still decrypt correctly with the same secret.
expect(await verifySession(a, SECRET, NOW)).toEqual(validPayload());
expect(await verifySession(b, SECRET, NOW)).toEqual(validPayload());
});
it("ciphertext does NOT leak the userId in plaintext (confidentiality)", async () => {
const cookie = await signSession(validPayload(), SECRET);
// The whole point of the GCM upgrade: someone with only the cookie
// value should not be able to read the userId / role straight off
// it the way they could with the old base64-encoded JSON.
expect(cookie).not.toContain(validPayload().userId);
expect(cookie).not.toContain("admin");
});
it("rejects when the ciphertext has been tampered with (auth tag mismatch)", async () => {
const cookie = await signSession(validPayload(), SECRET);
const [iv, ct] = cookie.split(".");
// Flip the last character of the ciphertext (still valid base64url).
const lastCh = ct!.slice(-1);
const replacement = lastCh === "A" ? "B" : "A";
const tampered = `${iv}.${ct!.slice(0, -1)}${replacement}`;
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
});
it("rejects when the IV has been swapped for another (auth tag mismatch)", async () => {
const cookie = await signSession(validPayload(), SECRET);
const otherIv = await signSession(validPayload(), SECRET);
const [, ct] = cookie.split(".");
const [otherIvB64] = otherIv.split(".");
const tampered = `${otherIvB64}.${ct}`;
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
});
it("rejects when verified with a different secret", async () => {
const cookie = await signSession(validPayload(), SECRET);
expect(await verifySession(cookie, "different-secret", NOW)).toBeNull();
});
it("rejects an expired cookie (exp <= now)", async () => {
const expired = { ...validPayload(), exp: NOW - 1 };
const cookie = await signSession(expired, SECRET);
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
});
it("rejects a cookie issued in the future beyond clock-skew tolerance", async () => {
const future = { ...validPayload(), iat: NOW + 120 };
const cookie = await signSession(future, SECRET);
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
});
it("accepts a cookie issued slightly in the future (within 60s skew)", async () => {
const future = { ...validPayload(), iat: NOW + 30 };
const cookie = await signSession(future, SECRET);
expect(await verifySession(cookie, SECRET, NOW)).not.toBeNull();
});
it("rejects a cookie whose v is stale (token-version bumped)", async () => {
const cookie = await signSession({ ...validPayload(), v: 1 }, SECRET);
process.env.OPERATOR_TOKEN_VERSION = "2";
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
process.env.OPERATOR_TOKEN_VERSION = "1";
});
it("rejects a cookie with an unknown role string", async () => {
const cookie = await signSession(
{ ...validPayload(), role: "superadmin" as never },
SECRET,
);
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
});
it("rejects a cookie that doesn't have a '.' separator", async () => {
expect(await verifySession("not-a-cookie", SECRET, NOW)).toBeNull();
expect(await verifySession("", SECRET, NOW)).toBeNull();
});
it("rejects a cookie whose IV decodes to the wrong byte length", async () => {
// GCM requires a 12-byte nonce. Swap the IV portion for something
// that decodes to a different length and confirm we bounce it
// before handing weird input to crypto.subtle.decrypt.
const cookie = await signSession(validPayload(), SECRET);
const [, ct] = cookie.split(".");
// 8 bytes encoded — too short.
const shortIv = "AAAAAAAAAAA";
expect(await verifySession(`${shortIv}.${ct}`, SECRET, NOW)).toBeNull();
});
it("rejects malformed (non-base64url) input gracefully — no throw, just null", async () => {
expect(await verifySession("$$$.$$$", SECRET, NOW)).toBeNull();
});
it("exposes COOKIE_NAME as 'session'", () => {
expect(COOKIE_NAME).toBe("session");
});
});

View File

@ -1,148 +0,0 @@
/**
* Edge-runtime-safe AES-256-GCM session cookie. Runs in middleware
* and Server Actions. NO database, NO bcrypt, NO Node-only APIs
* pure Web Crypto so it survives Edge runtime.
*
* Why GCM instead of HMAC-then-plaintext: GCM is authenticated
* encryption, so a leaked cookie no longer hands the userId/role to
* an attacker who only sees the bytes. Tampering with either the IV
* or the ciphertext invalidates the auth tag decrypt throws we
* return null. Replay protection comes from the per-payload `exp`
* field plus the global `OPERATOR_TOKEN_VERSION` kill switch.
*
* Cookie format: `<base64url(iv)>.<base64url(ciphertext+tag)>`
* - iv: 12 random bytes (GCM nonce)
* - ciphertext+tag: AES-GCM output (plaintext + 16-byte auth tag)
*/
export const COOKIE_NAME = "session";
export const DEFAULT_TTL_SECONDS = 30 * 86400; // 30 days
export const CLOCK_SKEW_SECONDS = 60;
export type Role = "admin" | "user";
export interface SessionPayload {
userId: string;
role: Role;
iat: number;
exp: number;
v: number;
}
function isValidPayload(x: unknown): x is SessionPayload {
if (typeof x !== "object" || x === null) return false;
const o = x as Record<string, unknown>;
return (
typeof o.userId === "string" &&
(o.role === "admin" || o.role === "user") &&
typeof o.iat === "number" &&
typeof o.exp === "number" &&
typeof o.v === "number"
);
}
function b64urlEncode(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlDecode(str: string): Uint8Array {
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
const s = atob(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
}
/**
* Derive a 256-bit AES key from the operator-supplied AUTH_SECRET.
* SHA-256 hashes the secret to a fixed-length key so the secret can
* be any printable string in env (no min/max length policing here).
*/
async function deriveKey(secret: string): Promise<CryptoKey> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(secret),
);
return crypto.subtle.importKey(
"raw",
digest,
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"],
);
}
export async function signSession(
payload: SessionPayload,
secret: string,
): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(secret);
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
const ct = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv as BufferSource },
key,
plaintext as BufferSource,
),
);
return `${b64urlEncode(iv)}.${b64urlEncode(ct)}`;
}
export async function verifySession(
cookie: string,
secret: string,
now: number = Math.floor(Date.now() / 1000),
): Promise<SessionPayload | null> {
if (!cookie || typeof cookie !== "string") return null;
const dot = cookie.indexOf(".");
if (dot <= 0 || dot === cookie.length - 1) return null;
let iv: Uint8Array;
let ct: Uint8Array;
try {
iv = b64urlDecode(cookie.slice(0, dot));
ct = b64urlDecode(cookie.slice(dot + 1));
} catch {
return null;
}
// GCM nonces are exactly 12 bytes. A wrong-sized IV would still
// sometimes succeed at the WebCrypto layer on some platforms;
// guard explicitly so callers can't slip a non-standard nonce past us.
if (iv.length !== 12) return null;
let plain: string;
try {
const key = await deriveKey(secret);
// The IV in `AesGcmParams` must be backed by a non-shared
// ArrayBuffer; TS 5.7+ tightened Uint8Array's generic to reject
// `SharedArrayBuffer`-backed views. b64urlDecode produces a
// regular ArrayBuffer, but we cast to BufferSource explicitly so
// future allocator changes don't regress this site.
const buf = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv as BufferSource },
key,
ct as BufferSource,
);
plain = new TextDecoder().decode(buf);
} catch {
// Auth-tag mismatch (tampered cookie or wrong key) lands here.
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(plain);
} catch {
return null;
}
if (!isValidPayload(parsed)) return null;
if (parsed.exp <= now) return null;
if (parsed.iat > now + CLOCK_SKEW_SECONDS) return null;
const expectedV = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
if (parsed.v !== expectedV) return null;
return parsed;
}

View File

@ -1,89 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const cookiesGetMock = vi.fn();
const findUserMock = vi.fn();
vi.mock("next/headers", () => ({
cookies: async () => ({ get: cookiesGetMock }),
}));
vi.mock("./db", () => ({
db: {
query: {
operators: {
findFirst: (...a: unknown[]) => findUserMock(...a),
},
},
},
}));
const SECRET = "test-secret";
beforeEach(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
cookiesGetMock.mockReset();
findUserMock.mockReset();
});
import { signSession } from "./auth-cookie";
import { getCurrentUser, requireUser, requireAdmin } from "./auth";
const NOW_S = Math.floor(Date.now() / 1000);
const ADMIN = {
id: "11111111-1111-1111-1111-111111111111",
username: "admin",
role: "admin" as const,
displayName: "Admin",
defaultTimezone: "UTC",
passwordHash: null,
};
const USER = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice", role: "user" as const };
async function makeCookie(role: "admin" | "user"): Promise<string> {
return signSession(
{
userId: role === "admin" ? ADMIN.id : USER.id,
role,
iat: NOW_S,
exp: NOW_S + 3600,
v: 1,
},
SECRET,
);
}
describe("auth helpers", () => {
it("getCurrentUser returns null when no cookie is set", async () => {
cookiesGetMock.mockReturnValue(undefined);
const u = await getCurrentUser();
expect(u).toBeNull();
});
it("getCurrentUser returns the user row for a valid admin cookie", async () => {
const cookie = await makeCookie("admin");
cookiesGetMock.mockReturnValue({ value: cookie });
findUserMock.mockResolvedValue(ADMIN);
const u = await getCurrentUser();
expect(u?.id).toBe(ADMIN.id);
expect(u?.role).toBe("admin");
});
it("requireUser throws when there is no session", async () => {
cookiesGetMock.mockReturnValue(undefined);
await expect(requireUser()).rejects.toThrow();
});
it("requireAdmin throws when role is 'user'", async () => {
const cookie = await makeCookie("user");
cookiesGetMock.mockReturnValue({ value: cookie });
findUserMock.mockResolvedValue(USER);
await expect(requireAdmin()).rejects.toThrow();
});
it("requireAdmin returns the user when role is 'admin'", async () => {
const cookie = await makeCookie("admin");
cookiesGetMock.mockReturnValue({ value: cookie });
findUserMock.mockResolvedValue(ADMIN);
const u = await requireAdmin();
expect(u.role).toBe("admin");
});
});

View File

@ -1,66 +0,0 @@
import "server-only";
import { cookies } from "next/headers";
import { db } from "./db";
import { COOKIE_NAME, verifySession } from "./auth-cookie";
export type AuthUser = {
id: string;
username: string;
role: "admin" | "user";
displayName: string;
defaultTimezone: string;
passwordHash: string | null;
};
export class UnauthenticatedError extends Error {
constructor() {
super("Unauthenticated");
this.name = "UnauthenticatedError";
}
}
export class ForbiddenError extends Error {
constructor() {
super("Forbidden");
this.name = "ForbiddenError";
}
}
/**
* Returns the operator row whose userId is encoded in the session
* cookie, or null if the cookie is missing / invalid / the row is
* gone. Never throws call requireUser() if you want a throw.
*/
export async function getCurrentUser(): Promise<AuthUser | null> {
const jar = await cookies();
const cookie = jar.get(COOKIE_NAME)?.value;
if (!cookie) return null;
const secret = process.env.AUTH_SECRET;
if (!secret) return null;
const payload = await verifySession(cookie, secret);
if (!payload) return null;
const row = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.id, payload.userId),
});
if (!row) return null;
if (row.role !== "admin" && row.role !== "user") return null;
return {
id: row.id,
username: row.username,
role: row.role,
displayName: row.displayName,
defaultTimezone: row.defaultTimezone,
passwordHash: row.passwordHash,
};
}
export async function requireUser(): Promise<AuthUser> {
const u = await getCurrentUser();
if (!u) throw new UnauthenticatedError();
return u;
}
export async function requireAdmin(): Promise<AuthUser> {
const u = await requireUser();
if (u.role !== "admin") throw new ForbiddenError();
return u;
}

View File

@ -1,49 +0,0 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Static guard: listAccounts() in apps/web/src/lib/queries.ts MUST
* order rows by createdAt ascending (with id as a deterministic
* tiebreaker) so the operator's earliest-added account stays on top.
*
* Earlier the orderBy used `asc(a.label)` which silently re-shuffled
* the list every time an account was renamed. This test pins the
* fix in source so a future refactor can't quietly bring the rename
* regression back.
*
* It's a static (regex) guard rather than an integration test
* because the live query needs Postgres + a seeded operator;
* pinning the source spelling keeps coverage cheap and CI-friendly.
*/
describe("listAccounts ordering (regression guard)", () => {
const src = readFileSync(
join(__dirname, "queries.ts"),
"utf8",
);
it("orders by created_at ASC", () => {
// Match across whitespace/comments inside listAccounts. Anchors:
// function header → orderBy → asc(a.createdAt).
const fnStart = src.indexOf("export async function listAccounts(");
expect(fnStart).toBeGreaterThan(-1);
const fnEnd = src.indexOf("export async function ", fnStart + 1);
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
expect(fnBody).toMatch(/orderBy:[\s\S]*asc\(a\.createdAt\)/);
});
it("uses id as a deterministic tiebreaker for ties on createdAt", () => {
const fnStart = src.indexOf("export async function listAccounts(");
const fnEnd = src.indexOf("export async function ", fnStart + 1);
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
expect(fnBody).toMatch(/asc\(a\.id\)/);
});
it("does NOT order by label (the regression we're guarding against)", () => {
const fnStart = src.indexOf("export async function listAccounts(");
const fnEnd = src.indexOf("export async function ", fnStart + 1);
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
expect(fnBody).not.toMatch(/asc\(a\.label\)/);
expect(fnBody).not.toMatch(/desc\(a\.label\)/);
});
});

View File

@ -240,44 +240,6 @@ describe("reminderFiredToNotification mapping", () => {
expect(args).toBeNull(); expect(args).toBeNull();
}); });
it("renders 'paused' with the resume/cancel call-to-action and sent/total", () => {
const args = reminderFiredToNotification({
type: "reminder.fired",
reminderId: "r-p",
runId: "run-p",
status: "paused",
sent: 412,
total: 1000,
});
expect(args?.title).toBe("Reminder paused");
expect(args?.body).toBe("412 of 1000 groups delivered. Tap to resume or cancel.");
expect(args?.tag).toBe("reminder:r-p");
expect(args?.href).toBe("/reminders/r-p");
});
it("renders 'paused' without sent/total with a generic body", () => {
const args = reminderFiredToNotification({
type: "reminder.fired",
reminderId: "r-p",
runId: "run-p",
status: "paused",
});
expect(args?.title).toBe("Reminder paused");
expect(args?.body).toMatch(/Delivery window closed/);
});
it("renders 'partial' with sent/total → 'X of Y groups delivered'", () => {
const args = reminderFiredToNotification({
type: "reminder.fired",
reminderId: "r-2",
runId: "run-2",
status: "partial",
sent: 87,
total: 100,
});
expect(args?.body).toBe("87 of 100 groups delivered. See activity for details.");
});
it("uses the same tag for repeat fires of the same reminder so they coalesce", () => { it("uses the same tag for repeat fires of the same reminder so they coalesce", () => {
const a = reminderFiredToNotification({ const a = reminderFiredToNotification({
type: "reminder.fired", type: "reminder.fired",

View File

@ -138,35 +138,20 @@ export function reminderFiredToNotification(event: {
reminderId: string; reminderId: string;
runId: string; runId: string;
status: string; status: string;
sent?: number;
total?: number;
}): ShowNotificationOptions | null { }): ShowNotificationOptions | null {
if (event.status === "skipped") return null; if (event.status === "skipped") return null;
const headline = const headline =
event.status === "success" event.status === "success"
? "Reminder sent" ? "Reminder sent"
: event.status === "paused" : event.status === "partial"
? "Reminder paused" ? "Reminder partly sent"
: event.status === "partial" : "Reminder failed";
? "Reminder partly sent" const body =
: "Reminder failed";
let body =
event.status === "success" event.status === "success"
? "All groups received the message." ? "All groups received the message."
: event.status === "paused" : event.status === "partial"
? "Delivery window closed before all groups got the message." ? "Some groups received the message; others failed. See activity."
: event.status === "partial" : "No groups received the message. See activity.";
? "Some groups received the message; others failed. See activity."
: "No groups received the message. See activity.";
if (event.status === "paused" && event.sent !== undefined && event.total !== undefined) {
body = `${event.sent} of ${event.total} groups delivered. Tap to resume or cancel.`;
} else if (
event.status === "partial" &&
event.sent !== undefined &&
event.total !== undefined
) {
body = `${event.sent} of ${event.total} groups delivered. See activity for details.`;
}
return { return {
title: headline, title: headline,
body, body,

View File

@ -5,14 +5,9 @@ import { db } from "./db";
export type BotCommand = export type BotCommand =
| { type: "account.start_pairing"; accountId: string } | { type: "account.start_pairing"; accountId: string }
| { type: "account.unpair"; accountId: string } | { type: "account.unpair"; accountId: string }
// Like account.unpair, but the bot also calls socket.logout() so
// WhatsApp drops this device from the operator's linked-devices
// list before the row is deleted.
| { type: "account.delete"; accountId: string }
| { type: "account.sync_groups"; accountId: string } | { type: "account.sync_groups"; accountId: string }
| { type: "group.send_test"; groupId: string; text: string } | { type: "group.send_test"; groupId: string; text: string }
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string } | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string };
| { type: "reminder.resume"; reminderId: string; runId: string };
export async function pgNotifyBot(cmd: BotCommand): Promise<void> { export async function pgNotifyBot(cmd: BotCommand): Promise<void> {
const json = JSON.stringify(cmd); const json = JSON.stringify(cmd);

View File

@ -1,21 +1,16 @@
import "server-only"; import "server-only";
import { getCurrentUser } from "./auth"; import { db } from "./db";
/** /**
* Compatibility shim. The app used to seed a single operator and * Returns the single seeded operator row. Since the app has no auth,
* attribute everything to it; now we have real auth + roles. Existing * every action is attributed to this operator.
* call sites read `.id` and `.defaultTimezone` off the returned
* object both are still present on the AuthUser shape, so the
* swap is mechanical and existing tests that mock @/lib/operator
* keep working unchanged.
*
* New code should call getCurrentUser / requireUser / requireAdmin
* from @/lib/auth directly.
*/ */
export async function getSeededOperator() { export async function getSeededOperator() {
const u = await getCurrentUser(); const op = await db.query.operators.findFirst({
if (!u) { orderBy: (o, { asc }) => [asc(o.createdAt)],
throw new Error("Not authenticated"); });
if (!op) {
throw new Error("No operator row seeded. Run scripts/db.sh seed.");
} }
return u; return op;
} }

View File

@ -1,69 +0,0 @@
import { describe, it, expect } from "vitest";
import {
validatePassword,
MIN_PASSWORD_LEN,
MAX_PASSWORD_LEN,
} from "./password-policy";
describe("validatePassword", () => {
it("accepts the canonical mixed-case + digit example", () => {
expect(validatePassword("hengs3rver").ok).toBe(true);
});
it("accepts the bare minimum length with a number", () => {
// 6 chars, letter + digit. Boundary case for MIN_PASSWORD_LEN.
expect(validatePassword("abc12!").ok).toBe(true);
});
it("accepts symbols in place of digits", () => {
expect(validatePassword("abcde!").ok).toBe(true);
});
it("rejects passwords shorter than the minimum", () => {
const r = validatePassword("ab1!");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/at least 6/);
});
it("rejects letters-only passwords", () => {
const r = validatePassword("abcdefgh");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/letters with numbers or symbols/);
});
it("rejects digits-only passwords", () => {
const r = validatePassword("12345678");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/letters/);
});
it("rejects symbols-only passwords (no letters)", () => {
const r = validatePassword("!!!!!!!!");
expect(r.ok).toBe(false);
});
it("rejects passwords longer than MAX_PASSWORD_LEN", () => {
const tooLong = "a".repeat(MAX_PASSWORD_LEN) + "1";
const r = validatePassword(tooLong);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/too long/);
});
it("rejects empty input", () => {
expect(validatePassword("").ok).toBe(false);
});
it("rejects non-string input defensively", () => {
// Server actions are typed but a malformed FormData payload could land
// here as null/undefined; the validator must not throw.
// @ts-expect-error - defensive runtime guard
expect(validatePassword(null).ok).toBe(false);
// @ts-expect-error - defensive runtime guard
expect(validatePassword(undefined).ok).toBe(false);
});
it("exposes the documented Facebook-aligned thresholds", () => {
expect(MIN_PASSWORD_LEN).toBe(6);
expect(MAX_PASSWORD_LEN).toBe(256);
});
});

View File

@ -1,37 +0,0 @@
/**
* Password policy modeled after Facebook's documented requirement
* (https://www.facebook.com/help/124904560921566): at least 6
* characters, with a recommended mix of letters and numbers/punctuation.
*
* We enforce the hard minimum (6) and the recommended-mix rule on
* password creation/reset (admin-only flows). Sign-in itself stays
* permissive old short passwords keep working until they're reset
* since rejecting them at login would lock people out without a recovery
* path.
*/
export const MIN_PASSWORD_LEN = 6;
export const MAX_PASSWORD_LEN = 256;
export type PasswordCheck = { ok: true } | { ok: false; error: string };
export function validatePassword(pw: string): PasswordCheck {
if (typeof pw !== "string" || pw.length < MIN_PASSWORD_LEN) {
return {
ok: false,
error: `Password must be at least ${MIN_PASSWORD_LEN} characters.`,
};
}
if (pw.length > MAX_PASSWORD_LEN) {
return { ok: false, error: "Password is too long." };
}
const hasLetter = /[A-Za-z]/.test(pw);
const hasNonLetter = /[^A-Za-z]/.test(pw);
if (!hasLetter || !hasNonLetter) {
return {
ok: false,
error: "Password must mix letters with numbers or symbols.",
};
}
return { ok: true };
}

View File

@ -6,18 +6,9 @@ export async function getDashboardStats(operatorId: string) {
const accounts = await db.query.whatsappAccounts.findMany({ const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId), where: (a, { eq }) => eq(a.operatorId, operatorId),
}); });
// Reminders scoped to this operator's accounts. The previous // All reminder rows so the dashboard can show active/total in one query.
// findMany() with no filter leaked global counts across users — a // Status enum today is active / ended (paused will join in a later phase).
// brand-new user would see another operator's totals on the const allReminders = await db.query.reminders.findMany();
// dashboard. INNER JOIN on whatsapp_accounts.operator_id keeps each
// user's view isolated.
const reminderRows = await db.execute(sql`
SELECT r.id, r.status
FROM reminders r
INNER JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId}
`);
const allReminders = reminderRows.rows as Array<{ id: string; status: string }>;
// LEFT JOIN so runs whose reminder has been deleted still appear. The // LEFT JOIN so runs whose reminder has been deleted still appear. The
// ownership filter widens to: either the reminder still exists and the // ownership filter widens to: either the reminder still exists and the
// operator owns its account, OR the reminder is gone but the run row // operator owns its account, OR the reminder is gone but the run row
@ -43,7 +34,7 @@ export async function getDashboardStats(operatorId: string) {
totalAccounts: accounts.length, totalAccounts: accounts.length,
activeReminders: allReminders.filter((r) => r.status === "active").length, activeReminders: allReminders.filter((r) => r.status === "active").length,
pausedReminders: allReminders.filter((r) => r.status === "paused").length, pausedReminders: allReminders.filter((r) => r.status === "paused").length,
inactiveReminders: allReminders.filter((r) => r.status === "inactive").length, endedReminders: allReminders.filter((r) => r.status === "ended").length,
totalReminders: allReminders.length, totalReminders: allReminders.length,
recentRuns: recentRuns.rows as Array<{ recentRuns: recentRuns.rows as Array<{
id: string; id: string;
@ -63,12 +54,9 @@ export async function listAccounts(operatorId: string) {
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows // exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
// by status produced phantom "I created an account but it's gone" // by status produced phantom "I created an account but it's gone"
// bug reports. // bug reports.
// Earliest-added on top, newest at the bottom. Stable across renames
// (a label edit shouldn't reorder the list and confuse muscle memory)
// and matches how other admin tools order accounts that grow over time.
return db.query.whatsappAccounts.findMany({ return db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId), where: (a, { eq }) => eq(a.operatorId, operatorId),
orderBy: (a, { asc }) => [asc(a.createdAt), asc(a.id)], orderBy: (a, { asc }) => [asc(a.label)],
}); });
} }
@ -82,19 +70,11 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
const account = await getAccount(operatorId, accountId); const account = await getAccount(operatorId, accountId);
if (!account) return null; if (!account) return null;
const trimmed = (q ?? "").trim(); const trimmed = (q ?? "").trim();
// Hide archived groups from the picker by default. They're rows
// that disappeared from the live participant list (group deleted,
// bot kicked, etc.) but still have reminder_targets pointing at
// them — see the soft-archive flow in apps/bot/src/whatsapp/
// group-sync.ts. Surfacing archived rows here would let an
// operator pick a group the bot can't actually reach.
const rows = trimmed const rows = trimmed
? await db.execute(sql` ? await db.execute(sql`
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
FROM whatsapp_groups FROM whatsapp_groups
WHERE account_id = ${accountId} WHERE account_id = ${accountId} AND name % ${trimmed}
AND is_archived = false
AND name % ${trimmed}
ORDER BY similarity(name, ${trimmed}) DESC ORDER BY similarity(name, ${trimmed}) DESC
LIMIT 50 LIMIT 50
`) `)
@ -102,7 +82,6 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
FROM whatsapp_groups FROM whatsapp_groups
WHERE account_id = ${accountId} WHERE account_id = ${accountId}
AND is_archived = false
ORDER BY name ASC ORDER BY name ASC
LIMIT 200 LIMIT 200
`); `);
@ -208,13 +187,11 @@ export async function listActivityRuns(
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder // exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
// has been deleted but history was preserved) in the list. // has been deleted but history was preserved) in the list.
// The `archived` flag flips the visibility filter: // The `archived` flag flips the visibility filter:
// false (default) — non-archived, non-skipped rows (skipped runs // false (default) — only non-archived rows
// belong to the Archived tab now) // true — only archived rows (for the Archived tab)
// true — archived rows OR skipped rows (they're treated
// as "history" rather than active outcomes)
const archivedClause = opts.archived const archivedClause = opts.archived
? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')` ? sql`rr.archived_at IS NOT NULL`
: sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`; : sql`rr.archived_at IS NULL`;
const rows = await db.execute(sql` const rows = await db.execute(sql`
SELECT SELECT
rr.id, rr.id,
@ -264,23 +241,11 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
where: (m, { eq }) => eq(m.reminderId, reminderId), where: (m, { eq }) => eq(m.reminderId, reminderId),
orderBy: (m, { asc }) => [asc(m.position)], orderBy: (m, { asc }) => [asc(m.position)],
}); });
// LEFT-JOIN aggregate counts in one round-trip so the detail page
// can render the paused banner with "X of Y groups delivered"
// without a per-run fan-out query. Counts are bigint in PG → cast
// to int so JSON marshalling stays lossless.
const runs = await db.execute(sql` const runs = await db.execute(sql`
SELECT SELECT id, fired_at, status, error_summary
rr.id, FROM reminder_runs
rr.fired_at, WHERE reminder_id = ${reminderId}
rr.status, ORDER BY fired_at DESC
rr.error_summary,
COALESCE(SUM(CASE WHEN rt.status = 'sent' THEN 1 ELSE 0 END)::int, 0) AS sent,
COALESCE(COUNT(rt.id)::int, 0) AS total
FROM reminder_runs rr
LEFT JOIN reminder_run_targets rt ON rt.run_id = rr.id
WHERE rr.reminder_id = ${reminderId}
GROUP BY rr.id, rr.fired_at, rr.status, rr.error_summary
ORDER BY rr.fired_at DESC
LIMIT 20 LIMIT 20
`); `);
return { return {
@ -296,8 +261,6 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
firedAt: r.fired_at as Date, firedAt: r.fired_at as Date,
status: r.status as string, status: r.status as string,
errorSummary: r.error_summary as string | null, errorSummary: r.error_summary as string | null,
sent: r.sent as number,
total: r.total as number,
})), })),
}; };
} }

View File

@ -73,7 +73,7 @@ describe("applyReminderFilter — account / group filters", () => {
}); });
it("status='all' or unset includes every status", () => { it("status='all' or unset includes every status", () => {
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "inactive" })]; const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "ended" })];
expect(applyReminderFilter(rows, { status: "all" }).map((r) => r.id)).toEqual(["a", "b"]); expect(applyReminderFilter(rows, { status: "all" }).map((r) => r.id)).toEqual(["a", "b"]);
expect(applyReminderFilter(rows, {}).map((r) => r.id)).toEqual(["a", "b"]); expect(applyReminderFilter(rows, {}).map((r) => r.id)).toEqual(["a", "b"]);
}); });
@ -81,7 +81,7 @@ describe("applyReminderFilter — account / group filters", () => {
it("status filters to the matching value", () => { it("status filters to the matching value", () => {
const rows = [ const rows = [
mk({ id: "a", status: "active" }), mk({ id: "a", status: "active" }),
mk({ id: "b", status: "inactive" }), mk({ id: "b", status: "ended" }),
mk({ id: "c", status: "paused" }), mk({ id: "c", status: "paused" }),
]; ];
expect(applyReminderFilter(rows, { status: "paused" }).map((r) => r.id)).toEqual(["c"]); expect(applyReminderFilter(rows, { status: "paused" }).map((r) => r.id)).toEqual(["c"]);
@ -152,7 +152,7 @@ describe("applyReminderFilter — combined", () => {
mk({ id: "match", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }), mk({ id: "match", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
mk({ id: "wrong-acc", name: "Daily ping", accountId: "acc-2", groupIds: ["g-1"], status: "active", ...base }), mk({ id: "wrong-acc", name: "Daily ping", accountId: "acc-2", groupIds: ["g-1"], status: "active", ...base }),
mk({ id: "wrong-group", name: "Daily ping", accountId: "acc-1", groupIds: ["g-9"], status: "active", ...base }), mk({ id: "wrong-group", name: "Daily ping", accountId: "acc-1", groupIds: ["g-9"], status: "active", ...base }),
mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "inactive", ...base }), mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "ended", ...base }),
mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }), mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
]; ];
expect( expect(

View File

@ -26,7 +26,7 @@ export interface ReminderFilter {
q?: string; q?: string;
accountId?: string; accountId?: string;
groupId?: string; groupId?: string;
status?: string; // "all" | "active" | "inactive" | "paused" status?: string; // "all" | "active" | "ended" | "paused"
sort?: SortKey; sort?: SortKey;
} }

View File

@ -59,11 +59,11 @@ describe("validateUpdateScheduledAt", () => {
if (r.ok) expect(r.scheduledAt.getTime()).toBe(PAST.getTime()); if (r.ok) expect(r.scheduledAt.getTime()).toBe(PAST.getTime());
}); });
it("inactive one-off, past timestamp matching existing → ALLOWED", () => { it("ended one-off, past timestamp matching existing → ALLOWED", () => {
const r = validateUpdateScheduledAt({ const r = validateUpdateScheduledAt({
iso: isoOf(PAST), iso: isoOf(PAST),
timezone: TZ, timezone: TZ,
existingStatus: "inactive", existingStatus: "ended",
existingScheduledAt: PAST, existingScheduledAt: PAST,
now: NOW, now: NOW,
}); });

View File

@ -34,7 +34,7 @@ export function validateUpdateScheduledAt(args: {
if (Number.isNaN(dt.getTime())) { if (Number.isNaN(dt.getTime())) {
return { ok: false, error: "Invalid date" }; return { ok: false, error: "Invalid date" };
} }
const isPaused = args.existingStatus === "paused" || args.existingStatus === "inactive"; const isPaused = args.existingStatus === "paused" || args.existingStatus === "ended";
const sameAsExisting = const sameAsExisting =
args.existingScheduledAt !== null && args.existingScheduledAt !== null &&
Math.abs(args.existingScheduledAt.getTime() - dt.getTime()) < 1000; Math.abs(args.existingScheduledAt.getTime() - dt.getTime()) < 1000;

View File

@ -1,43 +0,0 @@
import { describe, it, expect } from "vitest";
import { safeRedirect } from "./safe-redirect";
describe("safeRedirect", () => {
it("preserves a relative path that starts with a single slash", () => {
expect(safeRedirect("/dashboard")).toBe("/dashboard");
expect(safeRedirect("/reminders/new")).toBe("/reminders/new");
});
it("preserves query string and fragment", () => {
expect(safeRedirect("/legit?with=params&extra=fine#hash")).toBe(
"/legit?with=params&extra=fine#hash",
);
});
it("rejects protocol-relative URLs (//evil.com)", () => {
expect(safeRedirect("//evil.com")).toBe("/");
expect(safeRedirect("//evil.com/dashboard")).toBe("/");
});
it("rejects absolute URLs", () => {
expect(safeRedirect("https://evil.com")).toBe("/");
expect(safeRedirect("http://evil.com/dashboard")).toBe("/");
});
it("rejects javascript: and data: schemes", () => {
expect(safeRedirect("javascript:alert(1)")).toBe("/");
expect(safeRedirect("data:text/html,<script>x</script>")).toBe("/");
});
it("falls back to / for empty / null / undefined / whitespace input", () => {
expect(safeRedirect("")).toBe("/");
expect(safeRedirect(null)).toBe("/");
expect(safeRedirect(undefined)).toBe("/");
expect(safeRedirect(" ")).toBe("/");
});
it("rejects paths that don't start with / (relative-relative)", () => {
expect(safeRedirect("dashboard")).toBe("/");
expect(safeRedirect("./dashboard")).toBe("/");
expect(safeRedirect("../dashboard")).toBe("/");
});
});

View File

@ -1,16 +0,0 @@
/**
* Returns `next` if it is a safe relative path, otherwise "/".
*
* Safe means: starts with a single forward slash AND not "//" (which
* is a protocol-relative URL, e.g. //evil.com). Anything else falls
* back to the root including empty input, absolute URLs, javascript:
* URIs, and relative-relative paths like "dashboard" or "../foo".
*/
export function safeRedirect(next: string | null | undefined): string {
if (typeof next !== "string") return "/";
const s = next.trim();
if (s.length < 2) return "/";
if (!s.startsWith("/")) return "/";
if (s.startsWith("//")) return "/";
return s;
}

View File

@ -1,84 +0,0 @@
import { describe, it, expect, beforeAll } from "vitest";
import { NextRequest } from "next/server";
const SECRET = "test-secret";
beforeAll(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
});
import { signSession } from "./lib/auth-cookie";
import { middleware } from "./middleware";
async function makeReq(path: string, cookie?: string): Promise<NextRequest> {
const url = new URL(`https://wabot.04080616.xyz${path}`);
const headers = new Headers();
if (cookie) headers.set("cookie", `session=${cookie}`);
return new NextRequest(url, { headers });
}
async function validCookie(): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return signSession(
{
userId: "00000000-0000-0000-0000-000000000000",
role: "admin",
iat: now,
exp: now + 3600,
v: 1,
},
SECRET,
);
}
describe("middleware", () => {
it("page request without a cookie redirects to /login?next=…", async () => {
const r = await middleware(await makeReq("/dashboard"));
expect(r.status).toBe(307);
expect(r.headers.get("location")).toContain("/login");
expect(r.headers.get("location")).toContain("next=%2Fdashboard");
});
it("/api/* request without a cookie returns 401 with no body", async () => {
const r = await middleware(await makeReq("/api/events"));
expect(r.status).toBe(401);
});
it("page request with a valid cookie passes through", async () => {
const r = await middleware(await makeReq("/dashboard", await validCookie()));
// NextResponse.next() returns a 200 with the x-middleware-next header.
expect(r.status).toBe(200);
});
it("page request with a tampered cookie redirects to /login", async () => {
const cookie = (await validCookie()).slice(0, -4) + "AAAA";
const r = await middleware(await makeReq("/dashboard", cookie));
expect(r.status).toBe(307);
expect(r.headers.get("location")).toContain("/login");
});
it("allowlisted paths bypass auth (login, logout, health, manifest, icons)", async () => {
for (const path of [
"/login",
"/logout",
"/api/health",
"/manifest.webmanifest",
"/icon-192.png",
"/favicon.ico",
]) {
const r = await middleware(await makeReq(path));
expect(r.status).toBe(200);
}
});
it("/api/events and /api/qr/<id> are NOT in the allowlist (regression)", async () => {
expect((await middleware(await makeReq("/api/events"))).status).toBe(401);
expect(
(
await middleware(
await makeReq("/api/qr/11111111-1111-1111-1111-111111111111"),
)
).status,
).toBe(401);
});
});

View File

@ -1,41 +1,21 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { COOKIE_NAME, verifySession } from "./lib/auth-cookie";
const PUBLIC_PATHS = new Set<string>([ export function middleware(req: NextRequest) {
"/login",
"/logout",
"/api/health",
"/manifest.webmanifest",
"/favicon.ico",
"/robots.txt",
]);
function isPublic(path: string): boolean {
if (PUBLIC_PATHS.has(path)) return true;
if (path.startsWith("/icon-")) return true;
if (path.startsWith("/_next/")) return true;
return false;
}
export async function middleware(req: NextRequest): Promise<NextResponse> {
const path = req.nextUrl.pathname; const path = req.nextUrl.pathname;
if (isPublic(path)) return NextResponse.next();
const cookie = req.cookies.get(COOKIE_NAME)?.value; // Block all /api/* except a small set of read-only endpoints.
const secret = process.env.AUTH_SECRET; // Mutations happen via Server Actions which post to page URLs, not /api/*.
const ok = const allowed =
!!cookie && !!secret && (await verifySession(cookie, secret)) !== null; path === "/api/events" ||
if (ok) return NextResponse.next(); path === "/api/health" ||
path.startsWith("/api/qr/");
if (path.startsWith("/api/")) { if (path.startsWith("/api/") && !allowed) {
return new NextResponse("Unauthorized", { status: 401 }); return new NextResponse("Not Found", { status: 404 });
} }
const url = req.nextUrl.clone();
url.pathname = "/login"; return NextResponse.next();
url.searchParams.set("next", path + (req.nextUrl.search || ""));
return NextResponse.redirect(url);
} }
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image).*)"], matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"],
}; };

View File

@ -1,59 +0,0 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
assertJournalMonotonic,
formatJournalViolations,
type JournalEntry,
} from "@cmbot/db/journal-check";
/**
* CI guard against the recurring drizzle journal-skip bug.
*
* Drizzle's migrator orders entries by `when` (not `idx`) and only
* applies entries whose `when` is greater than the latest applied
* row's recorded `created_at`. We've shipped two breaking deploys
* (0010/0011 and 0012/0013) where freshly-generated migrations had
* `when` values older than a prior manually-bumped entry `pnpm
* migrate` printed "Migrations applied." while silently skipping
* the new SQL, and production 500'd until we hand-fixed the journal.
*
* This test reads the committed _journal.json and fails if the
* entries aren't strictly monotonically increasing by `when` in the
* same order as `idx`. Catches a bad commit at PR time instead of
* at the next deploy.
*/
describe("drizzle journal monotonicity (regression guard)", () => {
const journalPath = join(
__dirname,
"..",
"..",
"..",
"..",
"packages",
"db",
"migrations",
"meta",
"_journal.json",
);
const raw = JSON.parse(readFileSync(journalPath, "utf8")) as {
entries: JournalEntry[];
};
it("loads at least one journal entry (sanity)", () => {
expect(raw.entries.length).toBeGreaterThan(0);
});
it("`when` timestamps are strictly increasing in `idx` order", () => {
const result = assertJournalMonotonic(raw.entries);
if (!result.ok) {
// Print the same actionable message migrate.ts prints, so a
// failed CI run reads exactly like a failed local migrate.
// eslint-disable-next-line no-console
console.error(formatJournalViolations(result));
}
expect(result.violations).toEqual([]);
expect(result.ok).toBe(true);
});
});

View File

@ -1,112 +0,0 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
/**
* Static guard: no production `.tsx` file may pass `showCloseButton`
* to `<DialogFooter>`.
*
* Why: the shared DialogFooter renders an EXTRA outline-styled
* <Button>Close</Button> when `showCloseButton` is set. Every dialog
* we have that already provides its own primary action also includes
* a Cancel/dismiss button (either via DialogClose or by closing the
* Dialog state on submit) and Radix's auto-rendered corner X
* already gives users a third way out. The redundant Close button
* cluttered the footer and shipped to production multiple times
* before this guard existed; this test stops it from regressing.
*/
const SRC_ROOT = join(__dirname, "..");
function listTsxFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
out.push(...listTsxFiles(full));
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
interface Hit {
file: string;
line: number;
excerpt: string;
}
function findHits(content: string): Array<{ line: number; excerpt: string }> {
const hits: Array<{ line: number; excerpt: string }> = [];
// Match `<DialogFooter` with `showCloseButton` somewhere in the
// opening tag. Stops at `>` so we don't accidentally cross into the
// children. Multi-line opening tags are handled by `[\s\S]`.
const matches = content.matchAll(
/<DialogFooter\b[\s\S]*?showCloseButton\b[\s\S]*?>/g,
);
for (const m of matches) {
const idx = m.index ?? 0;
const line = content.slice(0, idx).split("\n").length;
hits.push({ line, excerpt: m[0].slice(0, 120).replace(/\s+/g, " ") });
}
return hits;
}
describe("static guard: no <DialogFooter showCloseButton>", () => {
// Skip this test file (it intentionally contains the pattern strings)
// and all other .test.tsx files (they're examples, not production UI).
const files = listTsxFiles(SRC_ROOT).filter(
(f) => !/\.test\.tsx?$/.test(f),
);
it("scans at least one source file (sanity)", () => {
expect(files.length).toBeGreaterThan(0);
});
it("finds no <DialogFooter showCloseButton ...> in any production .tsx file", () => {
const allHits: Hit[] = [];
for (const file of files) {
const content = readFileSync(file, "utf8");
for (const h of findHits(content)) {
allHits.push({ file: relative(SRC_ROOT, file), ...h });
}
}
if (allHits.length > 0) {
const message = allHits
.map((h) => ` ${h.file}:${h.line}${h.excerpt}`)
.join("\n");
throw new Error(
`Redundant Close button detected — <DialogFooter showCloseButton ...>:\n${message}\n` +
`The DialogFooter component injects an extra "Close" button when this prop\n` +
`is set. Every existing caller already has its own Cancel/Close action plus\n` +
`Radix's corner X — a third Close is just visual noise. Remove the prop.`,
);
}
expect(allHits).toEqual([]);
});
});
describe("findHits parser", () => {
it("matches a single-line <DialogFooter showCloseButton>", () => {
expect(
findHits("<DialogFooter showCloseButton>foo</DialogFooter>"),
).toHaveLength(1);
});
it("matches when other props are present alongside showCloseButton", () => {
expect(
findHits('<DialogFooter className="x" showCloseButton>'),
).toHaveLength(1);
});
it("matches across multiple lines", () => {
const src = `<DialogFooter\n className="x"\n showCloseButton\n>`;
expect(findHits(src)).toHaveLength(1);
});
it("does NOT match a clean <DialogFooter>", () => {
expect(findHits("<DialogFooter>x</DialogFooter>")).toHaveLength(0);
});
it("does NOT match a similarly-named prop on an unrelated component", () => {
expect(findHits("<SheetFooter showCloseButton>")).toHaveLength(0);
});
});

View File

@ -19,7 +19,7 @@ services:
MEDIA_DIR: ${MEDIA_DIR:-/data/media} MEDIA_DIR: ${MEDIA_DIR:-/data/media}
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081} BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info} BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
SEED_OPERATOR_USERNAME: ${SEED_OPERATOR_USERNAME:-admin} SEED_OPERATOR_TELEGRAM_ID: ${SEED_OPERATOR_TELEGRAM_ID:-0}
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator} SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
networks: networks:
- cmbot - cmbot
@ -36,8 +36,6 @@ services:
DATA_DIR: ${DATA_DIR} DATA_DIR: ${DATA_DIR}
MEDIA_DIR: ${MEDIA_DIR} MEDIA_DIR: ${MEDIA_DIR}
WEB_PORT: ${WEB_PORT} WEB_PORT: ${WEB_PORT}
AUTH_SECRET: ${AUTH_SECRET}
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
networks: networks:
- cmbot - cmbot

View File

@ -59,7 +59,5 @@ services:
DATA_DIR: ${DATA_DIR} DATA_DIR: ${DATA_DIR}
MEDIA_DIR: ${MEDIA_DIR} MEDIA_DIR: ${MEDIA_DIR}
WEB_PORT: ${WEB_PORT} WEB_PORT: ${WEB_PORT}
AUTH_SECRET: ${AUTH_SECRET}
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
depends_on: depends_on:
- tools - tools

View File

@ -1,111 +0,0 @@
# Portainer-ready stack. Pulls cm-whatsapp-{web,bot} from
# gitea.04080616.xyz/yiekheng instead of building from source — drop
# this file into a Portainer "Stack" (Repository or Web editor) and
# fill the env vars in the Portainer UI.
#
# Differences vs docker-compose.base.yml:
# - No `build:` blocks (Portainer pulls only).
# - Named volumes (cmbot-data, cmbot-sessions, cmbot-media) instead
# of host bind-mounts so the operator doesn't need shell access
# to manage persistent state.
# - Ports section on `web` so the operator can route a reverse
# proxy / Cloudflare Tunnel directly at the container.
# - `restart: unless-stopped` on both services.
#
# Required env vars (set in Portainer → Stack → Environment variables):
# DATABASE_URL postgres://USER:PASS@HOST:5432/wabot
# AUTH_SECRET 32-byte random hex (use scripts/gen_auth_secret.sh
# on any machine and copy the output)
# WEB_PORT host port for the web container (default 9000)
#
# Optional:
# DOCKER_IMAGE_TAG registry tag to deploy (default: latest)
# OPERATOR_TOKEN_VERSION session-cookie kill switch (default: 1)
# BOT_FIRE_CONCURRENCY pg-boss workers (default: 8)
# BOT_GROUP_CONCURRENCY per-account parallel sends (default: 3)
# BOT_MAX_SEND_PER_MINUTE per-account token-bucket rate (default: 40)
# BOT_LOG_LEVEL pino log level (default: info)
#
# Registry auth: Portainer needs a pull credential for
# gitea.04080616.xyz before you start the stack:
# Portainer → Registries → Add registry
# Name: gitea.04080616.xyz
# URL: gitea.04080616.xyz
# Username: <gitea user>
# Token: <gitea personal access token, read:packages>
# After adding, edit each service in the stack and set "Registry" to
# the one you just added so the pull resolves.
services:
bot:
image: gitea.04080616.xyz/yiekheng/cm-whatsapp-bot:${DOCKER_IMAGE_TAG:-latest}
container_name: cmbot-bot
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
DATA_DIR: /data
SESSIONS_DIR: /data/sessions
MEDIA_DIR: /data/media
BOT_HEALTH_PORT: 8081
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
BOT_FIRE_CONCURRENCY: ${BOT_FIRE_CONCURRENCY:-8}
BOT_GROUP_CONCURRENCY: ${BOT_GROUP_CONCURRENCY:-3}
BOT_MAX_SEND_PER_MINUTE: ${BOT_MAX_SEND_PER_MINUTE:-40}
volumes:
- cmbot-sessions:/data/sessions
- cmbot-media:/data/media
healthcheck:
test:
- "CMD-SHELL"
- "wget -qO- --timeout=2 http://127.0.0.1:8081/health >/dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
- cmbot
web:
image: gitea.04080616.xyz/yiekheng/cm-whatsapp-web:${DOCKER_IMAGE_TAG:-latest}
container_name: cmbot-web
restart: unless-stopped
depends_on:
- bot
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
DATA_DIR: /data
MEDIA_DIR: /data/media
WEB_PORT: 3000
AUTH_SECRET: ${AUTH_SECRET}
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
volumes:
# Web reads media from the same persistent volume the bot wrote.
- cmbot-media:/data/media:ro
ports:
# Maps the Next.js port (3000 inside the container) to whatever
# WEB_PORT the operator set. The reverse proxy / Cloudflare Tunnel
# in front of this host points at <host>:${WEB_PORT}.
- "${WEB_PORT:-9000}:3000"
healthcheck:
test:
- "CMD-SHELL"
- "wget -qO- --timeout=2 http://127.0.0.1:3000/api/health >/dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- cmbot
volumes:
cmbot-sessions:
name: cmbot-sessions
cmbot-media:
name: cmbot-media
networks:
cmbot:
driver: bridge
name: cmbot

View File

@ -26,13 +26,5 @@ COPY --from=build /app/node_modules /app/node_modules
COPY --from=build /app/apps/bot /app/apps/bot COPY --from=build /app/apps/bot /app/apps/bot
COPY --from=build /app/packages/db /app/packages/db COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/shared /app/packages/shared COPY --from=build /app/packages/shared /app/packages/shared
# Reuse the `node` user (UID/GID 1000) that node:alpine ships with —
# `addgroup -g 1000 app` failed in CI because gid 1000 was already
# taken by the node group. Same hardening posture (non-root, no
# shell login), one less moving part.
RUN mkdir -p /data/sessions /data/media /app && \
chown -R node:node /app /data && \
chmod 700 /data/sessions
USER node
EXPOSE 8081 EXPOSE 8081
CMD ["node", "apps/bot/dist/index.js"] CMD ["node", "apps/bot/dist/index.js"]

View File

@ -18,20 +18,7 @@ COPY tsconfig.base.json turbo.json ./
COPY apps/web apps/web COPY apps/web apps/web
COPY packages/db packages/db COPY packages/db packages/db
COPY packages/shared packages/shared COPY packages/shared packages/shared
# Placeholder env values during `next build`'s "Collecting page data" RUN pnpm --filter @cmbot/shared build && \
# pass. Next bundles `lib/db.ts` (`createClient(env.DATABASE_URL)`) and
# `actions/auth.ts` (uses AUTH_SECRET) into route bundles; their
# top-level env access fires when Next imports the route to inspect
# its config (the route's own `export const dynamic = "force-dynamic"`
# stops handler execution, NOT module evaluation).
#
# pg.Pool is lazy — it stores the URL and only connects on the first
# query — so a build-time placeholder never opens a socket. The
# placeholders are scoped to this RUN layer (each Dockerfile RUN is
# its own shell); nothing leaks into the runtime image.
RUN export DATABASE_URL=postgres://build:build@localhost:5432/build && \
export AUTH_SECRET=build-time-placeholder-not-used-at-runtime && \
pnpm --filter @cmbot/shared build && \
pnpm --filter @cmbot/db build && \ pnpm --filter @cmbot/db build && \
pnpm --filter @cmbot/web build pnpm --filter @cmbot/web build
@ -42,21 +29,5 @@ ENV HOSTNAME=0.0.0.0
COPY --from=build /app/apps/web/.next/standalone ./ COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /app/apps/web/public ./apps/web/public COPY --from=build /app/apps/web/public ./apps/web/public
# pnpm's workspace layout: each packages/<pkg>/node_modules/<dep> is a
# symlink into /app/node_modules/.pnpm/<dep>@<ver>/node_modules/<dep>
# where the real files live. Copying just packages/<pkg>/node_modules
# ships dangling symlinks. Bring the .pnpm content store across too so
# every symlink resolves at runtime; this is what unblocks the
# `Cannot find module 'rrule'` error from
# packages/shared/dist/rrule.js. Use --link to deduplicate the layer
# blobs inside docker so the runtime image stays slim despite the
# dot-pnpm tree being large.
COPY --link --from=build /app/node_modules/.pnpm ./node_modules/.pnpm
COPY --link --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --link --from=build /app/packages/db/node_modules ./packages/db/node_modules
# Reuse the `node` user (UID/GID 1000) that node:alpine ships with —
# `addgroup -g 1000 app` collided with the pre-existing node group.
RUN chown -R node:node /app
USER node
EXPOSE 3000 EXPOSE 3000
CMD ["node", "apps/web/server.js"] CMD ["node", "apps/web/server.js"]

View File

@ -1,172 +0,0 @@
# Deploying via Portainer
End-to-end deploy steps for a fresh Portainer-managed host. Targets
the standard cm-whatsapp-bot pair of images published by
`scripts/publish.sh`.
## 0. Prerequisites
- Portainer 2.x running on the target host (CE or EE both fine).
- A Postgres reachable from that host (the `wabot` database with the
pgcrypto / pg_trgm extensions enabled — run migrations from any
machine that can reach the DB before the stack is brought up).
- A pull credential for `gitea.04080616.xyz` — a Gitea personal
access token with the `read:packages` scope. Generate one in
Gitea → User Settings → Applications.
- A reverse proxy / Cloudflare Tunnel pointing at
`http://<portainer-host>:<WEB_PORT>` if the deploy needs to be
reachable on the public domain (e.g. `wabot.04080616.xyz`).
## 1. Add the registry to Portainer
Portainer → **Registries****+ Add registry** → Custom registry.
| Field | Value |
|---------------|-----------------------------|
| Name | `gitea.04080616.xyz` |
| Registry URL | `gitea.04080616.xyz` |
| Authentication | enabled |
| Username | your Gitea username |
| Password | the read:packages PAT |
Save. The registry must show as connected before continuing — if the
test pull fails, the stack will hang on `pull` later.
## 2. Push the images (on your dev machine)
```bash
# Login once (sudo path matches scripts/dev.sh by default)
sudo docker login gitea.04080616.xyz
# Push :latest. Tag explicitly with DOCKER_IMAGE_TAG=v1.x.y if you
# want pinned-tag deploys (recommended for prod — never deploy
# `latest` if you can avoid it; tag versions per release).
NO_SUDO=1 ./scripts/publish.sh latest
```
`publish.sh` builds + pushes both images:
- `gitea.04080616.xyz/yiekheng/cm-whatsapp-bot:<tag>`
- `gitea.04080616.xyz/yiekheng/cm-whatsapp-web:<tag>`
## 3. Create the Portainer stack
Portainer → **Stacks****+ Add stack**.
**Name:** `cm-whatsapp-bot`
**Build method:** "Web editor" or "Repository". Either is fine —
"Repository" pointing at this repo's `master` and the file
`docker-compose.portainer.yml` is the cleanest path because future
deploys are just "Pull and redeploy" inside Portainer.
**Web editor path:** copy the contents of
[`docker-compose.portainer.yml`](../docker-compose.portainer.yml)
into the editor verbatim.
**Repository path:**
| Field | Value |
|------------------|-------------------------------------------------------------|
| Repository URL | http://192.168.0.215:3000/yiekheng/cm_whatsapp_bot_v1.git |
| Reference | refs/heads/master |
| Compose path | docker-compose.portainer.yml |
| Authentication | enabled (same Gitea PAT as step 1) |
| Auto-update | optional — enabled lets Portainer redeploy on every push |
## 4. Set environment variables
In the same stack form, scroll to **Environment variables** and add:
| Key | Value |
|---------------------------|------------------------------------------------|
| `DATABASE_URL` | `postgres://wabot:PASS@192.168.0.210:5432/wabot` |
| `AUTH_SECRET` | output of `scripts/gen_auth_secret.sh` |
| `WEB_PORT` | host port (e.g. `9000`) |
| `DOCKER_IMAGE_TAG` | `latest` (or a pinned `v1.x.y`) |
| `OPERATOR_TOKEN_VERSION` | `1` (bump only when you want to invalidate every existing session) |
| `BOT_LOG_LEVEL` | `info` |
Optional tuning (defaults are fine for most installs):
| Key | Default | When to bump |
|---------------------------|---------|--------------|
| `BOT_FIRE_CONCURRENCY` | `8` | More accounts firing in parallel |
| `BOT_GROUP_CONCURRENCY` | `3` | More groups per fire — but careful with WhatsApp rate caps |
| `BOT_MAX_SEND_PER_MINUTE` | `40` | Aged accounts can push toward 60 |
## 5. Run database migrations
The stack does NOT auto-migrate on boot. Apply migrations from any
machine that can reach the same Postgres:
```bash
DATABASE_URL='postgres://...' \
./scripts/db.sh migrate
```
If the journal is non-monotonic, the migrate runner refuses with a
clear error and prints which `_journal.json` entry to bump (the
guard added in commit 47d7c53 + the CI test in
`apps/web/src/test/drizzle-journal-monotonic.test.ts`).
Then seed the bootstrap operator + set its password:
```bash
DATABASE_URL='postgres://...' SEED_OPERATOR_USERNAME=admin \
./scripts/db.sh seed
DATABASE_URL='postgres://...' \
./scripts/set-password.sh admin # reads the password from stdin
```
## 6. Deploy the stack
In Portainer → click **Deploy the stack**. Watch the container list
in **Containers**:
- `cmbot-bot` should show *running, healthy* within ~20 s.
- `cmbot-web` should show *running, healthy* within ~30 s (Next.js
cold boot is the bottleneck).
If a container shows *unhealthy*, check **Logs**:
| Symptom | Likely cause |
|----------------------------------------------|--------------|
| `column "email" does not exist` | Migrations weren't applied. Run step 5. |
| `Server is not configured for sign-in` | `AUTH_SECRET` blank or missing. Set it in stack env. |
| `pg-boss: queue policy ...standard` | Harmless first-boot log; the bot force-flips it. |
| `Stream Errored (restart required)` (Baileys) | Upstream noise; ignore unless pairing fails. |
## 7. First sign-in
Visit `https://<your-domain>/login`, sign in as `admin` with the
password set in step 5, and walk the
[`docs/runbook.md`](runbook.md) smoke checklist before declaring
the deploy good.
## 8. Future redeploys
Two paths depending on how you set up step 3:
**Web editor flow:**
1. Run `scripts/publish.sh <tag>` on your dev machine.
2. In Portainer → Stack → "Update the stack" → "Re-pull image and
redeploy".
**Repository flow:**
1. Run `scripts/publish.sh <tag>`.
2. Commit any compose / env changes to master.
3. Portainer → Stack → "Pull and redeploy". (If auto-update is on,
skip this — Portainer redeploys on every push.)
Always pin a tag (`v1.4.2`) instead of `latest` for production —
makes rollback a one-field stack edit instead of a republish.
## Rolling back
In Portainer → Stack → set `DOCKER_IMAGE_TAG=v1.4.1` (or whatever
the previous good tag was) → Re-pull and redeploy. The cmbot-* data
volumes (sessions, media) are preserved across image swaps, so a
rollback doesn't lose pairings or uploaded media.
If the schema also rolled back, run the corresponding `down` SQL by
hand — drizzle's migrator only goes forward, by design.

View File

@ -1,200 +0,0 @@
# Manual end-to-end runbook (v1)
Smoke checklist for verifying a fresh deploy. Unit tests don't catch
the live-Baileys / live-Postgres / browser-gesture path; this is what
you run before declaring a release good.
Time budget: ~10 minutes if everything works, ~30 if a step fails.
---
## Pre-flight
- [ ] **Stack up.**
`docker ps | grep cmbot` → expect `cmbot-tools`, `cmbot-bot`,
`cmbot-web` all `Up`.
- [ ] **Migrations clean.**
`NO_SUDO=1 scripts/db.sh migrate` → "Migrations applied." (and
*not* "Refusing to run drizzle migrate" — that's the journal
monotonicity guard tripping).
- [ ] **Web reachable.**
`curl -sf http://localhost:9000/api/health` → 200.
- [ ] **Bot reachable.**
`curl -sf http://localhost:8081/health` → 200.
If any pre-flight fails, fix before continuing.
---
## 1. Auth bootstrap
- [ ] `scripts/db.sh seed` (idempotent — only inserts the `admin`
operator if missing).
- [ ] `echo 'change-me-now' | scripts/set-password.sh admin` → "Password
updated."
- [ ] Open `http://localhost:9000/login` → enter `admin` / the password
→ redirected to `/`.
- [ ] **Wrong password three times in a row** still rate-limits but
with the generic "Too many attempts" message — no leak about
which limit (IP / username / global) tripped.
- [ ] Hit `/admin` URL while signed out → redirected to `/login` with
`?next=/admin`. After a successful login, lands back on `/admin`.
---
## 2. User management (admin-only)
- [ ] **Sidebar / drawer**: only one nav entry highlights at a time.
On `/settings/users`, only `Admin` lights up; `Settings` does
not.
- [ ] `/settings/users` → Add user → username `alice`, password
`alpha7!`, role `user` → "User created."
- [ ] `alice` row shows: username + `you` chip if applicable, role
pill, Promote / Reset / Delete buttons on row 2.
- [ ] Promote `alice` to admin → page revalidates, badge flips to
`admin`.
- [ ] Demote back to `user`.
- [ ] **Last-admin guard**: Demote / Delete on the only remaining
admin row are both disabled.
- [ ] Delete `alice` via the confirm dialog (Cancel + Delete user
buttons; **no third "Close" button** — the static guard test
catches that regression but eyeball it anyway).
---
## 3. Account pairing
- [ ] `/accounts` → New Account → label `WaBot Test` → Pair WhatsApp.
Land on the live QR page within ~2 s.
- [ ] Login screen header is JUST the centered brand mark — no nav,
no menu drawer.
- [ ] Scan with WhatsApp → "Linked Devices" → "Link a device".
- [ ] **Connection success.** Page transitions through `qr` → (brief
`restart-required` close handled silently) → `connected` with
a green check and `+60xxx` phone number → auto-redirect to
`/accounts/<id>` after 3 s.
- [ ] **Refresh Groups** button on `/accounts/<id>/groups` → spinner
during the sync, page auto-refreshes when the bot pushes
`groups.synced` over SSE. No manual reload needed.
### Pair regression checks (these caught real bugs)
- [ ] **Back → Re-pair**: from a live QR, click ← Back → Pair again
from the account detail page. Should NOT instantly flash
"Pairing timed out". A new QR appears and the countdown
restarts at 5:00.
- [ ] **Duplicate phone**: with one phone already paired, scan its QR
from a *second* account row → see the amber "Phone already
linked" panel naming the existing account. The original
account's session stays intact.
---
## 4. Reminder lifecycle
- [ ] `/reminders` → New Reminder → walk the wizard:
- Step 1: pick `WaBot Test`.
- Step 2: enter a short text message ("smoke test &lt;timestamp&gt;").
- Step 3: pick `Daily` recurrence, fire ~2 minutes from now.
Confirm "Pause sending by" checkbox is **unchecked by default**.
- Step 4: select 1 group.
- Step 5: review → Save.
- [ ] Reminder appears on `/reminders` with status `Active`.
Recurrence column shows the human-readable description; long
descriptions truncate with `…`.
- [ ] **Wait for the fire window.** When the time hits, the message
lands in the WhatsApp group **exactly once**.
- [ ] `/activity` → the run shows under `Success`. Default tab is
Success (no `All` tab).
- [ ] Swipe-left a row → Delete shelf appears. Swipe-right → Pause /
Restart shelf. Tapping a row navigates to its detail; dragging
does NOT navigate (6-px threshold).
- [ ] Pause the reminder → status flips to `Paused` immediately and
the next-fire-time disappears.
- [ ] Restart → fires on the next scheduled occurrence.
### Reminder regression checks
- [ ] **Triple-fire repro** (only if you have a tame group): edit
the reminder repeatedly within microseconds of each other (e.g.
the wizard Save button hammered three times). The message must
land **exactly once**. The bot logs should show
"duplicate fire detected inside mutex" warnings on the second
and third attempts.
- [ ] **Reschedule under existing job**: edit a recurring reminder's
schedule to a NEW time before its next-fire arrives. The new
time must fire (the old `created` job is now `cancelled` in
`pgboss.job`; verify with `select state, count(*) from
pgboss.job where name='reminder.fire' group by state`).
---
## 5. Account lifecycle
- [ ] **Unpair** the account from `/accounts/<id>`. Confirm dialog
(Cancel + Yes, unpair). The account row stays in the list with
"Unpaired" status; groups disappear from the picker (they're
soft-archived, not deleted).
- [ ] **Re-pair** the same account → groups come back via the
on-conflict upsert flipping `is_archived` back to false.
- [ ] **Delete** the account from `/accounts/<id>` → Confirm dialog →
the account vanishes from `/accounts`. Check on the *phone*'s
WhatsApp Linked Devices list — the entry is gone (the
logout-before-stop flow tells WhatsApp to drop it).
---
## 6. Sign-out + session lifetime
- [ ] **Sign out** from the sidebar / drawer footer → land on `/login`.
- [ ] Hit any protected URL → redirected to login.
- [ ] **Token-version kill switch**: set `OPERATOR_TOKEN_VERSION=2`
in `.env.development`, restart the web container. Every
previously-issued cookie is now invalid; every authenticated
request bounces to `/login`. Reset to `1` after.
---
## 7. Cross-tenant isolation
- [ ] Sign in as `admin`. Note dashboard counter values.
- [ ] As admin, create a second user `bob` and give them a fresh
account / reminder / fire it once.
- [ ] Sign out, sign in as `bob`. Dashboard counters MUST show only
bob's numbers (not admin's). `/reminders` lists only bob's
reminders. `/accounts` only bob's accounts.
---
## 8. Sweep
- [ ] `docker logs cmbot-web --since 10m | grep -iE 'error|'` — no
output (or only Baileys "Stream Errored (restart required)"
noise; that's upstream).
- [ ] `docker logs cmbot-bot --since 10m | grep -iE 'error|fatal'`
no output beyond the same Baileys upstream noise.
- [ ] `git status` clean (no leftover `_check.ts` or temp files).
---
## When a step fails
- **Migration refused** with "Refusing to run drizzle migrate":
open `packages/db/migrations/meta/_journal.json` and bump the
flagged entry's `when` to the suggested value. Re-run.
- **Pair shows immediate timeout**: bot logs should mention "ignoring
close from previous attempt while warming up" — that's the fix
working, but check a stale Baileys session isn't gummed up. Last
resort: `rm -rf dev-data/sessions/<accountId>` and re-pair.
- **Reminder fires twice**: check `pgboss.queue.policy` for
`reminder.fire` — must be `standard`, not `stately` (stately drops
reschedules silently). The `registerReminderJobs` boot hook
force-flips this on every bot start.
- **Delete didn't remove the linked-device entry on the phone**:
the bot's `socket.logout()` is best-effort — if the socket was
already disconnected when delete fired, the operator removes the
entry manually from WhatsApp's UI.
If any of the regression checks (Back→Re-pair, duplicate phone,
triple-fire, reschedule) fail, that's a real bug — capture the bot
log and file an issue before shipping.

File diff suppressed because it is too large Load Diff

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