Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa7387fca8 | |||
| 7d3d34af7f | |||
| b47c0409ae | |||
| f08b2bcb13 | |||
| 58b249097a | |||
| 6893ca6ba9 | |||
| 49f5c16b19 | |||
| 954d382b54 | |||
| 31cf845030 | |||
| ea7d07b2c8 | |||
| c906a9fa3a | |||
| 47d7c53fda | |||
| 27318888bc | |||
| b988d117a3 | |||
| d731390c9d | |||
| 08f2c0fd27 | |||
| 2fe8459d25 | |||
| f566e4683a | |||
| 7df3ef9c31 | |||
| 0fd581b365 | |||
| f4da1dd510 | |||
| 50b7e61037 | |||
| 89c7b1a84d | |||
| 32f87e1a92 | |||
| e32f633e02 | |||
| 429ae0827f | |||
| 496f882d9c | |||
| 3af0dc7ca7 | |||
| adaf087a5f | |||
| f69652d43b | |||
| 6942745085 | |||
| 2e6fbfa7a5 | |||
| 991b7ae0ab | |||
| b293bbf142 | |||
| a789b61e1f | |||
| e800882d15 | |||
| 5c48e0e85f | |||
| 40d788302c | |||
| d0db248460 | |||
| 7af7aa35d0 | |||
| 68668ef2cd | |||
| fe8e14b7a0 | |||
| dbdb156a09 | |||
| 6759ca8131 | |||
| 5d583d9194 | |||
| c493101b60 | |||
| b92ead3a97 | |||
| 4ddf5c094e | |||
| 797326e062 | |||
| ebbbdbdfb8 | |||
| 7ab51335a4 | |||
| 050292a282 | |||
| 1b7f553e24 | |||
| b29d137c84 | |||
| 67091c294a | |||
| b77a9d106d | |||
| 5b4787d10e | |||
| 4f1056cdcd | |||
| cedd623466 | |||
| d236196476 | |||
| e1ba1da2de | |||
| 27b7a3df1f | |||
| 838e129f37 | |||
| 46c0315559 | |||
| a37b36196d | |||
| 477e09f645 | |||
| feffe419db | |||
| 4cb4015666 | |||
| be3f28a1e6 | |||
| 2e1defaef6 | |||
| 309020fa5d | |||
| bb8d28a594 | |||
| 376bbe595b | |||
| 57786f9d09 |
@ -4,7 +4,7 @@ SESSIONS_DIR=/data/sessions
|
||||
MEDIA_DIR=/data/media
|
||||
BOT_HEALTH_PORT=8081
|
||||
BOT_LOG_LEVEL=debug
|
||||
SEED_OPERATOR_TELEGRAM_ID=818380985
|
||||
SEED_OPERATOR_USERNAME=admin
|
||||
SEED_OPERATOR_NAME="yiekheng (dev)"
|
||||
WEB_PORT=9000
|
||||
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -18,6 +18,13 @@ apps/web/public/swe-worker-*.js
|
||||
# ARE committed to this private Gitea. Only ignore example overrides:
|
||||
.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
|
||||
*.log
|
||||
|
||||
125
README.md
125
README.md
@ -6,24 +6,36 @@ the run history all from a phone home-screen icon.
|
||||
|
||||
## Status
|
||||
|
||||
**Plans 1, 2, and 3 complete.** The web app at `wabot.04080616.xyz` is
|
||||
the primary control surface; the Telegram bot has been removed.
|
||||
**v1 production-ready.** The web app at `wabot.04080616.xyz` is the
|
||||
primary control surface; the Telegram bot has been removed.
|
||||
|
||||
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.
|
||||
Mobile-first single-row header with a slide-out drawer; desktop
|
||||
sidebar.
|
||||
sidebar. Login lives outside the shell on a bare-header surface.
|
||||
- **Live QR pairing** — server-side Baileys session feeds the QR
|
||||
payload directly into the browser via Server-Sent Events. Scan,
|
||||
see "✅ Connected" within seconds, auto-redirect.
|
||||
- **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
|
||||
(Account → Message → When → Groups → Review) plus per-section edit
|
||||
pages so you don't have to walk the wizard end-to-end to fix one
|
||||
field. Active recurrence picker covers Daily / Weekly / Monthly /
|
||||
Yearly with multi-rule support and per-rule fire-time pickers; the
|
||||
rendered description reads as plain English ("Every week on Mon,
|
||||
Wed, Fri at 09:00") not raw cron.
|
||||
field. Recurrence picker covers Daily / Weekly / Monthly / Yearly
|
||||
with multi-rule support and per-rule fire-time pickers; the rendered
|
||||
description reads as plain English ("Every week on Mon, Wed, Fri at
|
||||
09:00") not raw cron. Optional "Pause sending by" deadline that
|
||||
defaults OFF — operators have to opt in explicitly.
|
||||
- **Multi-message stacks** — a reminder can carry multiple ordered
|
||||
parts (text + media), fired in sequence with a 1.5 s gap. Media
|
||||
files swap at any time from the Edit Message page.
|
||||
@ -33,19 +45,29 @@ What's working today:
|
||||
as a downloadable file instead of failing silently.
|
||||
- **Swipe-to-act rows** — on mobile, swipe a reminder or activity
|
||||
row left for Delete or right for Pause/Restart/Archive. iOS-Mail
|
||||
style.
|
||||
style. Click vs drag is disambiguated by a 6-px tap threshold so a
|
||||
swipe doesn't accidentally trigger the row's link.
|
||||
- **Activity tab** — last 200 runs with status filters (Success /
|
||||
Partial / Failed / Skipped) plus an Archived tab. Archive a noisy
|
||||
run to keep the main list readable; restore later. Hard-delete
|
||||
always available. Run history survives a reminder deletion.
|
||||
Paused / Failed / Archived). Partial runs surface under both Paused
|
||||
and Failed; Skipped runs collapse into Archived. Hard-delete and
|
||||
archive both available; run history survives a reminder deletion.
|
||||
- **Auto-reconnect on transient drops; restart-survival via Baileys
|
||||
session persistence.** Pair once, the device stays linked across
|
||||
container restarts.
|
||||
- **All actions audited.** Reminder run history queryable from the
|
||||
UI; per-run target results (sent / failed / skipped) preserved
|
||||
even when the underlying group is removed.
|
||||
container restarts. Logout-on-delete cleans the operator's
|
||||
linked-devices list on the WhatsApp side too.
|
||||
- **Hardened pg-boss scheduling** — three-tier dedupe so a triple-
|
||||
click Save or microsecond-spaced enqueue doesn't fire a reminder
|
||||
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: **249 web + 31 shared + 26 bot = 306** passing.
|
||||
Test count: **482 web + 88 bot = 570** passing.
|
||||
|
||||
## Host requirements
|
||||
|
||||
@ -79,24 +101,28 @@ Prerequisites: Docker, the `wabot` database + `waBot` role on
|
||||
# 1. Configure env
|
||||
cp envs/.env.example .env.development
|
||||
# edit .env.development: real DATABASE_URL, plus the LAN host to expose
|
||||
scripts/gen_auth_secret.sh --write
|
||||
scripts/gen_auth_secret.sh --write # writes AUTH_SECRET to .env.development
|
||||
|
||||
# 2. Bring up the stack, install deps
|
||||
NO_SUDO=1 scripts/dev.sh up
|
||||
NO_SUDO=1 scripts/dev.sh pnpm install
|
||||
|
||||
# 3. Apply migrations and seed your operator row
|
||||
# 3. Apply migrations and seed the bootstrap operator row
|
||||
NO_SUDO=1 scripts/db.sh migrate
|
||||
NO_SUDO=1 scripts/db.sh seed
|
||||
|
||||
# 4. Open the web app
|
||||
# 4. Set the bootstrap admin password (NO password is set by seed)
|
||||
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
|
||||
# LAN: http://<host-ip>:9000 (e.g. http://192.168.0.253:9000)
|
||||
# Public: https://wabot.04080616.xyz (whatever your reverse proxy serves)
|
||||
# LAN: http://<host-ip>:9000
|
||||
# Public: https://wabot.04080616.xyz
|
||||
```
|
||||
|
||||
Pair an account: `/accounts` → "New Account" → enter a label →
|
||||
"Pair WhatsApp" → scan the QR with WhatsApp's "Linked Devices".
|
||||
Inside the app: `/settings/users` → Add user → invite teammates with
|
||||
`user` role; promote / demote / reset password / delete from the same
|
||||
page. The "Admin" nav entry is admin-only.
|
||||
|
||||
PWA install: phone Chrome → menu → "Install App" / "Add to Home
|
||||
Screen". Launches fullscreen.
|
||||
@ -104,10 +130,22 @@ Screen". Launches fullscreen.
|
||||
`NO_SUDO=1` is the right setting if your user is in the `docker`
|
||||
group (the default for this repo). Drop it if you need `sudo docker`.
|
||||
|
||||
## 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
|
||||
|
||||
End-to-end checks that unit tests can't cover (live Baileys,
|
||||
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).
|
||||
|
||||
## Layout
|
||||
@ -118,11 +156,14 @@ WhatsApp delivery, swipe gestures):
|
||||
- `packages/db/` — Drizzle schema and migrations
|
||||
- `packages/shared/` — cross-app helpers (rrule, media paths,
|
||||
timezones, WhatsApp media classifier)
|
||||
- `docs/superpowers/specs/` — design specs and manual test runbooks
|
||||
- `docs/runbook.md` — manual end-to-end smoke checklist
|
||||
- `docs/superpowers/specs/` — design specs and earlier manual test
|
||||
runbooks
|
||||
- `docs/superpowers/plans/` — implementation plans
|
||||
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`,
|
||||
`web.Dockerfile`)
|
||||
- `scripts/` — `dev.sh`, `db.sh`, `gen_auth_secret.sh`
|
||||
- `scripts/` — `dev.sh`, `db.sh`, `gen_auth_secret.sh`,
|
||||
`set-password.sh`, `create-user.sh`
|
||||
|
||||
## Scripts
|
||||
|
||||
@ -134,17 +175,39 @@ 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/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/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).
|
||||
|
||||
## 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
|
||||
|
||||
- **Standalone media library** browser (currently media is uploaded
|
||||
per-reminder).
|
||||
- **E2E browser tests** (Playwright) on the swipe and pairing flows.
|
||||
- **Auth** (passkeys / email-password) — bring back if URL exposure
|
||||
becomes a concern. Today the app trusts whatever's in front of the
|
||||
reverse proxy.
|
||||
- **Multi-operator** — schema supports `operator_id` on every row,
|
||||
but the seed runs as a single operator and there's no /signup or
|
||||
invite flow yet.
|
||||
- **Search-as-you-type in the wizard's groups picker** — at 3 000+
|
||||
groups per account the picker still loads the alphabetical
|
||||
top-200; operators with >200 groups need to use the list page's
|
||||
search to find anything past 'L'.
|
||||
- **Self-service password reset** (email link, etc.) — out of scope
|
||||
for v1; admins use the Users page.
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
registerDefaultHandlers,
|
||||
} from "./ipc/command-consumer.js";
|
||||
import { sweepStalePendingAccounts } from "./ipc/pair-handler.js";
|
||||
import { sweepStalePendingRuns } from "./scheduler/sweep-stale-runs.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
logger.info("bot starting");
|
||||
@ -22,6 +23,7 @@ async function main(): Promise<void> {
|
||||
const stopConsumer = await startCommandConsumer();
|
||||
|
||||
await sweepStalePendingAccounts();
|
||||
await sweepStalePendingRuns();
|
||||
await sessionManager.resumeFromDb();
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
|
||||
@ -3,17 +3,26 @@ import type { Notification } from "pg";
|
||||
import { logger } from "../logger.js";
|
||||
import { env } from "../env.js";
|
||||
import { handleStartPairing } from "./pair-handler.js";
|
||||
import { handleUnpair } from "./unpair-handler.js";
|
||||
import { handleUnpair, handleDelete } from "./unpair-handler.js";
|
||||
import { handleSyncGroups } from "./sync-groups-handler.js";
|
||||
import { handleSendTest } from "./send-test-handler.js";
|
||||
import { handleScheduleReminder } from "./schedule-reminder-handler.js";
|
||||
import {
|
||||
handleScheduleReminder,
|
||||
handleResumeReminder,
|
||||
} from "./schedule-reminder-handler.js";
|
||||
|
||||
export type BotCommand =
|
||||
| { type: "account.start_pairing"; 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: "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>;
|
||||
const handlers: { [K in BotCommand["type"]]?: (cmd: Extract<BotCommand, { type: K }>) => Promise<void> } = {};
|
||||
@ -70,6 +79,9 @@ export function registerDefaultHandlers(): void {
|
||||
registerHandler("account.unpair", async (cmd) => {
|
||||
await handleUnpair(cmd.accountId);
|
||||
});
|
||||
registerHandler("account.delete", async (cmd) => {
|
||||
await handleDelete(cmd.accountId);
|
||||
});
|
||||
registerHandler("account.sync_groups", async (cmd) => {
|
||||
await handleSyncGroups(cmd.accountId);
|
||||
});
|
||||
@ -79,4 +91,7 @@ export function registerDefaultHandlers(): void {
|
||||
registerHandler("reminder.schedule", async (cmd) => {
|
||||
await handleScheduleReminder(cmd.reminderId, cmd.scheduledAtIso);
|
||||
});
|
||||
registerHandler("reminder.resume", async (cmd) => {
|
||||
await handleResumeReminder(cmd.reminderId, cmd.runId);
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,8 +10,28 @@ export type WebEvent =
|
||||
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
||||
| { type: "session.disconnected"; 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: "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 }
|
||||
// The web action enqueues a send_test via pg_notify and shows
|
||||
// "Sending…" optimistically. This event closes the loop.
|
||||
|
||||
@ -10,11 +10,23 @@ import { renderQrPng } from "../whatsapp/qr-renderer.js";
|
||||
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
|
||||
import { writeAuditLog } from "../audit.js";
|
||||
import { pgNotifyWeb } from "./notify.js";
|
||||
import {
|
||||
decidePairListenerOnClose,
|
||||
findDuplicateExistingAccount,
|
||||
nextWarmingUpAfterEvent,
|
||||
} from "./pair-state.js";
|
||||
|
||||
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const offByAccount = new Map<string, () => void>();
|
||||
const lastQrPayload = new Map<string, string>();
|
||||
const pairTimeouts = new Map<string, NodeJS.Timeout>();
|
||||
// "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 }> {
|
||||
const account = await db.query.whatsappAccounts.findFirst({
|
||||
@ -34,6 +46,7 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
|
||||
pairTimeouts.delete(accountId);
|
||||
}
|
||||
lastQrPayload.delete(accountId);
|
||||
pairingWarmingUp.delete(accountId);
|
||||
if (sessionManager.hasSession(accountId)) {
|
||||
await sessionManager.stop(accountId);
|
||||
}
|
||||
@ -80,10 +93,17 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
.set({ lastQrPng: null })
|
||||
.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) => {
|
||||
if (id !== accountId) return;
|
||||
try {
|
||||
if (event.type === "qr") {
|
||||
pairingWarmingUp.delete(id);
|
||||
// Dedupe by payload — Baileys can re-emit the same QR string in a
|
||||
// burst. Different strings (a fresh QR) always pass through, so
|
||||
// the user gets a new QR as soon as Baileys generates one.
|
||||
@ -102,6 +122,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
ts: Date.now(),
|
||||
});
|
||||
} else if (event.type === "open") {
|
||||
pairingWarmingUp.delete(id);
|
||||
const t = pairTimeouts.get(id);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
@ -109,6 +130,53 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
}
|
||||
lastQrPayload.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);
|
||||
let synced = 0;
|
||||
if (session) {
|
||||
@ -134,27 +202,42 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
count: synced,
|
||||
});
|
||||
off();
|
||||
} else if (event.type === "close" && event.restartRequired) {
|
||||
} else if (event.type === "close") {
|
||||
const decision = decidePairListenerOnClose({
|
||||
warmingUp: pairingWarmingUp.has(id),
|
||||
restartRequired: event.restartRequired,
|
||||
});
|
||||
if (decision === "ignore-leaked-close") {
|
||||
logger.info(
|
||||
{ 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 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`.
|
||||
// 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",
|
||||
);
|
||||
// The session-manager handles the actual reconnect; nothing to
|
||||
// do here other than NOT tear our listener / DB state down.
|
||||
} else if (event.type === "close") {
|
||||
// During the pairing window, any other close means the QR window
|
||||
// ended without a successful link — Baileys' default is to
|
||||
// close after exhausting QR refs (~2.5 min). Surface this to
|
||||
// the UI so the user gets a "pairing timed out" screen, and
|
||||
// park the row in a stable state so it shows up cleanly on
|
||||
// the accounts list with a "Re-pair" affordance.
|
||||
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);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
|
||||
@ -2,6 +2,9 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
decideOnPairClose,
|
||||
decideOnPairTimeout,
|
||||
decidePairListenerOnClose,
|
||||
findDuplicateExistingAccount,
|
||||
nextWarmingUpAfterEvent,
|
||||
shouldAutoReconnect,
|
||||
} from "./pair-state.js";
|
||||
|
||||
@ -82,3 +85,225 @@ describe("shouldAutoReconnect", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -80,3 +80,106 @@ export function decideOnPairTimeout({ current }: { current: AccountStatus }): St
|
||||
if (current !== "pending") return null;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
import { getBoss } from "../scheduler/pgboss-client.js";
|
||||
import { scheduleReminderFire } from "../scheduler/reminder-jobs.js";
|
||||
import {
|
||||
scheduleReminderFire,
|
||||
enqueueReminderResume,
|
||||
} from "../scheduler/reminder-jobs.js";
|
||||
|
||||
export async function handleScheduleReminder(reminderId: string, scheduledAtIso: string): Promise<void> {
|
||||
await scheduleReminderFire(getBoss(), reminderId, new Date(scheduledAtIso));
|
||||
}
|
||||
|
||||
export async function handleResumeReminder(
|
||||
reminderId: string,
|
||||
runId: string,
|
||||
): Promise<void> {
|
||||
await enqueueReminderResume(getBoss(), reminderId, runId);
|
||||
}
|
||||
|
||||
128
apps/bot/src/ipc/unpair-handler.test.ts
Normal file
128
apps/bot/src/ipc/unpair-handler.test.ts
Normal file
@ -0,0 +1,128 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -39,3 +39,41 @@ export async function handleUnpair(accountId: string): Promise<void> {
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
@ -18,13 +18,24 @@ const getReminderMock = vi.fn();
|
||||
vi.mock("../reminders/crud.js", () => ({
|
||||
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", () => ({
|
||||
db: {
|
||||
insert: () => ({ values: () => ({ returning: async () => [{ id: "run-1" }] }) }),
|
||||
insert: () => ({
|
||||
values: () => ({
|
||||
returning: async () => [{ id: "run-1" }],
|
||||
}),
|
||||
// Targets path: no .returning() chained.
|
||||
values_no_returning: async () => undefined,
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => undefined }) }),
|
||||
query: {
|
||||
whatsappGroups: { findMany: async () => [] },
|
||||
mediaFiles: { findMany: async () => [] },
|
||||
reminderRunTargets: { findMany: async () => [] },
|
||||
reminderRuns: { findFirst: (...args: unknown[]) => findExistingRunMock(...args) },
|
||||
},
|
||||
},
|
||||
}));
|
||||
@ -43,6 +54,7 @@ describe("fireReminder", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(accountMutex.run).mockClear();
|
||||
getReminderMock.mockReset();
|
||||
findExistingRunMock.mockReset();
|
||||
});
|
||||
|
||||
it("acquires accountMutex keyed by accountId for active reminders", async () => {
|
||||
@ -56,6 +68,8 @@ describe("fireReminder", () => {
|
||||
scheduleKind: "one_off",
|
||||
rrule: null,
|
||||
timezone: "Asia/Kuala_Lumpur",
|
||||
deliveryWindowStartHour: 6,
|
||||
deliveryWindowEndHour: 18,
|
||||
name: "Test",
|
||||
});
|
||||
|
||||
@ -69,13 +83,15 @@ describe("fireReminder", () => {
|
||||
getReminderMock.mockResolvedValue({
|
||||
id: "r-1",
|
||||
accountId: "acct-A",
|
||||
status: "ended",
|
||||
status: "inactive",
|
||||
targets: [],
|
||||
messages: [],
|
||||
createdBy: "op-1",
|
||||
scheduleKind: "one_off",
|
||||
rrule: null,
|
||||
timezone: "Asia/Kuala_Lumpur",
|
||||
deliveryWindowStartHour: 6,
|
||||
deliveryWindowEndHour: 18,
|
||||
name: "Test",
|
||||
});
|
||||
|
||||
@ -92,6 +108,111 @@ describe("fireReminder", () => {
|
||||
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 () => {
|
||||
getReminderMock.mockResolvedValueOnce({
|
||||
id: "r-A",
|
||||
@ -103,6 +224,8 @@ describe("fireReminder", () => {
|
||||
scheduleKind: "one_off",
|
||||
rrule: null,
|
||||
timezone: "Asia/Kuala_Lumpur",
|
||||
deliveryWindowStartHour: 6,
|
||||
deliveryWindowEndHour: 18,
|
||||
name: "A",
|
||||
});
|
||||
getReminderMock.mockResolvedValueOnce({
|
||||
@ -115,6 +238,8 @@ describe("fireReminder", () => {
|
||||
scheduleKind: "one_off",
|
||||
rrule: null,
|
||||
timezone: "Asia/Kuala_Lumpur",
|
||||
deliveryWindowStartHour: 6,
|
||||
deliveryWindowEndHour: 18,
|
||||
name: "B",
|
||||
});
|
||||
|
||||
|
||||
@ -12,7 +12,12 @@ import { readFile } from "node:fs/promises";
|
||||
import { db } from "../db.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { sessionManager } from "../whatsapp/session-manager.js";
|
||||
import { absoluteMediaPath, nextOccurrence, resolveDeliveryKind } from "@cmbot/shared";
|
||||
import {
|
||||
absoluteMediaPath,
|
||||
nextOccurrence,
|
||||
resolveDeliveryKind,
|
||||
windowEndAt,
|
||||
} from "@cmbot/shared";
|
||||
import { env } from "../env.js";
|
||||
import { writeAuditLog } from "../audit.js";
|
||||
import { getReminderWithDetails } from "../reminders/crud.js";
|
||||
@ -23,7 +28,23 @@ import { accountMutex } from "./per-key-mutex.js";
|
||||
import { accountRateLimiter } from "./rate-limiter.js";
|
||||
import { MediaUploadCache } from "./media-upload-cache.js";
|
||||
|
||||
export type FireReminderPayload = { reminderId: string };
|
||||
export type FireReminderPayload = {
|
||||
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
|
||||
* visible ordering in the chat at WA's natural pace. */
|
||||
@ -64,20 +85,101 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
|
||||
logger.warn({ reminderId: payload.reminderId }, "fire-reminder: reminder not found");
|
||||
return;
|
||||
}
|
||||
if (reminder.status !== "active") {
|
||||
logger.info({ reminderId: reminder.id, status: reminder.status }, "fire-reminder: skipping (not active)");
|
||||
// Resumes are allowed even when the reminder's lifecycle status is
|
||||
// 'paused' — we WANT to take a paused reminder back to active mid-
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
// (running them concurrently would double the effective send rate
|
||||
// and risk a ban). Different accounts run in parallel.
|
||||
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder));
|
||||
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder, payload.runId));
|
||||
}
|
||||
|
||||
async function fireReminderInner(
|
||||
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
|
||||
resumeRunId?: string,
|
||||
): Promise<void> {
|
||||
// Resume path attaches to the existing run row; fresh path inserts a new one.
|
||||
let runId: string;
|
||||
if (resumeRunId) {
|
||||
const existing = await db.query.reminderRuns.findFirst({
|
||||
where: (r, { eq: dEq }) => dEq(r.id, resumeRunId),
|
||||
});
|
||||
if (!existing) {
|
||||
logger.warn(
|
||||
{ reminderId: reminder.id, resumeRunId },
|
||||
"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({
|
||||
@ -86,17 +188,25 @@ async function fireReminderInner(
|
||||
status: "pending",
|
||||
})
|
||||
.returning({ id: reminderRuns.id });
|
||||
const runId = run!.id;
|
||||
runId = run!.id;
|
||||
}
|
||||
|
||||
const session = sessionManager.getSession(reminder.accountId);
|
||||
if (!session) {
|
||||
logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected");
|
||||
if (!resumeRunId) {
|
||||
await markAllSkipped(runId, reminder, "account not connected");
|
||||
}
|
||||
await db
|
||||
.update(reminderRuns)
|
||||
.set({ status: "skipped", errorSummary: "account not connected" })
|
||||
.where(eq(reminderRuns.id, runId));
|
||||
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status: "skipped" });
|
||||
await pgNotifyWeb({
|
||||
type: "reminder.fired",
|
||||
reminderId: reminder.id,
|
||||
runId,
|
||||
status: "skipped",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -115,8 +225,9 @@ async function fireReminderInner(
|
||||
: [];
|
||||
const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
|
||||
|
||||
// Pre-create run_target rows so the Activity tab shows progress mid-run.
|
||||
if (reminder.targets.length > 0) {
|
||||
// Pre-create run_target rows on the fresh path so the Activity tab
|
||||
// shows progress mid-run. Resume reuses the existing rows.
|
||||
if (!resumeRunId && reminder.targets.length > 0) {
|
||||
await db.insert(reminderRunTargets).values(
|
||||
reminder.targets.map((t) => ({
|
||||
runId,
|
||||
@ -127,11 +238,44 @@ async function fireReminderInner(
|
||||
);
|
||||
}
|
||||
|
||||
// Per-run media upload cache. Each unique mediaId is prepared via
|
||||
// generateWAMessageContent ONCE (which uploads to WA's CDN through
|
||||
// the socket's waUploadToServer); the resulting proto.Message is
|
||||
// reused for every group via socket.relayMessage. For 1000 groups
|
||||
// × 5 MB image, this turns 5 GB of upload into 5 MB.
|
||||
// On resume, only the still-pending rows are processed. On a fresh
|
||||
// fire that's every row since we just inserted them all as pending.
|
||||
const pendingRows = await db.query.reminderRunTargets.findMany({
|
||||
where: (t, { eq: dEq, and: dAnd }) => dAnd(dEq(t.runId, runId), dEq(t.status, "pending")),
|
||||
});
|
||||
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 media = mediaById.get(mediaId);
|
||||
if (!media) throw new Error(`media row missing: ${mediaId}`);
|
||||
@ -157,19 +301,26 @@ async function fireReminderInner(
|
||||
});
|
||||
});
|
||||
|
||||
// Per-account rate limiter — gates each socket send to stay within
|
||||
// the account's safe band (BOT_MAX_SEND_PER_MINUTE, default 40).
|
||||
// Per-account rate limiter — gates each socket send.
|
||||
const rateLimiter = accountRateLimiter.get(reminder.accountId);
|
||||
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let windowClosed = false;
|
||||
|
||||
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
|
||||
|
||||
await Promise.all(
|
||||
reminder.targets.map((target) =>
|
||||
targetsToProcess.map((target) =>
|
||||
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);
|
||||
if (!group) {
|
||||
await db
|
||||
@ -187,8 +338,6 @@ async function fireReminderInner(
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
// Once per group, before the first send. sendMessage handles
|
||||
// sessions internally; relayMessage does not.
|
||||
await ensureGroupSessions(session.socket, group.waGroupJid);
|
||||
|
||||
let lastMessageId: string | undefined;
|
||||
@ -242,14 +391,37 @@ 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;
|
||||
let status: "success" | "partial" | "failed";
|
||||
const totalSent = priorSentCount + sentCount;
|
||||
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;
|
||||
if (sentCount === total) {
|
||||
if (windowClosed && remainingPending > 0 && totalSent > 0) {
|
||||
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";
|
||||
} else if (sentCount > 0) {
|
||||
} else if (totalSent > 0) {
|
||||
status = "partial";
|
||||
errorSummary = `${sentCount} of ${total} groups delivered (${failedCount} failed, ${skippedCount} skipped).`;
|
||||
errorSummary = `${totalSent} of ${total} groups delivered (${totalFailed} failed, ${skippedCount} skipped).`;
|
||||
} else {
|
||||
status = "failed";
|
||||
errorSummary = total === 0 ? "No targets attached to reminder." : `All ${total} sends failed.`;
|
||||
@ -260,18 +432,45 @@ async function fireReminderInner(
|
||||
.set({ status, errorSummary })
|
||||
.where(eq(reminderRuns.id, runId));
|
||||
|
||||
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status });
|
||||
await pgNotifyWeb({
|
||||
type: "reminder.fired",
|
||||
reminderId: reminder.id,
|
||||
runId,
|
||||
status,
|
||||
sent: totalSent,
|
||||
total,
|
||||
});
|
||||
|
||||
// Lifecycle bookkeeping. Skip when the run is paused — the reminder
|
||||
// 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
|
||||
.update(reminders)
|
||||
.set({ status: "paused", updatedAt: new Date() })
|
||||
.where(eq(reminders.id, reminder.id));
|
||||
logger.info(
|
||||
{ reminderId: reminder.id, runId, totalSent, remainingPending },
|
||||
"fire-reminder: paused — leaving lifecycle alone for resume",
|
||||
);
|
||||
} else {
|
||||
if (reminder.scheduleKind === "one_off") {
|
||||
await db
|
||||
.update(reminders)
|
||||
.set({ status: "ended", updatedAt: new Date() })
|
||||
.set({ status: "inactive", updatedAt: new Date() })
|
||||
.where(eq(reminders.id, reminder.id));
|
||||
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
|
||||
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
|
||||
await db
|
||||
.update(reminders)
|
||||
.set({ lastFiredAt: new Date(), updatedAt: new Date() })
|
||||
.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 {
|
||||
@ -282,7 +481,8 @@ async function fireReminderInner(
|
||||
}
|
||||
} else {
|
||||
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
|
||||
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
|
||||
await db.update(reminders).set({ status: "inactive" }).where(eq(reminders.id, reminder.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -296,7 +496,15 @@ async function fireReminderInner(
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
119
apps/bot/src/scheduler/reminder-jobs.test.ts
Normal file
119
apps/bot/src/scheduler/reminder-jobs.test.ts
Normal file
@ -0,0 +1,119 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -1,12 +1,39 @@
|
||||
import type { PgBoss } from "pg-boss";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { logger } from "../logger.js";
|
||||
import { env } from "../env.js";
|
||||
import { db } from "../db.js";
|
||||
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
|
||||
|
||||
export const REMINDER_FIRE_QUEUE = "reminder.fire";
|
||||
|
||||
export async function registerReminderJobs(boss: PgBoss): Promise<void> {
|
||||
await boss.createQueue(REMINDER_FIRE_QUEUE);
|
||||
// 'standard' (the default) lets us enqueue a new fire even when an
|
||||
// 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>(
|
||||
REMINDER_FIRE_QUEUE,
|
||||
{
|
||||
@ -34,6 +61,33 @@ export async function scheduleReminderFire(
|
||||
reminderId: string,
|
||||
scheduledAt: Date,
|
||||
): 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(
|
||||
REMINDER_FIRE_QUEUE,
|
||||
{ reminderId },
|
||||
@ -42,14 +96,41 @@ export async function scheduleReminderFire(
|
||||
retryLimit: 3,
|
||||
retryDelay: 30,
|
||||
retryBackoff: true,
|
||||
// Use the reminderId as a singleton key so re-scheduling cancels the old job
|
||||
singletonKey: `reminder:${reminderId}`,
|
||||
// Singleton key kept on the job row for diagnostics + the
|
||||
// pre-send cancel above, even though 'standard' policy doesn't
|
||||
// dedupe by it.
|
||||
singletonKey,
|
||||
},
|
||||
);
|
||||
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");
|
||||
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> {
|
||||
// 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
|
||||
|
||||
76
apps/bot/src/scheduler/sweep-stale-runs.test.ts
Normal file
76
apps/bot/src/scheduler/sweep-stale-runs.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
64
apps/bot/src/scheduler/sweep-stale-runs.ts
Normal file
64
apps/bot/src/scheduler/sweep-stale-runs.ts
Normal file
@ -0,0 +1,64 @@
|
||||
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 };
|
||||
}
|
||||
@ -7,35 +7,45 @@ import { logger } from "../logger.js";
|
||||
export async function syncGroupsForAccount(
|
||||
accountId: string,
|
||||
socket: WASocket,
|
||||
): Promise<{ synced: number; removed: number }> {
|
||||
): Promise<{ synced: number; archived: number }> {
|
||||
const meta = await socket.groupFetchAllParticipating();
|
||||
const entries = Object.values(meta);
|
||||
const liveJids = entries.map((g) => g.id);
|
||||
|
||||
// Remove DB rows for groups that are no longer in the live participant list
|
||||
// (group was deleted, bot was removed, etc.). Only run the delete when we
|
||||
// got at least one live group back — an empty result is more likely a
|
||||
// transient WA fetch failure than a genuine "all groups gone" signal, and
|
||||
// we don't want to nuke valid data on a hiccup.
|
||||
let removed: { id: string }[] = [];
|
||||
// Mark DB rows as archived when they're no longer in the live
|
||||
// participant list (group deleted, bot removed, etc). We don't
|
||||
// physically DELETE because reminder_targets.group_id is a NOT
|
||||
// NULL FK to this row — a hard delete throws "violates foreign
|
||||
// key constraint reminder_targets_group_id_whatsapp_groups_id_fk"
|
||||
// and aborts the WHOLE group-sync transaction (which then strands
|
||||
// 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) {
|
||||
removed = await db
|
||||
.delete(whatsappGroups)
|
||||
const rows = await db
|
||||
.update(whatsappGroups)
|
||||
.set({ isArchived: true, lastSyncedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(whatsappGroups.accountId, accountId),
|
||||
notInArray(whatsappGroups.waGroupJid, liveJids),
|
||||
eq(whatsappGroups.isArchived, false),
|
||||
),
|
||||
)
|
||||
.returning({ id: whatsappGroups.id });
|
||||
archived = rows.length;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
logger.info(
|
||||
{ accountId },
|
||||
"group-sync: empty fetch — skipping delete sweep (treating as transient)",
|
||||
"group-sync: empty fetch — skipping archive sweep (treating as transient)",
|
||||
);
|
||||
return { synced: 0, removed: 0 };
|
||||
return { synced: 0, archived: 0 };
|
||||
}
|
||||
|
||||
const rows = entries.map((g) => ({
|
||||
@ -56,12 +66,16 @@ export async function syncGroupsForAccount(
|
||||
name: sql`excluded.name`,
|
||||
participantCount: sql`excluded.participant_count`,
|
||||
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(
|
||||
{ accountId, count: rows.length, removed: removed.length },
|
||||
{ accountId, count: rows.length, archived },
|
||||
"group-sync: synced",
|
||||
);
|
||||
return { synced: rows.length, removed: removed.length };
|
||||
return { synced: rows.length, archived };
|
||||
}
|
||||
|
||||
@ -120,6 +120,44 @@ class SessionManager {
|
||||
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> {
|
||||
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
|
||||
}
|
||||
|
||||
27
apps/web/.env.example
Normal file
27
apps/web/.env.example
Normal file
@ -0,0 +1,27 @@
|
||||
# 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
|
||||
@ -21,6 +21,7 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
typedRoutes: true,
|
||||
serverActions: {
|
||||
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
|
||||
// Default Server Action body limit is 1 MB — way under WhatsApp's
|
||||
// 100 MB document cap. Lifted to 100 MB so document uploads reach
|
||||
// the action; the per-kind WhatsApp validator
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@serwist/next": "^9.5.11",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
@ -44,6 +45,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/node": "^22.7.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
||||
@ -172,8 +172,16 @@ export async function unpairAccountAction(formData: FormData): Promise<void> {
|
||||
.update(whatsappAccounts)
|
||||
.set({ status: "unpaired", phoneNumber: null })
|
||||
.where(eq(whatsappAccounts.id, accountId));
|
||||
// Wipe synced groups too — they belong to a different WA login now.
|
||||
await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId));
|
||||
// Soft-archive synced groups instead of DELETEing. Hard delete
|
||||
// failed with "violates foreign key constraint
|
||||
// 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/${accountId}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -193,8 +201,12 @@ export async function deleteAccountAction(formData: FormData): Promise<void> {
|
||||
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
||||
});
|
||||
if (!account) return;
|
||||
// Stop any live session / clean session files first.
|
||||
await pgNotifyBot({ type: "account.unpair", accountId });
|
||||
// Tell the bot to logout() over the live socket FIRST (so WhatsApp
|
||||
// drops this device from the operator's linked-devices list), then
|
||||
// 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.
|
||||
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
|
||||
revalidatePath("/accounts");
|
||||
|
||||
367
apps/web/src/actions/auth.test.ts
Normal file
367
apps/web/src/actions/auth.test.ts
Normal file
@ -0,0 +1,367 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
182
apps/web/src/actions/auth.ts
Normal file
182
apps/web/src/actions/auth.ts
Normal file
@ -0,0 +1,182 @@
|
||||
"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);
|
||||
}
|
||||
@ -33,6 +33,12 @@ export async function sendTestAction(_prev: unknown, formData: FormData): Promis
|
||||
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 group = await db.query.whatsappGroups.findFirst({
|
||||
where: (g, { eq }) => eq(g.id, parsed.data.groupId),
|
||||
|
||||
211
apps/web/src/actions/reminders.run-actions.test.ts
Normal file
211
apps/web/src/actions/reminders.run-actions.test.ts
Normal file
@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@ -6,7 +6,13 @@ import { headers } from "next/headers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { DateTime } from "luxon";
|
||||
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
|
||||
import {
|
||||
reminders,
|
||||
reminderTargets,
|
||||
reminderMessages,
|
||||
reminderRuns,
|
||||
reminderRunTargets,
|
||||
} from "@cmbot/db";
|
||||
import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared";
|
||||
import { db } from "@/lib/db";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
@ -265,7 +271,7 @@ const createReminderSchema = z
|
||||
path: ["messages"],
|
||||
},
|
||||
)
|
||||
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 18), {
|
||||
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 24), {
|
||||
message: "Delivery window start must be earlier than end",
|
||||
path: ["deliveryWindowStartHour"],
|
||||
});
|
||||
@ -322,7 +328,11 @@ export async function createReminderAction(
|
||||
timezone,
|
||||
} = parsed.data;
|
||||
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
||||
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
|
||||
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the
|
||||
// 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 op = await getSeededOperator();
|
||||
@ -436,7 +446,11 @@ export async function updateReminderAction(
|
||||
timezone,
|
||||
} = parsed.data;
|
||||
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
||||
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
|
||||
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the
|
||||
// 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 op = await getSeededOperator();
|
||||
@ -538,3 +552,141 @@ export async function updateReminderAction(
|
||||
revalidatePath(`/reminders/${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 };
|
||||
}
|
||||
|
||||
192
apps/web/src/actions/users.test.ts
Normal file
192
apps/web/src/actions/users.test.ts
Normal file
@ -0,0 +1,192 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
139
apps/web/src/actions/users.ts
Normal file
139
apps/web/src/actions/users.ts
Normal file
@ -0,0 +1,139 @@
|
||||
"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 };
|
||||
}
|
||||
104
apps/web/src/app/accounts/[id]/delete-account-card.tsx
Normal file
104
apps/web/src/app/accounts/[id]/delete-account-card.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,6 @@ import {
|
||||
ArrowLeftIcon,
|
||||
SearchIcon,
|
||||
UsersIcon,
|
||||
RefreshCwIcon,
|
||||
Users2Icon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -16,6 +15,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { listGroupsForAccount } from "@/lib/queries";
|
||||
import { RefreshGroupsClient } from "./refresh-groups-client";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
@ -57,13 +57,7 @@ export default async function GroupsListPage({ params, searchParams }: Props) {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Refresh button — no-op placeholder, wired in Task 17 */}
|
||||
<form action={async () => { "use server"; /* wired in Task 17 */ }}>
|
||||
<Button type="submit" variant="outline" size="sm" className="shrink-0">
|
||||
<RefreshCwIcon />
|
||||
Refresh Groups
|
||||
</Button>
|
||||
</form>
|
||||
<RefreshGroupsClient accountId={account.id} />
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
UsersIcon,
|
||||
Trash2Icon,
|
||||
ArrowLeftIcon,
|
||||
SmartphoneIcon,
|
||||
CalendarIcon,
|
||||
@ -10,7 +9,6 @@ import {
|
||||
DatabaseIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
PowerOffIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -20,23 +18,12 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { getAccount } from "@/lib/queries";
|
||||
import {
|
||||
unpairAccountAction,
|
||||
pairAccountAction,
|
||||
deleteAccountAction,
|
||||
} from "@/actions/accounts";
|
||||
import { pairAccountAction } from "@/actions/accounts";
|
||||
import { DeleteAccountCard } from "./delete-account-card";
|
||||
import { UnpairAccountCard } from "./unpair-account-card";
|
||||
|
||||
interface AccountDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@ -156,102 +143,11 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Unpair — transparent <button> overlay opens the dialog
|
||||
so we don't pass button-specific props onto the Card div
|
||||
(Radix asChild does that and it produces a hydration
|
||||
mismatch on a div). */}
|
||||
<Dialog>
|
||||
<Card className="relative transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
||||
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Unpair</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disconnect from WhatsApp; keep the account so you can re-pair later
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||
</CardContent>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Unpair WhatsApp"
|
||||
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</Card>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unpair this account?</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{account.label}</strong> will disconnect from WhatsApp and
|
||||
scheduled reminders using it will stop firing until you re-pair.
|
||||
The account itself is kept; reminders and other data are not deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton>
|
||||
<form action={unpairAccountAction}>
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<Button type="submit" variant="default" size="sm">
|
||||
<PowerOffIcon />
|
||||
Yes, unpair
|
||||
</Button>
|
||||
</form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<UnpairAccountCard 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>
|
||||
<DeleteAccountCard accountId={account.id} accountLabel={account.label} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
102
apps/web/src/app/accounts/[id]/unpair-account-card.tsx
Normal file
102
apps/web/src/app/accounts/[id]/unpair-account-card.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -6,21 +6,14 @@ import {
|
||||
ArchiveRestoreIcon,
|
||||
CheckCircle2Icon,
|
||||
MinusCircleIcon,
|
||||
PauseCircleIcon,
|
||||
PlayIcon,
|
||||
Trash2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -36,11 +29,11 @@ import { getSeededOperator } from "@/lib/operator";
|
||||
import { listActivityRuns } from "@/lib/queries";
|
||||
import {
|
||||
archiveRunAction,
|
||||
clearHistoryAction,
|
||||
deleteRunAction,
|
||||
unarchiveRunAction,
|
||||
} from "@/actions/history";
|
||||
import { SwipeableRow } from "@/components/swipeable-row";
|
||||
import { ResumeRunButton } from "@/components/activity/resume-run-button";
|
||||
|
||||
function relativeTime(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
@ -62,6 +55,12 @@ const RUN_STATUS_CONFIG: Record<
|
||||
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||
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: {
|
||||
label: "Partial",
|
||||
className:
|
||||
@ -97,16 +96,24 @@ function RunStatusBadge({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped" | "archived";
|
||||
type FilterValue = "success" | "paused" | "failed" | "archived";
|
||||
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "success", label: "Success" },
|
||||
{ value: "partial", label: "Partial" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "skipped", label: "Skipped" },
|
||||
{ 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 {
|
||||
searchParams: Promise<{ filter?: string }>;
|
||||
}
|
||||
@ -167,76 +174,42 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filter: FilterValue =
|
||||
sp.filter === "success" ||
|
||||
sp.filter === "partial" ||
|
||||
sp.filter === "paused" ||
|
||||
sp.filter === "failed" ||
|
||||
sp.filter === "skipped" ||
|
||||
sp.filter === "archived"
|
||||
? sp.filter
|
||||
: "all";
|
||||
: "success";
|
||||
const showingArchived = filter === "archived";
|
||||
|
||||
const op = await getSeededOperator();
|
||||
const runs = await listActivityRuns(op.id, { archived: showingArchived });
|
||||
const filtered =
|
||||
filter === "all" || filter === "archived"
|
||||
filter === "archived"
|
||||
? runs
|
||||
: runs.filter((r) => r.status === filter);
|
||||
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
|
||||
const hasAny = runs.length > 0;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Activity"
|
||||
action={
|
||||
hasAny && !showingArchived ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
|
||||
<Trash2Icon />
|
||||
Clear history
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear all run history?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This permanently removes every reminder run record, including
|
||||
runs from reminders that have already been deleted. Reminders
|
||||
themselves are not affected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton>
|
||||
<form action={clearHistoryAction}>
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2Icon />
|
||||
Yes, clear history
|
||||
</Button>
|
||||
</form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
|
||||
packed into a phone-width row left every label squeezed to
|
||||
~50px. Wrap the list in an overflow-x scroller so each tab
|
||||
keeps a readable label + comfortable touch target on mobile;
|
||||
on desktop the row fits naturally and no scroll bar appears.
|
||||
Negative margins extend the scroller to the page edges so the
|
||||
first/last tabs don't look clipped against the container. */}
|
||||
<PageShell title="Activity">
|
||||
{/* Filter tabs span the full row and wrap onto a second line when the
|
||||
viewport can't fit them all. Each trigger has a small basis so they
|
||||
share space evenly while still keeping a readable label on mobile. */}
|
||||
<Tabs value={filter}>
|
||||
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<TabsList>
|
||||
<TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1">
|
||||
{FILTER_TABS.map(({ value, label }) => (
|
||||
<TabsTrigger key={value} value={value} asChild>
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
asChild
|
||||
className="h-8 grow basis-20"
|
||||
>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
|
||||
<Link href={`/activity?filter=${value}` as any}>
|
||||
{label}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
@ -354,6 +327,9 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
</TableCell>
|
||||
<TableCell className="text-right pr-2 whitespace-nowrap">
|
||||
<div className="inline-flex items-center gap-0.5">
|
||||
{run.status === "paused" && (
|
||||
<ResumeRunButton runId={run.id} />
|
||||
)}
|
||||
<form
|
||||
action={
|
||||
isArchived ? unarchiveRunAction : archiveRunAction
|
||||
@ -401,11 +377,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title={
|
||||
filter === "all"
|
||||
? "No activity yet."
|
||||
: showingArchived
|
||||
? "No archived runs."
|
||||
: `No ${filter} runs yet.`
|
||||
showingArchived ? "No archived runs." : `No ${filter} runs yet.`
|
||||
}
|
||||
description={
|
||||
hasAny
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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 {
|
||||
params: Promise<{ accountId: string }>;
|
||||
}
|
||||
|
||||
@ -4,12 +4,14 @@ import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { NotificationManager } from "@/components/notification-manager";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "cm WhatsApp Bot",
|
||||
description: "Self-hosted WhatsApp reminder bot",
|
||||
applicationName: "cm WhatsApp Bot",
|
||||
robots: { index: false, follow: false },
|
||||
// PWA wiring: the manifest comes from the dynamic route at
|
||||
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
|
||||
// emitted from public/, and `appleWebApp.capable` lets iOS treat the
|
||||
@ -32,7 +34,13 @@ export const viewport: Viewport = {
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async 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 (
|
||||
// `suppressHydrationWarning` here is for *attribute* differences only.
|
||||
// Two sources legitimately mutate <html>/<body> attributes after the
|
||||
@ -45,7 +53,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
|
||||
<body suppressHydrationWarning>
|
||||
<ThemeProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
<AppShell role={role} username={username}>{children}</AppShell>
|
||||
<Toaster richColors position="top-right" />
|
||||
{/* SSE → browser notification bridge. Renders no DOM. */}
|
||||
<NotificationManager />
|
||||
|
||||
101
apps/web/src/app/login/login-form-client.tsx
Normal file
101
apps/web/src/app/login/login-form-client.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/app/login/page.tsx
Normal file
25
apps/web/src/app/login/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -182,9 +182,9 @@ export default async function DashboardPage() {
|
||||
/>
|
||||
<StatCard
|
||||
title="Reminders"
|
||||
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.endedReminders} / ${stats.totalReminders}`}
|
||||
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.inactiveReminders} / ${stats.totalReminders}`}
|
||||
icon={BellIcon}
|
||||
description="Active / Paused / Ended / Total"
|
||||
description="Active / Paused / Inactive / Total"
|
||||
href="/reminders"
|
||||
/>
|
||||
</div>
|
||||
@ -217,7 +217,7 @@ export default async function DashboardPage() {
|
||||
themselves are not affected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton>
|
||||
<DialogFooter>
|
||||
<form action={clearHistoryAction}>
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2Icon />
|
||||
|
||||
@ -41,9 +41,9 @@ describe("ActionsBar — card visibility by status", () => {
|
||||
expect(html).not.toMatch(/aria-label="Pause"/);
|
||||
});
|
||||
|
||||
it("ended: shows Restart and Delete (no Pause)", () => {
|
||||
it("inactive: shows Restart and Delete (no Pause)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ActionsBar reminderId="r-1" status="ended" isRecurring={false} />,
|
||||
<ActionsBar reminderId="r-1" status="inactive" isRecurring={false} />,
|
||||
);
|
||||
expect(html).toMatch(/aria-label="Restart"/);
|
||||
expect(html).toMatch(/aria-label="Delete"/);
|
||||
|
||||
@ -38,7 +38,7 @@ interface ActionsBarProps {
|
||||
* on desktop, stacked on mobile:
|
||||
*
|
||||
* - Pause — only when status === "active"
|
||||
* - Restart — when status is "paused" or "ended"
|
||||
* - Restart — when status is "paused" or "inactive"
|
||||
* - Delete — always available (terminal)
|
||||
*
|
||||
* Each Dialog confirms before firing the corresponding server action.
|
||||
@ -46,7 +46,7 @@ interface ActionsBarProps {
|
||||
*/
|
||||
export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) {
|
||||
const canPause = status === "active";
|
||||
const canRestart = status === "paused" || status === "ended";
|
||||
const canRestart = status === "paused" || status === "inactive";
|
||||
|
||||
return (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter showCloseButton>
|
||||
<DialogFooter>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
setSubmitting(true);
|
||||
|
||||
@ -30,6 +30,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { getReminderWithRuns } from "@/lib/queries";
|
||||
import { PausedRunBanner } from "@/components/reminder-detail/paused-run-banner";
|
||||
import { ActionsBar } from "./actions-bar";
|
||||
|
||||
function formatWhen(date: Date | null, tz: string): string {
|
||||
@ -47,7 +48,7 @@ function formatWhen(date: Date | null, tz: string): string {
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
active:
|
||||
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||
ended:
|
||||
inactive:
|
||||
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
|
||||
paused:
|
||||
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
||||
@ -119,6 +120,22 @@ export default async function ReminderDetailPage({ params }: Props) {
|
||||
</p>
|
||||
</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 />
|
||||
|
||||
{/* Name — click to edit. Required field, the operator's
|
||||
@ -213,12 +230,28 @@ export default async function ReminderDetailPage({ params }: Props) {
|
||||
</p>
|
||||
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
|
||||
{reminder.rrule && reminder.scheduledAt ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-primary/80">
|
||||
// Single-line summary with mid-string ellipsis. Long
|
||||
// descriptions ("Every month on days 4, 6, 11, 13, 18,
|
||||
// 20 +2 more at 11:32") truncate cleanly via `truncate`
|
||||
// (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),
|
||||
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 className="text-xs text-muted-foreground">One-off</p>
|
||||
|
||||
@ -32,7 +32,7 @@ import {
|
||||
restartReminderAction,
|
||||
} from "@/actions/reminders";
|
||||
|
||||
type FilterValue = "all" | "active" | "ended" | "paused";
|
||||
type FilterValue = "all" | "active" | "inactive" | "paused";
|
||||
|
||||
function formatWhen(date: Date | null, tz: string): string {
|
||||
if (!date) return "—";
|
||||
@ -48,7 +48,7 @@ function formatWhen(date: Date | null, tz: string): string {
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
active:
|
||||
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||
ended:
|
||||
inactive:
|
||||
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
|
||||
paused:
|
||||
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
||||
@ -104,7 +104,7 @@ function StatusPill({ status }: { status: string }) {
|
||||
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "ended", label: "Ended" },
|
||||
{ value: "inactive", label: "Inactive" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
];
|
||||
|
||||
@ -127,7 +127,7 @@ interface PageProps {
|
||||
export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const status: FilterValue =
|
||||
sp.filter === "active" || sp.filter === "ended" || sp.filter === "paused"
|
||||
sp.filter === "active" || sp.filter === "inactive" || sp.filter === "paused"
|
||||
? sp.filter
|
||||
: "all";
|
||||
// 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) => {
|
||||
const canPause = reminder.status === "active";
|
||||
const canRestart =
|
||||
reminder.status === "paused" || reminder.status === "ended";
|
||||
reminder.status === "paused" || reminder.status === "inactive";
|
||||
const cardBody = (
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -247,15 +247,30 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right space-y-1">
|
||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
{/* Right meta column. Capped at ~14rem so a long
|
||||
recurrence description ("Every month on days
|
||||
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" />
|
||||
<span>{formatWhen(reminder.scheduledAt, tz)}</span>
|
||||
<span className="truncate">
|
||||
{formatWhen(reminder.scheduledAt, tz)}
|
||||
</span>
|
||||
</div>
|
||||
{reminder.rrule && reminder.scheduledAt ? (
|
||||
<div className="flex items-center justify-end gap-1 text-xs text-primary/80">
|
||||
<div
|
||||
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" />
|
||||
<span>
|
||||
<span className="truncate">
|
||||
{describeRecurrence(
|
||||
specFromRrule(reminder.rrule),
|
||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
||||
@ -264,9 +279,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
) : null}
|
||||
{reminder.groupCount > 0 && (
|
||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
<UsersIcon className="size-3 shrink-0" />
|
||||
<span>
|
||||
<span className="truncate">
|
||||
{reminder.groupCount}{" "}
|
||||
{reminder.groupCount === 1 ? "group" : "groups"}
|
||||
</span>
|
||||
|
||||
5
apps/web/src/app/robots.ts
Normal file
5
apps/web/src/app/robots.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return { rules: [{ userAgent: "*", disallow: "/" }] };
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { PageShell } from "@/components/page-shell";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const op = await getSeededOperator();
|
||||
const isAdmin = op.role === "admin";
|
||||
return (
|
||||
<PageShell title="Settings" narrow>
|
||||
<Card>
|
||||
@ -14,13 +15,15 @@ export default async function SettingsPage() {
|
||||
<CardTitle>Operator</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<Row label="Display name" value={op.displayName} />
|
||||
<Separator />
|
||||
<Row label="Operator ID" value={String(op.telegramUserId)} mono />
|
||||
<Row label="Username" value={op.username} mono />
|
||||
<Separator />
|
||||
<Row label="Default timezone" value={op.defaultTimezone} mono />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<Row label="Role" value={op.role} mono />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -47,10 +50,6 @@ export default async function SettingsPage() {
|
||||
<ThemeToggle />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
cm WhatsApp Bot · self-hosted
|
||||
</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
95
apps/web/src/app/settings/users/add-user-form-client.tsx
Normal file
95
apps/web/src/app/settings/users/add-user-form-client.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/app/settings/users/page.tsx
Normal file
62
apps/web/src/app/settings/users/page.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
197
apps/web/src/app/settings/users/user-row-client.tsx
Normal file
197
apps/web/src/app/settings/users/user-row-client.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/components/activity/resume-run-button.test.tsx
Normal file
32
apps/web/src/components/activity/resume-run-button.test.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
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"/);
|
||||
});
|
||||
});
|
||||
54
apps/web/src/components/activity/resume-run-button.tsx
Normal file
54
apps/web/src/components/activity/resume-run-button.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -55,7 +55,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
||||
|
||||
it("renders a fixed top header that hides on sm+ breakpoints", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<main>page</main>
|
||||
</AppShell>,
|
||||
);
|
||||
@ -66,7 +66,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
||||
|
||||
it("brand mark on the left links to /", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -90,7 +90,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
||||
for (const c of cases) {
|
||||
pathnameMock.mockReturnValue(c.path);
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</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", () => {
|
||||
pathnameMock.mockReturnValue("/unknown-route");
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -115,7 +115,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
||||
|
||||
it("menu button on the right uses aria-label='Open menu'", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</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", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</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'", () => {
|
||||
pathnameMock.mockReturnValue("/reminders");
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</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 "/".
|
||||
pathnameMock.mockReturnValue("/accounts");
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</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)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</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", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -221,7 +221,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
||||
|
||||
it("renders the sidebar nav with every NAV_ITEM", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -232,21 +232,22 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the theme toggle in the sidebar footer", () => {
|
||||
it("renders a Sign out button in the sidebar footer", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
// The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must
|
||||
// appear in the sidebar (we removed it from the mobile drawer).
|
||||
expect(html).toContain('data-testid="theme-toggle"');
|
||||
// Theme toggle was dropped from the shell per request; the footer
|
||||
// now carries the Sign out affordance + the signed-in username.
|
||||
expect(html).toContain('aria-label="Sign out"');
|
||||
expect(html).toContain("admin");
|
||||
});
|
||||
|
||||
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
|
||||
pathnameMock.mockReturnValue("/accounts");
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -264,7 +265,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
||||
// reader users on a wide-window split-screen don't hear two
|
||||
// identical announcements when both are visible.
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell>
|
||||
<AppShell role="admin" username="admin">
|
||||
<div />
|
||||
</AppShell>,
|
||||
);
|
||||
@ -273,6 +274,79 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { MenuIcon } from "lucide-react";
|
||||
import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { logoutAction } from "@/actions/auth";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@ -14,8 +15,13 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { NAV_ITEMS } from "@/components/nav-config";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import {
|
||||
NAV_ITEMS,
|
||||
navItemsForRole,
|
||||
pickActiveNavKey,
|
||||
type NavItem,
|
||||
type NavRole,
|
||||
} from "@/components/nav-config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile header (sm:hidden)
|
||||
@ -30,8 +36,51 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
||||
// waiting for the page content to render. The menu button on the right
|
||||
// opens a Sheet with the full nav list and the theme toggle.
|
||||
// ---------------------------------------------------------------------------
|
||||
function MobileHeader() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 activeKey = pickActiveNavKey(items, pathname);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Close the drawer when the route changes (i.e. the user picked a nav
|
||||
@ -41,6 +90,10 @@ function MobileHeader() {
|
||||
setOpen(false);
|
||||
}, [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 }) =>
|
||||
href === "/" ? pathname === "/" : pathname.startsWith(href),
|
||||
);
|
||||
@ -90,10 +143,10 @@ function MobileHeader() {
|
||||
|
||||
<nav
|
||||
aria-label="Primary navigation"
|
||||
className="flex flex-col gap-0.5 p-2 flex-1"
|
||||
className="flex flex-col gap-0.5 p-2"
|
||||
>
|
||||
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
{items.map(({ key, href, label, icon: Icon }) => {
|
||||
const active = activeKey === key;
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
@ -117,6 +170,10 @@ function MobileHeader() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto border-t border-border p-3">
|
||||
<SignOutButton username={username} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</header>
|
||||
@ -126,8 +183,15 @@ function MobileHeader() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar (desktop only — hidden below sm)
|
||||
// ---------------------------------------------------------------------------
|
||||
function Sidebar() {
|
||||
function Sidebar({
|
||||
items,
|
||||
username,
|
||||
}: {
|
||||
items: NavItem[];
|
||||
username: string | null;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const activeKey = pickActiveNavKey(items, pathname);
|
||||
|
||||
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">
|
||||
@ -150,7 +214,7 @@ function Sidebar() {
|
||||
|
||||
{/* Nav items */}
|
||||
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
|
||||
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
||||
{items.map(({ key, href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
@ -172,29 +236,74 @@ function Sidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer: theme toggle */}
|
||||
{/* Footer: signed-in user + sign-out */}
|
||||
<div className="border-t border-sidebar-border p-3">
|
||||
<ThemeToggle />
|
||||
<SignOutButton username={username} />
|
||||
</div>
|
||||
</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
|
||||
// ---------------------------------------------------------------------------
|
||||
interface AppShellProps {
|
||||
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 }: AppShellProps) {
|
||||
export function AppShell({ children, role, username }: 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 (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<Sidebar />
|
||||
<Sidebar items={items} username={username} />
|
||||
|
||||
{/* Mobile header (single row: brand · title · menu) */}
|
||||
<MobileHeader />
|
||||
<MobileHeader items={items} username={username} />
|
||||
|
||||
{/* Main content
|
||||
Mobile: push down for the h-14 header (56px) plus a small gap
|
||||
|
||||
119
apps/web/src/components/nav-config.test.ts
Normal file
119
apps/web/src/components/nav-config.test.ts
Normal file
@ -0,0 +1,119 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,11 +1,22 @@
|
||||
import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react";
|
||||
import {
|
||||
Home,
|
||||
Smartphone,
|
||||
Calendar,
|
||||
Activity,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export type NavRole = "admin" | "user";
|
||||
|
||||
export interface NavItem {
|
||||
key: string;
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** When set, only roles listed here will see this nav entry. */
|
||||
visibleTo?: NavRole[];
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
@ -13,5 +24,54 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
||||
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -18,7 +18,8 @@ type PairingState =
|
||||
| { phase: "waiting" }
|
||||
| { phase: "qr"; qrUrl: string }
|
||||
| { phase: "connected"; phoneNumber: string }
|
||||
| { phase: "timeout" };
|
||||
| { phase: "timeout" }
|
||||
| { phase: "duplicate"; phoneNumber: string; existingLabel: string };
|
||||
|
||||
interface PairLiveProps {
|
||||
accountId: string;
|
||||
@ -112,6 +113,15 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
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
|
||||
@ -234,6 +244,35 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
118
apps/web/src/components/reminder-detail/paused-run-banner.tsx
Normal file
118
apps/web/src/components/reminder-detail/paused-run-banner.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -52,7 +52,15 @@ export function EditWhenForm({
|
||||
const [date, setDate] = useState(initial.date);
|
||||
const [time, setTime] = useState(initial.time);
|
||||
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec);
|
||||
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(initialDeliveryEndHour);
|
||||
// Optional deadline: 24 (next-day midnight) is the off-sentinel —
|
||||
// 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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -108,7 +116,7 @@ export function EditWhenForm({
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
timezone,
|
||||
deliveryWindowEndHour: deliveryEndHour,
|
||||
deliveryWindowEndHour: useDeadline ? deliveryEndHour : 24,
|
||||
});
|
||||
if (r.ok) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -162,13 +170,29 @@ export function EditWhenForm({
|
||||
|
||||
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="flex items-center gap-1.5">
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useDeadline}
|
||||
onChange={(e) => {
|
||||
setUseDeadline(e.target.checked);
|
||||
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">
|
||||
<ClockIcon className="size-3.5" />
|
||||
Pause sending by
|
||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
</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}
|
||||
@ -179,6 +203,7 @@ export function EditWhenForm({
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">({timezone})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@ -99,7 +99,7 @@ export function ReminderFilterBar({ accounts }: FilterBarProps) {
|
||||
id="filter-account"
|
||||
value={initial.accountId}
|
||||
onChange={(e) => setParam("accountId", e.target.value)}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<option value="">All accounts</option>
|
||||
{accounts.map((a) => (
|
||||
|
||||
@ -82,8 +82,12 @@ export function ReviewSubmitClient({
|
||||
|
||||
const groupCount = groupIds ? groupIds.split(",").filter(Boolean).length : 0;
|
||||
const fireAt = new Date(scheduledAt);
|
||||
const endHour = deliveryEndHour ?? 18;
|
||||
const wEnd = windowEndAt(timezone, endHour, fireAt);
|
||||
// Treat hour=24 (or unset) as "no deadline". The pill goes neutral.
|
||||
const hasDeadline =
|
||||
deliveryEndHour !== undefined && deliveryEndHour !== 24;
|
||||
const wEnd = hasDeadline
|
||||
? windowEndAt(timezone, deliveryEndHour!, fireAt)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pt-2">
|
||||
|
||||
@ -3,6 +3,20 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { RunEtaPill } from "./run-eta-pill";
|
||||
|
||||
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", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RunEtaPill
|
||||
|
||||
@ -4,7 +4,10 @@ import { estimateRunDuration } from "@/lib/run-eta";
|
||||
interface RunEtaPillProps {
|
||||
targetCount: number;
|
||||
fireAt: Date;
|
||||
windowEndAt: Date;
|
||||
/** Optional. When omitted (or when the operator picked "no
|
||||
* deadline"), the pill renders a neutral ETA without the
|
||||
* green/amber fit indicator. */
|
||||
windowEndAt?: Date;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
@ -27,8 +30,6 @@ export function RunEtaPill({
|
||||
targetCount,
|
||||
fireAt,
|
||||
});
|
||||
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
|
||||
|
||||
const finishLocal = new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
@ -36,6 +37,23 @@ export function RunEtaPill({
|
||||
timeZone: timezone,
|
||||
}).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) {
|
||||
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">
|
||||
|
||||
@ -45,8 +45,16 @@ export function WhenFormClient({
|
||||
const [date, setDate] = useState(initial.date);
|
||||
const [time, setTime] = useState(initial.time);
|
||||
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>(
|
||||
initialDeliveryEndHour ?? 18,
|
||||
initialUseDeadline ? (initialDeliveryEndHour ?? 18) : 18,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -77,7 +85,8 @@ export function WhenFormClient({
|
||||
if (passThroughParams.name) sp.set("name", passThroughParams.name);
|
||||
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
sp.set("deliveryEndHour", String(deliveryEndHour));
|
||||
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
|
||||
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||
return;
|
||||
@ -121,7 +130,8 @@ export function WhenFormClient({
|
||||
if (passThroughParams.name) sp.set("name", passThroughParams.name);
|
||||
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
sp.set("deliveryEndHour", String(deliveryEndHour));
|
||||
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
|
||||
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||
}
|
||||
@ -168,14 +178,32 @@ export function WhenFormClient({
|
||||
|
||||
{/* Deadline — fire time is the implicit start; this only sets when
|
||||
the bot must stop. Long fan-outs that don't finish before the
|
||||
deadline are paused so the operator can resume them later. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="flex items-center gap-1.5">
|
||||
deadline are paused so the operator can resume them later.
|
||||
The whole control is opt-in: tick the box to surface the hour
|
||||
picker, untick to remove the deadline entirely. */}
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useDeadline}
|
||||
onChange={(e) => {
|
||||
setUseDeadline(e.target.checked);
|
||||
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">
|
||||
<ClockIcon className="size-3.5" />
|
||||
Pause sending by
|
||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
</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}
|
||||
@ -186,6 +214,7 @@ export function WhenFormClient({
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">({timezone})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
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)");
|
||||
});
|
||||
});
|
||||
@ -67,6 +67,11 @@ export function SwipeableRow({
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | 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.
|
||||
useEffect(() => {
|
||||
@ -92,12 +97,17 @@ export function SwipeableRow({
|
||||
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||
dragStart.current = { x: e.clientX, baseOffset: offset };
|
||||
dragMoved.current = false;
|
||||
setDragging(true);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!dragging || !dragStart.current) return;
|
||||
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));
|
||||
}
|
||||
|
||||
@ -113,6 +123,28 @@ export function SwipeableRow({
|
||||
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 (
|
||||
@ -150,6 +182,14 @@ export function SwipeableRow({
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={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={{
|
||||
transform: `translateX(${offset}px)`,
|
||||
transition: dragging ? "none" : "transform 200ms ease-out",
|
||||
|
||||
@ -8,4 +8,25 @@ const envSchema = z.object({
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@ -9,8 +9,19 @@ export type WebEventMap = {
|
||||
"session.connected": { accountId: string; phoneNumber: string | null };
|
||||
"session.disconnected": { accountId: string };
|
||||
"session.timeout": { accountId: string };
|
||||
"session.duplicate": {
|
||||
accountId: string;
|
||||
phoneNumber: string;
|
||||
existingLabel: string;
|
||||
};
|
||||
"groups.synced": { accountId: string; count: number };
|
||||
"reminder.fired": { reminderId: string; runId: string; status: string };
|
||||
"reminder.fired": {
|
||||
reminderId: string;
|
||||
runId: string;
|
||||
status: string;
|
||||
sent?: number;
|
||||
total?: number;
|
||||
};
|
||||
"reminder.failed": { reminderId: string; error: string };
|
||||
"send_test.done": { groupId: string; ok: boolean; error: string | null };
|
||||
};
|
||||
|
||||
135
apps/web/src/lib/auth-cookie.test.ts
Normal file
135
apps/web/src/lib/auth-cookie.test.ts
Normal file
@ -0,0 +1,135 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
148
apps/web/src/lib/auth-cookie.ts
Normal file
148
apps/web/src/lib/auth-cookie.ts
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
89
apps/web/src/lib/auth.test.ts
Normal file
89
apps/web/src/lib/auth.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
66
apps/web/src/lib/auth.ts
Normal file
66
apps/web/src/lib/auth.ts
Normal file
@ -0,0 +1,66 @@
|
||||
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;
|
||||
}
|
||||
49
apps/web/src/lib/list-accounts-order.test.ts
Normal file
49
apps/web/src/lib/list-accounts-order.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
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\)/);
|
||||
});
|
||||
});
|
||||
@ -240,6 +240,44 @@ describe("reminderFiredToNotification mapping", () => {
|
||||
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", () => {
|
||||
const a = reminderFiredToNotification({
|
||||
type: "reminder.fired",
|
||||
|
||||
@ -138,20 +138,35 @@ export function reminderFiredToNotification(event: {
|
||||
reminderId: string;
|
||||
runId: string;
|
||||
status: string;
|
||||
sent?: number;
|
||||
total?: number;
|
||||
}): ShowNotificationOptions | null {
|
||||
if (event.status === "skipped") return null;
|
||||
const headline =
|
||||
event.status === "success"
|
||||
? "Reminder sent"
|
||||
: event.status === "paused"
|
||||
? "Reminder paused"
|
||||
: event.status === "partial"
|
||||
? "Reminder partly sent"
|
||||
: "Reminder failed";
|
||||
const body =
|
||||
let body =
|
||||
event.status === "success"
|
||||
? "All groups received the message."
|
||||
: event.status === "paused"
|
||||
? "Delivery window closed before all groups got the message."
|
||||
: event.status === "partial"
|
||||
? "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 {
|
||||
title: headline,
|
||||
body,
|
||||
|
||||
@ -5,9 +5,14 @@ import { db } from "./db";
|
||||
export type BotCommand =
|
||||
| { type: "account.start_pairing"; 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: "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> {
|
||||
const json = JSON.stringify(cmd);
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import "server-only";
|
||||
import { db } from "./db";
|
||||
import { getCurrentUser } from "./auth";
|
||||
|
||||
/**
|
||||
* Returns the single seeded operator row. Since the app has no auth,
|
||||
* every action is attributed to this operator.
|
||||
* Compatibility shim. The app used to seed a single operator and
|
||||
* attribute everything to it; now we have real auth + roles. Existing
|
||||
* 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() {
|
||||
const op = await db.query.operators.findFirst({
|
||||
orderBy: (o, { asc }) => [asc(o.createdAt)],
|
||||
});
|
||||
if (!op) {
|
||||
throw new Error("No operator row seeded. Run scripts/db.sh seed.");
|
||||
const u = await getCurrentUser();
|
||||
if (!u) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
return op;
|
||||
return u;
|
||||
}
|
||||
|
||||
69
apps/web/src/lib/password-policy.test.ts
Normal file
69
apps/web/src/lib/password-policy.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
37
apps/web/src/lib/password-policy.ts
Normal file
37
apps/web/src/lib/password-policy.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@ -6,9 +6,18 @@ export async function getDashboardStats(operatorId: string) {
|
||||
const accounts = await db.query.whatsappAccounts.findMany({
|
||||
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||
});
|
||||
// All reminder rows so the dashboard can show active/total in one query.
|
||||
// Status enum today is active / ended (paused will join in a later phase).
|
||||
const allReminders = await db.query.reminders.findMany();
|
||||
// Reminders scoped to this operator's accounts. The previous
|
||||
// findMany() with no filter leaked global counts across users — a
|
||||
// brand-new user would see another operator's totals on the
|
||||
// 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
|
||||
// ownership filter widens to: either the reminder still exists and the
|
||||
// operator owns its account, OR the reminder is gone but the run row
|
||||
@ -34,7 +43,7 @@ export async function getDashboardStats(operatorId: string) {
|
||||
totalAccounts: accounts.length,
|
||||
activeReminders: allReminders.filter((r) => r.status === "active").length,
|
||||
pausedReminders: allReminders.filter((r) => r.status === "paused").length,
|
||||
endedReminders: allReminders.filter((r) => r.status === "ended").length,
|
||||
inactiveReminders: allReminders.filter((r) => r.status === "inactive").length,
|
||||
totalReminders: allReminders.length,
|
||||
recentRuns: recentRuns.rows as Array<{
|
||||
id: string;
|
||||
@ -54,9 +63,12 @@ export async function listAccounts(operatorId: string) {
|
||||
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
|
||||
// by status produced phantom "I created an account but it's gone"
|
||||
// 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({
|
||||
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||
orderBy: (a, { asc }) => [asc(a.label)],
|
||||
orderBy: (a, { asc }) => [asc(a.createdAt), asc(a.id)],
|
||||
});
|
||||
}
|
||||
|
||||
@ -70,11 +82,19 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
|
||||
const account = await getAccount(operatorId, accountId);
|
||||
if (!account) return null;
|
||||
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
|
||||
? await db.execute(sql`
|
||||
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
||||
FROM whatsapp_groups
|
||||
WHERE account_id = ${accountId} AND name % ${trimmed}
|
||||
WHERE account_id = ${accountId}
|
||||
AND is_archived = false
|
||||
AND name % ${trimmed}
|
||||
ORDER BY similarity(name, ${trimmed}) DESC
|
||||
LIMIT 50
|
||||
`)
|
||||
@ -82,6 +102,7 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
|
||||
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
||||
FROM whatsapp_groups
|
||||
WHERE account_id = ${accountId}
|
||||
AND is_archived = false
|
||||
ORDER BY name ASC
|
||||
LIMIT 200
|
||||
`);
|
||||
@ -187,11 +208,13 @@ export async function listActivityRuns(
|
||||
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
|
||||
// has been deleted but history was preserved) in the list.
|
||||
// The `archived` flag flips the visibility filter:
|
||||
// false (default) — only non-archived rows
|
||||
// true — only archived rows (for the Archived tab)
|
||||
// false (default) — non-archived, non-skipped rows (skipped runs
|
||||
// belong to the Archived tab now)
|
||||
// true — archived rows OR skipped rows (they're treated
|
||||
// as "history" rather than active outcomes)
|
||||
const archivedClause = opts.archived
|
||||
? sql`rr.archived_at IS NOT NULL`
|
||||
: sql`rr.archived_at IS NULL`;
|
||||
? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')`
|
||||
: sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`;
|
||||
const rows = await db.execute(sql`
|
||||
SELECT
|
||||
rr.id,
|
||||
@ -241,11 +264,23 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
|
||||
where: (m, { eq }) => eq(m.reminderId, reminderId),
|
||||
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`
|
||||
SELECT id, fired_at, status, error_summary
|
||||
FROM reminder_runs
|
||||
WHERE reminder_id = ${reminderId}
|
||||
ORDER BY fired_at DESC
|
||||
SELECT
|
||||
rr.id,
|
||||
rr.fired_at,
|
||||
rr.status,
|
||||
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
|
||||
`);
|
||||
return {
|
||||
@ -261,6 +296,8 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
|
||||
firedAt: r.fired_at as Date,
|
||||
status: r.status as string,
|
||||
errorSummary: r.error_summary as string | null,
|
||||
sent: r.sent as number,
|
||||
total: r.total as number,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ describe("applyReminderFilter — account / group filters", () => {
|
||||
});
|
||||
|
||||
it("status='all' or unset includes every status", () => {
|
||||
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "ended" })];
|
||||
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "inactive" })];
|
||||
expect(applyReminderFilter(rows, { status: "all" }).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", () => {
|
||||
const rows = [
|
||||
mk({ id: "a", status: "active" }),
|
||||
mk({ id: "b", status: "ended" }),
|
||||
mk({ id: "b", status: "inactive" }),
|
||||
mk({ id: "c", status: "paused" }),
|
||||
];
|
||||
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: "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-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "ended", ...base }),
|
||||
mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "inactive", ...base }),
|
||||
mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
|
||||
];
|
||||
expect(
|
||||
|
||||
@ -26,7 +26,7 @@ export interface ReminderFilter {
|
||||
q?: string;
|
||||
accountId?: string;
|
||||
groupId?: string;
|
||||
status?: string; // "all" | "active" | "ended" | "paused"
|
||||
status?: string; // "all" | "active" | "inactive" | "paused"
|
||||
sort?: SortKey;
|
||||
}
|
||||
|
||||
|
||||
@ -59,11 +59,11 @@ describe("validateUpdateScheduledAt", () => {
|
||||
if (r.ok) expect(r.scheduledAt.getTime()).toBe(PAST.getTime());
|
||||
});
|
||||
|
||||
it("ended one-off, past timestamp matching existing → ALLOWED", () => {
|
||||
it("inactive one-off, past timestamp matching existing → ALLOWED", () => {
|
||||
const r = validateUpdateScheduledAt({
|
||||
iso: isoOf(PAST),
|
||||
timezone: TZ,
|
||||
existingStatus: "ended",
|
||||
existingStatus: "inactive",
|
||||
existingScheduledAt: PAST,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
@ -34,7 +34,7 @@ export function validateUpdateScheduledAt(args: {
|
||||
if (Number.isNaN(dt.getTime())) {
|
||||
return { ok: false, error: "Invalid date" };
|
||||
}
|
||||
const isPaused = args.existingStatus === "paused" || args.existingStatus === "ended";
|
||||
const isPaused = args.existingStatus === "paused" || args.existingStatus === "inactive";
|
||||
const sameAsExisting =
|
||||
args.existingScheduledAt !== null &&
|
||||
Math.abs(args.existingScheduledAt.getTime() - dt.getTime()) < 1000;
|
||||
|
||||
43
apps/web/src/lib/safe-redirect.test.ts
Normal file
43
apps/web/src/lib/safe-redirect.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
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("/");
|
||||
});
|
||||
});
|
||||
16
apps/web/src/lib/safe-redirect.ts
Normal file
16
apps/web/src/lib/safe-redirect.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
84
apps/web/src/middleware.test.ts
Normal file
84
apps/web/src/middleware.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -1,21 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { COOKIE_NAME, verifySession } from "./lib/auth-cookie";
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname;
|
||||
const PUBLIC_PATHS = new Set<string>([
|
||||
"/login",
|
||||
"/logout",
|
||||
"/api/health",
|
||||
"/manifest.webmanifest",
|
||||
"/favicon.ico",
|
||||
"/robots.txt",
|
||||
]);
|
||||
|
||||
// Block all /api/* except a small set of read-only endpoints.
|
||||
// Mutations happen via Server Actions which post to page URLs, not /api/*.
|
||||
const allowed =
|
||||
path === "/api/events" ||
|
||||
path === "/api/health" ||
|
||||
path.startsWith("/api/qr/");
|
||||
if (path.startsWith("/api/") && !allowed) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
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;
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
const path = req.nextUrl.pathname;
|
||||
if (isPublic(path)) return NextResponse.next();
|
||||
|
||||
const cookie = req.cookies.get(COOKIE_NAME)?.value;
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
const ok =
|
||||
!!cookie && !!secret && (await verifySession(cookie, secret)) !== null;
|
||||
if (ok) return NextResponse.next();
|
||||
|
||||
if (path.startsWith("/api/")) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.searchParams.set("next", path + (req.nextUrl.search || ""));
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"],
|
||||
matcher: ["/((?!_next/static|_next/image).*)"],
|
||||
};
|
||||
|
||||
59
apps/web/src/test/drizzle-journal-monotonic.test.ts
Normal file
59
apps/web/src/test/drizzle-journal-monotonic.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
112
apps/web/src/test/no-dialog-footer-show-close-button.test.ts
Normal file
112
apps/web/src/test/no-dialog-footer-show-close-button.test.ts
Normal file
@ -0,0 +1,112 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -19,7 +19,7 @@ services:
|
||||
MEDIA_DIR: ${MEDIA_DIR:-/data/media}
|
||||
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
|
||||
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
|
||||
SEED_OPERATOR_TELEGRAM_ID: ${SEED_OPERATOR_TELEGRAM_ID:-0}
|
||||
SEED_OPERATOR_USERNAME: ${SEED_OPERATOR_USERNAME:-admin}
|
||||
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
|
||||
networks:
|
||||
- cmbot
|
||||
@ -36,6 +36,8 @@ services:
|
||||
DATA_DIR: ${DATA_DIR}
|
||||
MEDIA_DIR: ${MEDIA_DIR}
|
||||
WEB_PORT: ${WEB_PORT}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
||||
networks:
|
||||
- cmbot
|
||||
|
||||
|
||||
@ -59,5 +59,7 @@ services:
|
||||
DATA_DIR: ${DATA_DIR}
|
||||
MEDIA_DIR: ${MEDIA_DIR}
|
||||
WEB_PORT: ${WEB_PORT}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
||||
depends_on:
|
||||
- tools
|
||||
|
||||
111
docker-compose.portainer.yml
Normal file
111
docker-compose.portainer.yml
Normal file
@ -0,0 +1,111 @@
|
||||
# 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
|
||||
@ -26,5 +26,13 @@ COPY --from=build /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/apps/bot /app/apps/bot
|
||||
COPY --from=build /app/packages/db /app/packages/db
|
||||
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
|
||||
CMD ["node", "apps/bot/dist/index.js"]
|
||||
|
||||
@ -18,7 +18,20 @@ COPY tsconfig.base.json turbo.json ./
|
||||
COPY apps/web apps/web
|
||||
COPY packages/db packages/db
|
||||
COPY packages/shared packages/shared
|
||||
RUN pnpm --filter @cmbot/shared build && \
|
||||
# Placeholder env values during `next build`'s "Collecting page data"
|
||||
# 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/web build
|
||||
|
||||
@ -29,5 +42,21 @@ ENV HOSTNAME=0.0.0.0
|
||||
COPY --from=build /app/apps/web/.next/standalone ./
|
||||
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=build /app/apps/web/public ./apps/web/public
|
||||
# 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
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
|
||||
172
docs/deploy-portainer.md
Normal file
172
docs/deploy-portainer.md
Normal file
@ -0,0 +1,172 @@
|
||||
# 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.
|
||||
200
docs/runbook.md
Normal file
200
docs/runbook.md
Normal file
@ -0,0 +1,200 @@
|
||||
# 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 <timestamp>").
|
||||
- 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.
|
||||
2314
docs/superpowers/plans/2026-05-10-auth-and-prod-hardening.md
Normal file
2314
docs/superpowers/plans/2026-05-10-auth-and-prod-hardening.md
Normal file
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
Loading…
x
Reference in New Issue
Block a user