Compare commits
No commits in common. "master" and "v1.0.0" have entirely different histories.
@ -4,7 +4,7 @@ SESSIONS_DIR=/data/sessions
|
|||||||
MEDIA_DIR=/data/media
|
MEDIA_DIR=/data/media
|
||||||
BOT_HEALTH_PORT=8081
|
BOT_HEALTH_PORT=8081
|
||||||
BOT_LOG_LEVEL=debug
|
BOT_LOG_LEVEL=debug
|
||||||
SEED_OPERATOR_USERNAME=admin
|
SEED_OPERATOR_TELEGRAM_ID=818380985
|
||||||
SEED_OPERATOR_NAME="yiekheng (dev)"
|
SEED_OPERATOR_NAME="yiekheng (dev)"
|
||||||
WEB_PORT=9000
|
WEB_PORT=9000
|
||||||
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c
|
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -18,13 +18,6 @@ apps/web/public/swe-worker-*.js
|
|||||||
# ARE committed to this private Gitea. Only ignore example overrides:
|
# ARE committed to this private Gitea. Only ignore example overrides:
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
# Anything inside envs/ EXCEPT the example template — a real env
|
|
||||||
# file (envs/ENV) leaked once into commit 6893ca6 carrying the DB
|
|
||||||
# password and AUTH_SECRET. Whitelist .env.example explicitly so a
|
|
||||||
# future copy-paste of envs/.env.example into envs/ENV (or any other
|
|
||||||
# name) gets blocked at git add time.
|
|
||||||
envs/*
|
|
||||||
!envs/.env.example
|
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
125
README.md
125
README.md
@ -6,36 +6,24 @@ the run history all from a phone home-screen icon.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**v1 production-ready.** The web app at `wabot.04080616.xyz` is the
|
**Plans 1, 2, and 3 complete.** The web app at `wabot.04080616.xyz` is
|
||||||
primary control surface; the Telegram bot has been removed.
|
the primary control surface; the Telegram bot has been removed.
|
||||||
|
|
||||||
What's working today:
|
What's working today:
|
||||||
|
|
||||||
- **Username + password auth** with role-based access (admin / user).
|
|
||||||
HttpOnly + Secure session cookies, encrypted with AES-256-GCM (so a
|
|
||||||
leaked cookie reveals nothing about userId / role) and bound to the
|
|
||||||
`OPERATOR_TOKEN_VERSION` env so a single env bump kills every
|
|
||||||
outstanding session.
|
|
||||||
- **Three-layer login rate limit** — per-IP + per-username (lower-cased
|
|
||||||
so case-rotation doesn't help) + a global backstop, so a residential-
|
|
||||||
proxy attacker can't brute one account by hopping IPs.
|
|
||||||
- **Self-hosted Next.js 16 PWA** — installable on a phone home screen.
|
- **Self-hosted Next.js 16 PWA** — installable on a phone home screen.
|
||||||
Mobile-first single-row header with a slide-out drawer; desktop
|
Mobile-first single-row header with a slide-out drawer; desktop
|
||||||
sidebar. Login lives outside the shell on a bare-header surface.
|
sidebar.
|
||||||
- **Live QR pairing** — server-side Baileys session feeds the QR
|
- **Live QR pairing** — server-side Baileys session feeds the QR
|
||||||
payload directly into the browser via Server-Sent Events. Scan,
|
payload directly into the browser via Server-Sent Events. Scan,
|
||||||
see "✅ Connected" within seconds, auto-redirect.
|
see "✅ Connected" within seconds, auto-redirect.
|
||||||
- **Duplicate-pair detection** — scanning a QR with a phone already
|
|
||||||
linked to another account row surfaces a clear "already paired as
|
|
||||||
<label>" message instead of fighting Baileys for the device.
|
|
||||||
- **Multi-account, multi-group reminders** — 5-step wizard
|
- **Multi-account, multi-group reminders** — 5-step wizard
|
||||||
(Account → Message → When → Groups → Review) plus per-section edit
|
(Account → Message → When → Groups → Review) plus per-section edit
|
||||||
pages so you don't have to walk the wizard end-to-end to fix one
|
pages so you don't have to walk the wizard end-to-end to fix one
|
||||||
field. Recurrence picker covers Daily / Weekly / Monthly / Yearly
|
field. Active recurrence picker covers Daily / Weekly / Monthly /
|
||||||
with multi-rule support and per-rule fire-time pickers; the rendered
|
Yearly with multi-rule support and per-rule fire-time pickers; the
|
||||||
description reads as plain English ("Every week on Mon, Wed, Fri at
|
rendered description reads as plain English ("Every week on Mon,
|
||||||
09:00") not raw cron. Optional "Pause sending by" deadline that
|
Wed, Fri at 09:00") not raw cron.
|
||||||
defaults OFF — operators have to opt in explicitly.
|
|
||||||
- **Multi-message stacks** — a reminder can carry multiple ordered
|
- **Multi-message stacks** — a reminder can carry multiple ordered
|
||||||
parts (text + media), fired in sequence with a 1.5 s gap. Media
|
parts (text + media), fired in sequence with a 1.5 s gap. Media
|
||||||
files swap at any time from the Edit Message page.
|
files swap at any time from the Edit Message page.
|
||||||
@ -45,29 +33,19 @@ What's working today:
|
|||||||
as a downloadable file instead of failing silently.
|
as a downloadable file instead of failing silently.
|
||||||
- **Swipe-to-act rows** — on mobile, swipe a reminder or activity
|
- **Swipe-to-act rows** — on mobile, swipe a reminder or activity
|
||||||
row left for Delete or right for Pause/Restart/Archive. iOS-Mail
|
row left for Delete or right for Pause/Restart/Archive. iOS-Mail
|
||||||
style. Click vs drag is disambiguated by a 6-px tap threshold so a
|
style.
|
||||||
swipe doesn't accidentally trigger the row's link.
|
|
||||||
- **Activity tab** — last 200 runs with status filters (Success /
|
- **Activity tab** — last 200 runs with status filters (Success /
|
||||||
Paused / Failed / Archived). Partial runs surface under both Paused
|
Partial / Failed / Skipped) plus an Archived tab. Archive a noisy
|
||||||
and Failed; Skipped runs collapse into Archived. Hard-delete and
|
run to keep the main list readable; restore later. Hard-delete
|
||||||
archive both available; run history survives a reminder deletion.
|
always available. Run history survives a reminder deletion.
|
||||||
- **Auto-reconnect on transient drops; restart-survival via Baileys
|
- **Auto-reconnect on transient drops; restart-survival via Baileys
|
||||||
session persistence.** Pair once, the device stays linked across
|
session persistence.** Pair once, the device stays linked across
|
||||||
container restarts. Logout-on-delete cleans the operator's
|
container restarts.
|
||||||
linked-devices list on the WhatsApp side too.
|
- **All actions audited.** Reminder run history queryable from the
|
||||||
- **Hardened pg-boss scheduling** — three-tier dedupe so a triple-
|
UI; per-run target results (sent / failed / skipped) preserved
|
||||||
click Save or microsecond-spaced enqueue doesn't fire a reminder
|
even when the underlying group is removed.
|
||||||
multiple times. Reschedule cancels stale jobs by singletonKey first
|
|
||||||
so a recurring next-fire never gets silently dropped.
|
|
||||||
- **Drizzle journal monotonicity guard** — `pnpm migrate` refuses to
|
|
||||||
run if the `_journal.json` `when` timestamps aren't strictly
|
|
||||||
increasing (a recurring foot-gun where drizzle would silently skip
|
|
||||||
a freshly-generated migration). CI tests + the migrate runner both
|
|
||||||
enforce.
|
|
||||||
- **All actions audited.** Per-run target results (sent / failed /
|
|
||||||
skipped) preserved even when the underlying group is removed.
|
|
||||||
|
|
||||||
Test count: **482 web + 88 bot = 570** passing.
|
Test count: **249 web + 31 shared + 26 bot = 306** passing.
|
||||||
|
|
||||||
## Host requirements
|
## Host requirements
|
||||||
|
|
||||||
@ -101,28 +79,24 @@ Prerequisites: Docker, the `wabot` database + `waBot` role on
|
|||||||
# 1. Configure env
|
# 1. Configure env
|
||||||
cp envs/.env.example .env.development
|
cp envs/.env.example .env.development
|
||||||
# edit .env.development: real DATABASE_URL, plus the LAN host to expose
|
# edit .env.development: real DATABASE_URL, plus the LAN host to expose
|
||||||
scripts/gen_auth_secret.sh --write # writes AUTH_SECRET to .env.development
|
scripts/gen_auth_secret.sh --write
|
||||||
|
|
||||||
# 2. Bring up the stack, install deps
|
# 2. Bring up the stack, install deps
|
||||||
NO_SUDO=1 scripts/dev.sh up
|
NO_SUDO=1 scripts/dev.sh up
|
||||||
NO_SUDO=1 scripts/dev.sh pnpm install
|
NO_SUDO=1 scripts/dev.sh pnpm install
|
||||||
|
|
||||||
# 3. Apply migrations and seed the bootstrap operator row
|
# 3. Apply migrations and seed your operator row
|
||||||
NO_SUDO=1 scripts/db.sh migrate
|
NO_SUDO=1 scripts/db.sh migrate
|
||||||
NO_SUDO=1 scripts/db.sh seed
|
NO_SUDO=1 scripts/db.sh seed
|
||||||
|
|
||||||
# 4. Set the bootstrap admin password (NO password is set by seed)
|
# 4. Open the web app
|
||||||
echo 'change-me-now' | scripts/set-password.sh admin
|
|
||||||
|
|
||||||
# 5. Open the web app and sign in as `admin` with the password above
|
|
||||||
# Local: http://localhost:9000
|
# Local: http://localhost:9000
|
||||||
# LAN: http://<host-ip>:9000
|
# LAN: http://<host-ip>:9000 (e.g. http://192.168.0.253:9000)
|
||||||
# Public: https://wabot.04080616.xyz
|
# Public: https://wabot.04080616.xyz (whatever your reverse proxy serves)
|
||||||
```
|
```
|
||||||
|
|
||||||
Inside the app: `/settings/users` → Add user → invite teammates with
|
Pair an account: `/accounts` → "New Account" → enter a label →
|
||||||
`user` role; promote / demote / reset password / delete from the same
|
"Pair WhatsApp" → scan the QR with WhatsApp's "Linked Devices".
|
||||||
page. The "Admin" nav entry is admin-only.
|
|
||||||
|
|
||||||
PWA install: phone Chrome → menu → "Install App" / "Add to Home
|
PWA install: phone Chrome → menu → "Install App" / "Add to Home
|
||||||
Screen". Launches fullscreen.
|
Screen". Launches fullscreen.
|
||||||
@ -130,22 +104,10 @@ Screen". Launches fullscreen.
|
|||||||
`NO_SUDO=1` is the right setting if your user is in the `docker`
|
`NO_SUDO=1` is the right setting if your user is in the `docker`
|
||||||
group (the default for this repo). Drop it if you need `sudo docker`.
|
group (the default for this repo). Drop it if you need `sudo docker`.
|
||||||
|
|
||||||
## Deploying
|
|
||||||
|
|
||||||
- **Local dev** — `NO_SUDO=1 scripts/dev.sh up` (described in Quick
|
|
||||||
start above).
|
|
||||||
- **Portainer** — push images with `scripts/publish.sh`, then deploy
|
|
||||||
the [`docker-compose.portainer.yml`](docker-compose.portainer.yml)
|
|
||||||
stack via the Portainer UI. Full walk-through:
|
|
||||||
[`docs/deploy-portainer.md`](docs/deploy-portainer.md).
|
|
||||||
|
|
||||||
## Manual test runbook
|
## Manual test runbook
|
||||||
|
|
||||||
End-to-end checks that unit tests can't cover (live Baileys,
|
End-to-end checks that unit tests can't cover (live Baileys,
|
||||||
WhatsApp delivery, swipe gestures):
|
WhatsApp delivery, swipe gestures):
|
||||||
[`docs/runbook.md`](docs/runbook.md).
|
|
||||||
|
|
||||||
The earlier wizard-only checklist still lives at
|
|
||||||
[`docs/superpowers/specs/manual-test-web.md`](docs/superpowers/specs/manual-test-web.md).
|
[`docs/superpowers/specs/manual-test-web.md`](docs/superpowers/specs/manual-test-web.md).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@ -156,14 +118,11 @@ The earlier wizard-only checklist still lives at
|
|||||||
- `packages/db/` — Drizzle schema and migrations
|
- `packages/db/` — Drizzle schema and migrations
|
||||||
- `packages/shared/` — cross-app helpers (rrule, media paths,
|
- `packages/shared/` — cross-app helpers (rrule, media paths,
|
||||||
timezones, WhatsApp media classifier)
|
timezones, WhatsApp media classifier)
|
||||||
- `docs/runbook.md` — manual end-to-end smoke checklist
|
- `docs/superpowers/specs/` — design specs and manual test runbooks
|
||||||
- `docs/superpowers/specs/` — design specs and earlier manual test
|
|
||||||
runbooks
|
|
||||||
- `docs/superpowers/plans/` — implementation plans
|
- `docs/superpowers/plans/` — implementation plans
|
||||||
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`,
|
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`,
|
||||||
`web.Dockerfile`)
|
`web.Dockerfile`)
|
||||||
- `scripts/` — `dev.sh`, `db.sh`, `gen_auth_secret.sh`,
|
- `scripts/` — `dev.sh`, `db.sh`, `gen_auth_secret.sh`
|
||||||
`set-password.sh`, `create-user.sh`
|
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
@ -175,39 +134,17 @@ container, so no host Node is needed.
|
|||||||
| `scripts/dev.sh up\|down\|logs\|status\|build\|exec\|pnpm\|shell\|restart-bot` | Stack lifecycle and tools-container shell |
|
| `scripts/dev.sh up\|down\|logs\|status\|build\|exec\|pnpm\|shell\|restart-bot` | Stack lifecycle and tools-container shell |
|
||||||
| `scripts/db.sh migrate\|generate\|studio\|seed\|reset` | Drizzle migration helper |
|
| `scripts/db.sh migrate\|generate\|studio\|seed\|reset` | Drizzle migration helper |
|
||||||
| `scripts/gen_auth_secret.sh [--write]` | Generate `AUTH_SECRET` (host-only, no Node needed) |
|
| `scripts/gen_auth_secret.sh [--write]` | Generate `AUTH_SECRET` (host-only, no Node needed) |
|
||||||
| `scripts/set-password.sh <username>` | Set / reset a user's password (reads stdin) |
|
|
||||||
| `scripts/create-user.sh <username> <role>` | Create a user from CLI (admin / user) |
|
|
||||||
|
|
||||||
Set `NO_SUDO=1` if your user is in the docker group (recommended).
|
Set `NO_SUDO=1` if your user is in the docker group (recommended).
|
||||||
|
|
||||||
## Auth + admin model
|
|
||||||
|
|
||||||
- One bootstrap operator (`admin`) is created by the seed; its
|
|
||||||
password is set via `scripts/set-password.sh admin` on first launch.
|
|
||||||
- Two roles: `admin` (full access including user management) and
|
|
||||||
`user` (everything except `/settings/users`). Role-based nav
|
|
||||||
filtering is enforced in middleware + the AppShell + every server
|
|
||||||
action that mutates user state.
|
|
||||||
- Every user gets an isolated workspace — accounts, reminders,
|
|
||||||
groups, and run history all scope by `operator_id`. The admin
|
|
||||||
panel is the only cross-tenant surface.
|
|
||||||
- Sessions: AES-256-GCM-encrypted cookie keyed off `AUTH_SECRET`,
|
|
||||||
HttpOnly + Secure-in-prod + SameSite=Lax, 30-day TTL. The
|
|
||||||
`OPERATOR_TOKEN_VERSION` env (defaults to `"1"`) is the kill switch
|
|
||||||
— bumping it invalidates every outstanding cookie globally on the
|
|
||||||
next request.
|
|
||||||
- Login rate limits: 10 / 5 min per-IP + 5 / 15 min per-username + a
|
|
||||||
100 / min global backstop. The error message is identical for all
|
|
||||||
three so the limit-which-tripped isn't leaked.
|
|
||||||
|
|
||||||
## Deferred
|
## Deferred
|
||||||
|
|
||||||
- **Standalone media library** browser (currently media is uploaded
|
- **Standalone media library** browser (currently media is uploaded
|
||||||
per-reminder).
|
per-reminder).
|
||||||
- **E2E browser tests** (Playwright) on the swipe and pairing flows.
|
- **E2E browser tests** (Playwright) on the swipe and pairing flows.
|
||||||
- **Search-as-you-type in the wizard's groups picker** — at 3 000+
|
- **Auth** (passkeys / email-password) — bring back if URL exposure
|
||||||
groups per account the picker still loads the alphabetical
|
becomes a concern. Today the app trusts whatever's in front of the
|
||||||
top-200; operators with >200 groups need to use the list page's
|
reverse proxy.
|
||||||
search to find anything past 'L'.
|
- **Multi-operator** — schema supports `operator_id` on every row,
|
||||||
- **Self-service password reset** (email link, etc.) — out of scope
|
but the seed runs as a single operator and there's no /signup or
|
||||||
for v1; admins use the Users page.
|
invite flow yet.
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
registerDefaultHandlers,
|
registerDefaultHandlers,
|
||||||
} from "./ipc/command-consumer.js";
|
} from "./ipc/command-consumer.js";
|
||||||
import { sweepStalePendingAccounts } from "./ipc/pair-handler.js";
|
import { sweepStalePendingAccounts } from "./ipc/pair-handler.js";
|
||||||
import { sweepStalePendingRuns } from "./scheduler/sweep-stale-runs.js";
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
logger.info("bot starting");
|
logger.info("bot starting");
|
||||||
@ -23,7 +22,6 @@ async function main(): Promise<void> {
|
|||||||
const stopConsumer = await startCommandConsumer();
|
const stopConsumer = await startCommandConsumer();
|
||||||
|
|
||||||
await sweepStalePendingAccounts();
|
await sweepStalePendingAccounts();
|
||||||
await sweepStalePendingRuns();
|
|
||||||
await sessionManager.resumeFromDb();
|
await sessionManager.resumeFromDb();
|
||||||
|
|
||||||
const shutdown = async (signal: string): Promise<void> => {
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
|||||||
@ -3,26 +3,17 @@ import type { Notification } from "pg";
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { handleStartPairing } from "./pair-handler.js";
|
import { handleStartPairing } from "./pair-handler.js";
|
||||||
import { handleUnpair, handleDelete } from "./unpair-handler.js";
|
import { handleUnpair } from "./unpair-handler.js";
|
||||||
import { handleSyncGroups } from "./sync-groups-handler.js";
|
import { handleSyncGroups } from "./sync-groups-handler.js";
|
||||||
import { handleSendTest } from "./send-test-handler.js";
|
import { handleSendTest } from "./send-test-handler.js";
|
||||||
import {
|
import { handleScheduleReminder } from "./schedule-reminder-handler.js";
|
||||||
handleScheduleReminder,
|
|
||||||
handleResumeReminder,
|
|
||||||
} from "./schedule-reminder-handler.js";
|
|
||||||
|
|
||||||
export type BotCommand =
|
export type BotCommand =
|
||||||
| { type: "account.start_pairing"; accountId: string }
|
| { type: "account.start_pairing"; accountId: string }
|
||||||
| { type: "account.unpair"; accountId: string }
|
| { type: "account.unpair"; accountId: string }
|
||||||
// Like unpair, but tells WhatsApp to drop this device from the
|
|
||||||
// user's linked-devices list first via socket.logout(). The web
|
|
||||||
// action calls this immediately before deleting the row so the
|
|
||||||
// operator's phone doesn't keep showing a phantom linked device.
|
|
||||||
| { type: "account.delete"; accountId: string }
|
|
||||||
| { type: "account.sync_groups"; accountId: string }
|
| { type: "account.sync_groups"; accountId: string }
|
||||||
| { type: "group.send_test"; groupId: string; text: string }
|
| { type: "group.send_test"; groupId: string; text: string }
|
||||||
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string };
|
||||||
| { type: "reminder.resume"; reminderId: string; runId: string };
|
|
||||||
|
|
||||||
type Handler = (cmd: BotCommand) => Promise<void>;
|
type Handler = (cmd: BotCommand) => Promise<void>;
|
||||||
const handlers: { [K in BotCommand["type"]]?: (cmd: Extract<BotCommand, { type: K }>) => Promise<void> } = {};
|
const handlers: { [K in BotCommand["type"]]?: (cmd: Extract<BotCommand, { type: K }>) => Promise<void> } = {};
|
||||||
@ -79,9 +70,6 @@ export function registerDefaultHandlers(): void {
|
|||||||
registerHandler("account.unpair", async (cmd) => {
|
registerHandler("account.unpair", async (cmd) => {
|
||||||
await handleUnpair(cmd.accountId);
|
await handleUnpair(cmd.accountId);
|
||||||
});
|
});
|
||||||
registerHandler("account.delete", async (cmd) => {
|
|
||||||
await handleDelete(cmd.accountId);
|
|
||||||
});
|
|
||||||
registerHandler("account.sync_groups", async (cmd) => {
|
registerHandler("account.sync_groups", async (cmd) => {
|
||||||
await handleSyncGroups(cmd.accountId);
|
await handleSyncGroups(cmd.accountId);
|
||||||
});
|
});
|
||||||
@ -91,7 +79,4 @@ export function registerDefaultHandlers(): void {
|
|||||||
registerHandler("reminder.schedule", async (cmd) => {
|
registerHandler("reminder.schedule", async (cmd) => {
|
||||||
await handleScheduleReminder(cmd.reminderId, cmd.scheduledAtIso);
|
await handleScheduleReminder(cmd.reminderId, cmd.scheduledAtIso);
|
||||||
});
|
});
|
||||||
registerHandler("reminder.resume", async (cmd) => {
|
|
||||||
await handleResumeReminder(cmd.reminderId, cmd.runId);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,28 +10,8 @@ export type WebEvent =
|
|||||||
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
||||||
| { type: "session.disconnected"; accountId: string }
|
| { type: "session.disconnected"; accountId: string }
|
||||||
| { type: "session.timeout"; accountId: string }
|
| { type: "session.timeout"; accountId: string }
|
||||||
// Operator scanned the QR with a phone that's already linked to another
|
|
||||||
// account row. We park the new pairing instead of letting two account
|
|
||||||
// rows fight over the same WhatsApp device. existingLabel surfaces in
|
|
||||||
// the UI so the operator knows which account already owns the phone.
|
|
||||||
| {
|
|
||||||
type: "session.duplicate";
|
|
||||||
accountId: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
existingLabel: string;
|
|
||||||
}
|
|
||||||
| { type: "groups.synced"; accountId: string; count: number }
|
| { type: "groups.synced"; accountId: string; count: number }
|
||||||
| {
|
| { type: "reminder.fired"; reminderId: string; runId: string; status: string }
|
||||||
type: "reminder.fired";
|
|
||||||
reminderId: string;
|
|
||||||
runId: string;
|
|
||||||
status: string;
|
|
||||||
// Optional delivered/total counts so the web side can render
|
|
||||||
// "X of Y groups delivered" in the paused-status notification
|
|
||||||
// body. Omitted on terminal-status events that don't need them.
|
|
||||||
sent?: number;
|
|
||||||
total?: number;
|
|
||||||
}
|
|
||||||
| { type: "reminder.failed"; reminderId: string; error: string }
|
| { type: "reminder.failed"; reminderId: string; error: string }
|
||||||
// The web action enqueues a send_test via pg_notify and shows
|
// The web action enqueues a send_test via pg_notify and shows
|
||||||
// "Sending…" optimistically. This event closes the loop.
|
// "Sending…" optimistically. This event closes the loop.
|
||||||
|
|||||||
@ -10,23 +10,11 @@ import { renderQrPng } from "../whatsapp/qr-renderer.js";
|
|||||||
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
|
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
|
||||||
import { writeAuditLog } from "../audit.js";
|
import { writeAuditLog } from "../audit.js";
|
||||||
import { pgNotifyWeb } from "./notify.js";
|
import { pgNotifyWeb } from "./notify.js";
|
||||||
import {
|
|
||||||
decidePairListenerOnClose,
|
|
||||||
findDuplicateExistingAccount,
|
|
||||||
nextWarmingUpAfterEvent,
|
|
||||||
} from "./pair-state.js";
|
|
||||||
|
|
||||||
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
|
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
const offByAccount = new Map<string, () => void>();
|
const offByAccount = new Map<string, () => void>();
|
||||||
const lastQrPayload = new Map<string, string>();
|
const lastQrPayload = new Map<string, string>();
|
||||||
const pairTimeouts = new Map<string, NodeJS.Timeout>();
|
const pairTimeouts = new Map<string, NodeJS.Timeout>();
|
||||||
// "Warming" set: while present, the just-attached listener will ignore
|
|
||||||
// close events. Cleared the moment a qr/open arrives. This prevents the
|
|
||||||
// old session's close (broadcast asynchronously by sessionManager after
|
|
||||||
// our await sessionManager.stop() returns) from being mis-read as the
|
|
||||||
// NEW session timing out — which manifested as: get QR → go back →
|
|
||||||
// click Pair again → instantly see "Pairing timed out".
|
|
||||||
const pairingWarmingUp = new Set<string>();
|
|
||||||
|
|
||||||
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
|
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
|
||||||
const account = await db.query.whatsappAccounts.findFirst({
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
@ -46,7 +34,6 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
|
|||||||
pairTimeouts.delete(accountId);
|
pairTimeouts.delete(accountId);
|
||||||
}
|
}
|
||||||
lastQrPayload.delete(accountId);
|
lastQrPayload.delete(accountId);
|
||||||
pairingWarmingUp.delete(accountId);
|
|
||||||
if (sessionManager.hasSession(accountId)) {
|
if (sessionManager.hasSession(accountId)) {
|
||||||
await sessionManager.stop(accountId);
|
await sessionManager.stop(accountId);
|
||||||
}
|
}
|
||||||
@ -93,17 +80,10 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
.set({ lastQrPng: null })
|
.set({ lastQrPng: null })
|
||||||
.where(eq(whatsappAccounts.id, accountId));
|
.where(eq(whatsappAccounts.id, accountId));
|
||||||
|
|
||||||
// Mark the new attempt as warming up. Cleared by the first qr/open we
|
|
||||||
// observe; while set, any close event is treated as the leaked tail of
|
|
||||||
// the previous session being torn down (see comment near
|
|
||||||
// `pairingWarmingUp` declaration).
|
|
||||||
pairingWarmingUp.add(accountId);
|
|
||||||
|
|
||||||
const off = sessionManager.on(async (id, _state, event) => {
|
const off = sessionManager.on(async (id, _state, event) => {
|
||||||
if (id !== accountId) return;
|
if (id !== accountId) return;
|
||||||
try {
|
try {
|
||||||
if (event.type === "qr") {
|
if (event.type === "qr") {
|
||||||
pairingWarmingUp.delete(id);
|
|
||||||
// Dedupe by payload — Baileys can re-emit the same QR string in a
|
// Dedupe by payload — Baileys can re-emit the same QR string in a
|
||||||
// burst. Different strings (a fresh QR) always pass through, so
|
// burst. Different strings (a fresh QR) always pass through, so
|
||||||
// the user gets a new QR as soon as Baileys generates one.
|
// the user gets a new QR as soon as Baileys generates one.
|
||||||
@ -122,7 +102,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
});
|
});
|
||||||
} else if (event.type === "open") {
|
} else if (event.type === "open") {
|
||||||
pairingWarmingUp.delete(id);
|
|
||||||
const t = pairTimeouts.get(id);
|
const t = pairTimeouts.get(id);
|
||||||
if (t) {
|
if (t) {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
@ -130,53 +109,6 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
lastQrPayload.delete(id);
|
lastQrPayload.delete(id);
|
||||||
offByAccount.delete(id);
|
offByAccount.delete(id);
|
||||||
|
|
||||||
// Duplicate-pair guard. Operator scanned the QR with a phone
|
|
||||||
// that's already linked to another account row. Letting both
|
|
||||||
// rows claim the same WhatsApp device confuses Baileys and
|
|
||||||
// turns sends into a coin flip — abandon this pairing and
|
|
||||||
// surface a clear message to the UI.
|
|
||||||
const siblings = await db.query.whatsappAccounts.findMany({
|
|
||||||
where: (a, { eq: dEq }) => dEq(a.operatorId, account.operatorId),
|
|
||||||
columns: { id: true, phoneNumber: true, label: true },
|
|
||||||
});
|
|
||||||
const dup = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: id,
|
|
||||||
currentPhoneNumber: event.phoneNumber,
|
|
||||||
siblings,
|
|
||||||
});
|
|
||||||
if (dup) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
accountId: id,
|
|
||||||
phoneNumber: event.phoneNumber,
|
|
||||||
existingAccountId: dup.existingAccountId,
|
|
||||||
existingLabel: dup.existingLabel,
|
|
||||||
},
|
|
||||||
"pair: duplicate phone — abandoning new pairing",
|
|
||||||
);
|
|
||||||
// Stop the duplicate session, scrub the partial auth blob,
|
|
||||||
// and reset the row's status. We DO NOT logout() here — the
|
|
||||||
// original account's session remains valid and the operator
|
|
||||||
// hasn't actually added a new linked device on the phone yet
|
|
||||||
// (it'd just be the freshly-completed scan, which Baileys
|
|
||||||
// hasn't yet committed to the WhatsApp side).
|
|
||||||
await sessionManager.stop(id, { intentional: true });
|
|
||||||
await rm(join(env.SESSIONS_DIR, id), { recursive: true, force: true });
|
|
||||||
await db
|
|
||||||
.update(whatsappAccounts)
|
|
||||||
.set({ status: "unpaired", lastQrPng: null, phoneNumber: null })
|
|
||||||
.where(eq(whatsappAccounts.id, id));
|
|
||||||
await pgNotifyWeb({
|
|
||||||
type: "session.duplicate",
|
|
||||||
accountId: id,
|
|
||||||
phoneNumber: event.phoneNumber!,
|
|
||||||
existingLabel: dup.existingLabel,
|
|
||||||
});
|
|
||||||
off();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = sessionManager.getSession(id);
|
const session = sessionManager.getSession(id);
|
||||||
let synced = 0;
|
let synced = 0;
|
||||||
if (session) {
|
if (session) {
|
||||||
@ -202,42 +134,27 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
count: synced,
|
count: synced,
|
||||||
});
|
});
|
||||||
off();
|
off();
|
||||||
} else if (event.type === "close") {
|
} else if (event.type === "close" && event.restartRequired) {
|
||||||
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"
|
// After the user scans, WhatsApp tells Baileys to "restart"
|
||||||
// the connection. The socket closes with status 515 and the
|
// the connection. The socket closes with status 515 and the
|
||||||
// session-manager will reopen it with the new credentials —
|
// session-manager will reopen it with the new credentials —
|
||||||
// the next `open` event finishes the pairing. Keep the
|
// the next `open` event is what completes the pairing.
|
||||||
// listener attached and don't surface a timeout to the UI.
|
// This is NOT a failure: keep the listener attached so we see
|
||||||
//
|
// that subsequent `open` event, and don't surface a timeout
|
||||||
// Re-arm the warming-up flag: the session-manager schedules a
|
// to the UI. The DB row stays in `pending` until `open`.
|
||||||
// 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(
|
logger.info(
|
||||||
{ accountId: id },
|
{ accountId: id },
|
||||||
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
|
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
|
||||||
);
|
);
|
||||||
return;
|
// The session-manager handles the actual reconnect; nothing to
|
||||||
}
|
// do here other than NOT tear our listener / DB state down.
|
||||||
// decision === "treat-as-timeout": ephemeral close on a live
|
} else if (event.type === "close") {
|
||||||
// attempt. Park the row as `unpaired` and push session.timeout
|
// During the pairing window, any other close means the QR window
|
||||||
// so the operator sees the "Re-pair" affordance.
|
// ended without a successful link — Baileys' default is to
|
||||||
|
// close after exhausting QR refs (~2.5 min). Surface this to
|
||||||
|
// the UI so the user gets a "pairing timed out" screen, and
|
||||||
|
// park the row in a stable state so it shows up cleanly on
|
||||||
|
// the accounts list with a "Re-pair" affordance.
|
||||||
const t = pairTimeouts.get(id);
|
const t = pairTimeouts.get(id);
|
||||||
if (t) {
|
if (t) {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
|
|||||||
@ -2,9 +2,6 @@ import { describe, it, expect } from "vitest";
|
|||||||
import {
|
import {
|
||||||
decideOnPairClose,
|
decideOnPairClose,
|
||||||
decideOnPairTimeout,
|
decideOnPairTimeout,
|
||||||
decidePairListenerOnClose,
|
|
||||||
findDuplicateExistingAccount,
|
|
||||||
nextWarmingUpAfterEvent,
|
|
||||||
shouldAutoReconnect,
|
shouldAutoReconnect,
|
||||||
} from "./pair-state.js";
|
} from "./pair-state.js";
|
||||||
|
|
||||||
@ -85,225 +82,3 @@ describe("shouldAutoReconnect", () => {
|
|||||||
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
|
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("decidePairListenerOnClose (back→re-pair flicker regression)", () => {
|
|
||||||
it("ignores a close while warming up — even if also restartRequired", () => {
|
|
||||||
// The exact bug: stop() was awaited, listener attached, then the OLD
|
|
||||||
// session's close arrives and races our new listener. Warming-up
|
|
||||||
// wins over every other branch so the UI never sees a spurious
|
|
||||||
// session.timeout before the new QR is rendered.
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: true, restartRequired: false }),
|
|
||||||
).toBe("ignore-leaked-close");
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
|
|
||||||
).toBe("ignore-leaked-close");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a close on a live attempt (warmingUp=false) as a real timeout", () => {
|
|
||||||
// Refs exhausted, network blip, etc. — operator gets the
|
|
||||||
// "Pairing timed out" screen and a Re-pair affordance.
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: false, restartRequired: false }),
|
|
||||||
).toBe("treat-as-timeout");
|
|
||||||
expect(decidePairListenerOnClose({ warmingUp: false })).toBe("treat-as-timeout");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the restart-required (post-pair-success) branch when not warming up", () => {
|
|
||||||
// Status 515 close: the session-manager will reconnect and the next
|
|
||||||
// `open` finishes the pair. We must NOT push session.timeout here.
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: false, restartRequired: true }),
|
|
||||||
).toBe("post-pair-restart");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warming-up overrides restartRequired so 515 from a stale session is also swallowed", () => {
|
|
||||||
// Defense-in-depth: if Baileys' restart-required close from the OLD
|
|
||||||
// session somehow leaks through, treating it as a real 515 would
|
|
||||||
// KEEP the listener attached forever (no reconnect comes from a
|
|
||||||
// session we just stopped). Ignore it entirely until a fresh qr/open.
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
|
|
||||||
).toBe("ignore-leaked-close");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("nextWarmingUpAfterEvent (pair-listener flag transitions)", () => {
|
|
||||||
it("first qr from the live session clears warming-up", () => {
|
|
||||||
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "qr" })).toBe(false);
|
|
||||||
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "qr" })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("first open from the live session clears warming-up", () => {
|
|
||||||
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "open" })).toBe(false);
|
|
||||||
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "open" })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("RE-ARMS warming-up on a restart-required close (post-pair-success)", () => {
|
|
||||||
// The regression: after the user scans, Baileys closes with status
|
|
||||||
// 515 and the session-manager schedules a stop().then(start())
|
|
||||||
// reconnect. That cleanup-stop emits a SECOND close that arrives
|
|
||||||
// before the new socket reopens. If warming-up isn't re-armed
|
|
||||||
// between the two closes, the second one resolves to
|
|
||||||
// 'treat-as-timeout' and detaches the listener right at the
|
|
||||||
// moment the user actually paired successfully — UI never gets
|
|
||||||
// session.connected.
|
|
||||||
expect(
|
|
||||||
nextWarmingUpAfterEvent({ warmingUp: false, event: "close", restartRequired: true }),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
nextWarmingUpAfterEvent({ warmingUp: true, event: "close", restartRequired: true }),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("plain close leaves warming-up unchanged", () => {
|
|
||||||
// The pair-handler decides what to DO with a non-restart close
|
|
||||||
// separately (decidePairListenerOnClose). The warming-up flag
|
|
||||||
// doesn't change as a side effect — the listener either detaches
|
|
||||||
// (treat-as-timeout) or already returned (ignore-leaked-close).
|
|
||||||
expect(
|
|
||||||
nextWarmingUpAfterEvent({ warmingUp: false, event: "close" }),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
nextWarmingUpAfterEvent({ warmingUp: true, event: "close" }),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("end-to-end successful-pair sequence: warming → qr → restart-required close → open", () => {
|
|
||||||
// Full lifecycle the helper has to thread correctly so the user
|
|
||||||
// sees 'Account connected!' instead of 'Pairing timed out'.
|
|
||||||
let warming = true; // freshly attached listener after a re-pair
|
|
||||||
|
|
||||||
// First QR arrives — clears the leak-protection flag.
|
|
||||||
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "qr" });
|
|
||||||
expect(warming).toBe(false);
|
|
||||||
|
|
||||||
// User scans → Baileys closes with restartRequired=true.
|
|
||||||
// Re-arms because session-manager will run another stop+start.
|
|
||||||
warming = nextWarmingUpAfterEvent({
|
|
||||||
warmingUp: warming,
|
|
||||||
event: "close",
|
|
||||||
restartRequired: true,
|
|
||||||
});
|
|
||||||
expect(warming).toBe(true);
|
|
||||||
|
|
||||||
// The cleanup-stop's second close arrives. The CALLER decides via
|
|
||||||
// decidePairListenerOnClose to ignore it (warmingUp=true wins).
|
|
||||||
expect(
|
|
||||||
decidePairListenerOnClose({ warmingUp: warming, restartRequired: false }),
|
|
||||||
).toBe("ignore-leaked-close");
|
|
||||||
// Flag stays armed because a plain close doesn't change it.
|
|
||||||
warming = nextWarmingUpAfterEvent({
|
|
||||||
warmingUp: warming,
|
|
||||||
event: "close",
|
|
||||||
restartRequired: false,
|
|
||||||
});
|
|
||||||
expect(warming).toBe(true);
|
|
||||||
|
|
||||||
// Fresh socket opens with the new credentials → success.
|
|
||||||
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "open" });
|
|
||||||
expect(warming).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findDuplicateExistingAccount (one-phone-per-account guard)", () => {
|
|
||||||
const sibling = (id: string, phone: string | null, label: string) => ({
|
|
||||||
id,
|
|
||||||
phoneNumber: phone,
|
|
||||||
label,
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags a sibling that already holds this phone number", () => {
|
|
||||||
const r = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: "60123456789",
|
|
||||||
siblings: [
|
|
||||||
sibling("new", null, "scratch"),
|
|
||||||
sibling("existing", "60123456789", "Yiekheng-my"),
|
|
||||||
sibling("other", "60987654321", "WaBot Test"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(r).toEqual({
|
|
||||||
existingAccountId: "existing",
|
|
||||||
existingLabel: "Yiekheng-my",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when the phone is unique", () => {
|
|
||||||
const r = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: "60123456789",
|
|
||||||
siblings: [
|
|
||||||
sibling("new", null, "scratch"),
|
|
||||||
sibling("other", "60987654321", "WaBot"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(r).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes the current account from comparison (a row's own phone isn't a 'duplicate')", () => {
|
|
||||||
// After session-manager.handleEvent runs first it has already
|
|
||||||
// written phone_number on the current row. The check must skip
|
|
||||||
// that row, otherwise EVERY successful pair would match itself
|
|
||||||
// and look like a duplicate.
|
|
||||||
const r = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "self",
|
|
||||||
currentPhoneNumber: "60123456789",
|
|
||||||
siblings: [sibling("self", "60123456789", "Self")],
|
|
||||||
});
|
|
||||||
expect(r).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for null/empty/whitespace phone numbers (don't false-positive on unset)", () => {
|
|
||||||
const siblings = [
|
|
||||||
sibling("new", null, "scratch"),
|
|
||||||
sibling("a", null, "Old A"),
|
|
||||||
sibling("b", "", "Old B"),
|
|
||||||
sibling("c", " ", "Old C"),
|
|
||||||
];
|
|
||||||
expect(
|
|
||||||
findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: null,
|
|
||||||
siblings,
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: "",
|
|
||||||
siblings,
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: " ",
|
|
||||||
siblings,
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalises whitespace on both sides before comparing", () => {
|
|
||||||
const r = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: " 60123456789 ",
|
|
||||||
siblings: [sibling("existing", "60123456789", "Existing")],
|
|
||||||
});
|
|
||||||
expect(r?.existingAccountId).toBe("existing");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("picks the FIRST matching sibling when (somehow) two rows share the phone", () => {
|
|
||||||
// Defensive: this state shouldn't exist in production but the helper
|
|
||||||
// should at least be deterministic so the message is consistent.
|
|
||||||
const r = findDuplicateExistingAccount({
|
|
||||||
currentAccountId: "new",
|
|
||||||
currentPhoneNumber: "60123456789",
|
|
||||||
siblings: [
|
|
||||||
sibling("first", "60123456789", "First"),
|
|
||||||
sibling("second", "60123456789", "Second"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(r?.existingAccountId).toBe("first");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -80,106 +80,3 @@ export function decideOnPairTimeout({ current }: { current: AccountStatus }): St
|
|||||||
if (current !== "pending") return null;
|
if (current !== "pending") return null;
|
||||||
return { next: "unpaired", clearQrPng: true };
|
return { next: "unpaired", clearQrPng: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide how the pair-handler should react to a `close` event delivered
|
|
||||||
* to its listener. Three outcomes:
|
|
||||||
*
|
|
||||||
* - "ignore-leaked-close": the new attempt is still warming up and
|
|
||||||
* we're seeing the OLD session's tail close. Do nothing — don't
|
|
||||||
* emit timeout to the UI, don't touch the DB row.
|
|
||||||
* - "post-pair-restart": status-515 close from a successful scan.
|
|
||||||
* The session-manager will reconnect; we keep the listener alive
|
|
||||||
* and wait for the subsequent `open` event.
|
|
||||||
* - "treat-as-timeout": a real ephemeral close on a live attempt
|
|
||||||
* (refs exhausted, etc.). Park the row as `unpaired` and push
|
|
||||||
* `session.timeout` to the UI.
|
|
||||||
*
|
|
||||||
* Captures the regression where, after the user pulled up a QR and
|
|
||||||
* navigated back, clicking Pair again would instantly flash "Pairing
|
|
||||||
* timed out" because the await on stop() returned before
|
|
||||||
* sessionManager.handleEvent finished broadcasting the old session's
|
|
||||||
* close — and the new listener was already attached.
|
|
||||||
*/
|
|
||||||
export type PairListenerCloseDecision =
|
|
||||||
| "ignore-leaked-close"
|
|
||||||
| "post-pair-restart"
|
|
||||||
| "treat-as-timeout";
|
|
||||||
|
|
||||||
export function decidePairListenerOnClose(input: {
|
|
||||||
warmingUp: boolean;
|
|
||||||
restartRequired?: boolean;
|
|
||||||
}): PairListenerCloseDecision {
|
|
||||||
if (input.warmingUp) return "ignore-leaked-close";
|
|
||||||
if (input.restartRequired) return "post-pair-restart";
|
|
||||||
return "treat-as-timeout";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step the pair-listener's warming-up flag forward through one Baileys
|
|
||||||
* event. Captures three rules in one place so they're test-locked:
|
|
||||||
*
|
|
||||||
* - First `qr` / `open` from the live session clears warming-up
|
|
||||||
* (we've seen real session activity, future closes are real).
|
|
||||||
* - `close + restartRequired` (post-pair-success / status 515)
|
|
||||||
* RE-ARMS warming-up. The session-manager will schedule a
|
|
||||||
* `stop().then(start())` reconnect; that stop emits a second close
|
|
||||||
* before the new socket reopens. Without re-arming, the leaked
|
|
||||||
* close from the cleanup-stop reaches us with warming-up=false and
|
|
||||||
* resolves to `treat-as-timeout` — detaching the listener right at
|
|
||||||
* the moment the user actually paired successfully (regression).
|
|
||||||
* - Any other `close` keeps warming-up unchanged (the listener
|
|
||||||
* either ignored it because we're warming, or processed it as a
|
|
||||||
* real timeout / restart and is leaving the loop anyway).
|
|
||||||
*/
|
|
||||||
export function nextWarmingUpAfterEvent(input: {
|
|
||||||
warmingUp: boolean;
|
|
||||||
event: "qr" | "open" | "close";
|
|
||||||
restartRequired?: boolean;
|
|
||||||
}): boolean {
|
|
||||||
if (input.event === "qr" || input.event === "open") return false;
|
|
||||||
if (input.event === "close" && input.restartRequired) return true;
|
|
||||||
return input.warmingUp;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether a freshly-paired account is a duplicate of an
|
|
||||||
* existing account row owned by the same operator. The operator
|
|
||||||
* cannot legitimately link the same WhatsApp number to two account
|
|
||||||
* rows — Baileys keeps one auth blob per phone and the second row
|
|
||||||
* would just hijack the first's session.
|
|
||||||
*
|
|
||||||
* Inputs:
|
|
||||||
* - `currentAccountId` the row that just received the open event
|
|
||||||
* - `currentPhoneNumber` the JID-derived phone string (or null)
|
|
||||||
* - `siblings` every other operator-owned account row
|
|
||||||
*
|
|
||||||
* Returns `null` if the phone is unique (proceed normally), or a
|
|
||||||
* descriptor with the existing-row's id+label so the caller can park
|
|
||||||
* the duplicate row and surface a clear "already linked" message to
|
|
||||||
* the UI. A null/empty phone never reports a duplicate (we'd be
|
|
||||||
* comparing apples and we'd block legitimate first pairs that
|
|
||||||
* haven't received the WID yet).
|
|
||||||
*/
|
|
||||||
export interface DuplicatePairInput {
|
|
||||||
currentAccountId: string;
|
|
||||||
currentPhoneNumber: string | null | undefined;
|
|
||||||
siblings: Array<{ id: string; phoneNumber: string | null; label: string }>;
|
|
||||||
}
|
|
||||||
export interface DuplicatePairFinding {
|
|
||||||
existingAccountId: string;
|
|
||||||
existingLabel: string;
|
|
||||||
}
|
|
||||||
export function findDuplicateExistingAccount(
|
|
||||||
input: DuplicatePairInput,
|
|
||||||
): DuplicatePairFinding | null {
|
|
||||||
const phone = (input.currentPhoneNumber ?? "").trim();
|
|
||||||
if (!phone) return null;
|
|
||||||
for (const s of input.siblings) {
|
|
||||||
if (s.id === input.currentAccountId) continue;
|
|
||||||
if ((s.phoneNumber ?? "").trim() === phone) {
|
|
||||||
return { existingAccountId: s.id, existingLabel: s.label };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,16 +1,6 @@
|
|||||||
import { getBoss } from "../scheduler/pgboss-client.js";
|
import { getBoss } from "../scheduler/pgboss-client.js";
|
||||||
import {
|
import { scheduleReminderFire } from "../scheduler/reminder-jobs.js";
|
||||||
scheduleReminderFire,
|
|
||||||
enqueueReminderResume,
|
|
||||||
} from "../scheduler/reminder-jobs.js";
|
|
||||||
|
|
||||||
export async function handleScheduleReminder(reminderId: string, scheduledAtIso: string): Promise<void> {
|
export async function handleScheduleReminder(reminderId: string, scheduledAtIso: string): Promise<void> {
|
||||||
await scheduleReminderFire(getBoss(), reminderId, new Date(scheduledAtIso));
|
await scheduleReminderFire(getBoss(), reminderId, new Date(scheduledAtIso));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleResumeReminder(
|
|
||||||
reminderId: string,
|
|
||||||
runId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await enqueueReminderResume(getBoss(), reminderId, runId);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,128 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
// Hoisted spies so the vi.mock factories can reach them.
|
|
||||||
const {
|
|
||||||
stopMock,
|
|
||||||
logoutAndStopMock,
|
|
||||||
rmMock,
|
|
||||||
findFirstMock,
|
|
||||||
writeAuditLogMock,
|
|
||||||
pgNotifyWebMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
stopMock: vi.fn(async () => undefined),
|
|
||||||
logoutAndStopMock: vi.fn(async () => undefined),
|
|
||||||
rmMock: vi.fn(async () => undefined),
|
|
||||||
findFirstMock: vi.fn(async () => ({ operatorId: "op-1", label: "WaBot" })),
|
|
||||||
writeAuditLogMock: vi.fn(async () => undefined),
|
|
||||||
pgNotifyWebMock: vi.fn(async () => undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("node:fs/promises", () => ({
|
|
||||||
rm: (...args: unknown[]) => rmMock(...args),
|
|
||||||
}));
|
|
||||||
vi.mock("../db.js", () => ({
|
|
||||||
db: {
|
|
||||||
query: { whatsappAccounts: { findFirst: (...a: unknown[]) => findFirstMock(...a) } },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("../env.js", () => ({ env: { SESSIONS_DIR: "/data/sessions" } }));
|
|
||||||
vi.mock("../whatsapp/session-manager.js", () => ({
|
|
||||||
sessionManager: {
|
|
||||||
stop: (...a: unknown[]) => stopMock(...a),
|
|
||||||
logoutAndStop: (...a: unknown[]) => logoutAndStopMock(...a),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("../audit.js", () => ({
|
|
||||||
writeAuditLog: (...a: unknown[]) => writeAuditLogMock(...a),
|
|
||||||
}));
|
|
||||||
vi.mock("./notify.js", () => ({
|
|
||||||
pgNotifyWeb: (...a: unknown[]) => pgNotifyWebMock(...a),
|
|
||||||
}));
|
|
||||||
vi.mock("../logger.js", () => ({
|
|
||||||
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { handleUnpair, handleDelete } from "./unpair-handler.js";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
stopMock.mockReset();
|
|
||||||
stopMock.mockResolvedValue(undefined);
|
|
||||||
logoutAndStopMock.mockReset();
|
|
||||||
logoutAndStopMock.mockResolvedValue(undefined);
|
|
||||||
rmMock.mockReset();
|
|
||||||
rmMock.mockResolvedValue(undefined);
|
|
||||||
findFirstMock.mockReset();
|
|
||||||
findFirstMock.mockResolvedValue({ operatorId: "op-1", label: "WaBot" });
|
|
||||||
writeAuditLogMock.mockReset();
|
|
||||||
writeAuditLogMock.mockResolvedValue(undefined);
|
|
||||||
pgNotifyWebMock.mockReset();
|
|
||||||
pgNotifyWebMock.mockResolvedValue(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handleUnpair", () => {
|
|
||||||
it("stops the session WITHOUT logout, removes files, audits, notifies", async () => {
|
|
||||||
await handleUnpair("acct-A");
|
|
||||||
// The unpair flow MUST NOT call logoutAndStop — that would tell
|
|
||||||
// WhatsApp to drop the linked device, which the operator might
|
|
||||||
// re-pair shortly after. logoutAndStop is only for permanent
|
|
||||||
// delete.
|
|
||||||
expect(logoutAndStopMock).not.toHaveBeenCalled();
|
|
||||||
expect(stopMock).toHaveBeenCalledWith("acct-A", { intentional: true });
|
|
||||||
expect(rmMock).toHaveBeenCalled();
|
|
||||||
expect(writeAuditLogMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ action: "account.unpaired", targetId: "acct-A" }),
|
|
||||||
);
|
|
||||||
expect(pgNotifyWebMock).toHaveBeenCalledWith({
|
|
||||||
type: "session.disconnected",
|
|
||||||
accountId: "acct-A",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handleDelete (logout-before-teardown)", () => {
|
|
||||||
it("calls logoutAndStop BEFORE rm so WhatsApp drops the linked device first", async () => {
|
|
||||||
await handleDelete("acct-A");
|
|
||||||
expect(logoutAndStopMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(logoutAndStopMock).toHaveBeenCalledWith("acct-A");
|
|
||||||
expect(rmMock).toHaveBeenCalledTimes(1);
|
|
||||||
// Order: logout-and-stop must invoke before rm (otherwise the
|
|
||||||
// socket was torn down on disk before WhatsApp could be told to
|
|
||||||
// drop the linked device).
|
|
||||||
expect(logoutAndStopMock.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
rmMock.mock.invocationCallOrder[0]!,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT call the plain stop() — that's reserved for unpair", async () => {
|
|
||||||
// Sanity guard: a refactor that swaps logoutAndStop for stop()
|
|
||||||
// would silently regress the linked-device cleanup. The test
|
|
||||||
// pins the contract.
|
|
||||||
await handleDelete("acct-A");
|
|
||||||
expect(stopMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes an account.deleted audit log carrying the row's label", async () => {
|
|
||||||
findFirstMock.mockResolvedValueOnce({ operatorId: "op-7", label: "Yiekheng-my" });
|
|
||||||
await handleDelete("acct-X");
|
|
||||||
expect(writeAuditLogMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
action: "account.deleted",
|
|
||||||
operatorId: "op-7",
|
|
||||||
targetId: "acct-X",
|
|
||||||
payload: { label: "Yiekheng-my" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still completes when the audit-log lookup fails (best-effort)", async () => {
|
|
||||||
// The web action runs the cascade DELETE right after; if the row
|
|
||||||
// is gone before this handler reads it, the audit lookup throws.
|
|
||||||
// Delete must not strand on that.
|
|
||||||
findFirstMock.mockRejectedValueOnce(new Error("no such row"));
|
|
||||||
await expect(handleDelete("acct-A")).resolves.toBeUndefined();
|
|
||||||
expect(rmMock).toHaveBeenCalled();
|
|
||||||
expect(pgNotifyWebMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -39,41 +39,3 @@ export async function handleUnpair(accountId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
await pgNotifyWeb({ type: "session.disconnected", accountId });
|
await pgNotifyWeb({ type: "session.disconnected", accountId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete-account flow on the bot side. Distinct from unpair because
|
|
||||||
* we want WhatsApp to drop this device from the user's linked-devices
|
|
||||||
* list — otherwise the phone keeps showing a phantom entry that has
|
|
||||||
* to be manually removed from WhatsApp's UI.
|
|
||||||
*
|
|
||||||
* Order is important:
|
|
||||||
* 1. socket.logout() over the still-connected socket → WhatsApp
|
|
||||||
* removes the linked device on the server side.
|
|
||||||
* 2. close() the local Baileys session.
|
|
||||||
* 3. rm() the on-disk auth blob so the next pairing starts clean.
|
|
||||||
*
|
|
||||||
* Step 1 is best-effort — if the socket is already torn down or the
|
|
||||||
* RPC fails the delete still proceeds. The web action then deletes
|
|
||||||
* the row (cascade FKs handle groups/reminders/runs).
|
|
||||||
*/
|
|
||||||
export async function handleDelete(accountId: string): Promise<void> {
|
|
||||||
await sessionManager.logoutAndStop(accountId);
|
|
||||||
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
|
|
||||||
try {
|
|
||||||
const row = await db.query.whatsappAccounts.findFirst({
|
|
||||||
where: (a, { eq }) => eq(a.id, accountId),
|
|
||||||
columns: { operatorId: true, label: true },
|
|
||||||
});
|
|
||||||
await writeAuditLog(db, {
|
|
||||||
operatorId: row?.operatorId ?? null,
|
|
||||||
source: "web",
|
|
||||||
action: "account.deleted",
|
|
||||||
targetType: "whatsapp_account",
|
|
||||||
targetId: accountId,
|
|
||||||
payload: { label: row?.label ?? null },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn({ err, accountId }, "delete: audit log failed (non-fatal)");
|
|
||||||
}
|
|
||||||
await pgNotifyWeb({ type: "session.disconnected", accountId });
|
|
||||||
}
|
|
||||||
|
|||||||
@ -18,24 +18,13 @@ const getReminderMock = vi.fn();
|
|||||||
vi.mock("../reminders/crud.js", () => ({
|
vi.mock("../reminders/crud.js", () => ({
|
||||||
getReminderWithDetails: (...args: unknown[]) => getReminderMock(...args),
|
getReminderWithDetails: (...args: unknown[]) => getReminderMock(...args),
|
||||||
}));
|
}));
|
||||||
// Drizzle's chainable query builders are mocked just deeply enough to
|
|
||||||
// let fire-reminder's happy path (and the resume path) walk through.
|
|
||||||
const findExistingRunMock = vi.fn();
|
|
||||||
vi.mock("../db.js", () => ({
|
vi.mock("../db.js", () => ({
|
||||||
db: {
|
db: {
|
||||||
insert: () => ({
|
insert: () => ({ values: () => ({ returning: async () => [{ id: "run-1" }] }) }),
|
||||||
values: () => ({
|
|
||||||
returning: async () => [{ id: "run-1" }],
|
|
||||||
}),
|
|
||||||
// Targets path: no .returning() chained.
|
|
||||||
values_no_returning: async () => undefined,
|
|
||||||
}),
|
|
||||||
update: () => ({ set: () => ({ where: async () => undefined }) }),
|
update: () => ({ set: () => ({ where: async () => undefined }) }),
|
||||||
query: {
|
query: {
|
||||||
whatsappGroups: { findMany: async () => [] },
|
whatsappGroups: { findMany: async () => [] },
|
||||||
mediaFiles: { findMany: async () => [] },
|
mediaFiles: { findMany: async () => [] },
|
||||||
reminderRunTargets: { findMany: async () => [] },
|
|
||||||
reminderRuns: { findFirst: (...args: unknown[]) => findExistingRunMock(...args) },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -54,7 +43,6 @@ describe("fireReminder", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(accountMutex.run).mockClear();
|
vi.mocked(accountMutex.run).mockClear();
|
||||||
getReminderMock.mockReset();
|
getReminderMock.mockReset();
|
||||||
findExistingRunMock.mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("acquires accountMutex keyed by accountId for active reminders", async () => {
|
it("acquires accountMutex keyed by accountId for active reminders", async () => {
|
||||||
@ -68,8 +56,6 @@ describe("fireReminder", () => {
|
|||||||
scheduleKind: "one_off",
|
scheduleKind: "one_off",
|
||||||
rrule: null,
|
rrule: null,
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
deliveryWindowStartHour: 6,
|
|
||||||
deliveryWindowEndHour: 18,
|
|
||||||
name: "Test",
|
name: "Test",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -83,15 +69,13 @@ describe("fireReminder", () => {
|
|||||||
getReminderMock.mockResolvedValue({
|
getReminderMock.mockResolvedValue({
|
||||||
id: "r-1",
|
id: "r-1",
|
||||||
accountId: "acct-A",
|
accountId: "acct-A",
|
||||||
status: "inactive",
|
status: "ended",
|
||||||
targets: [],
|
targets: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
createdBy: "op-1",
|
createdBy: "op-1",
|
||||||
scheduleKind: "one_off",
|
scheduleKind: "one_off",
|
||||||
rrule: null,
|
rrule: null,
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
deliveryWindowStartHour: 6,
|
|
||||||
deliveryWindowEndHour: 18,
|
|
||||||
name: "Test",
|
name: "Test",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -108,111 +92,6 @@ describe("fireReminder", () => {
|
|||||||
expect(accountMutex.run).not.toHaveBeenCalled();
|
expect(accountMutex.run).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("BAILS OUT INSIDE the mutex when a concurrent job inserted a run while we were queued (TOCTOU repro)", async () => {
|
|
||||||
// Repro: three pg-boss jobs arrive in the same microsecond. All
|
|
||||||
// three pass the OUTER recent-run check (no run exists yet) and
|
|
||||||
// queue up on the per-account mutex. The first acquires, INSERTs
|
|
||||||
// a run, sends. The second acquires AFTER the first finished —
|
|
||||||
// its inner check now sees the just-inserted run and must bail,
|
|
||||||
// otherwise the message would be sent twice (or three times for
|
|
||||||
// the third job). Without the inner check this regression
|
|
||||||
// produced "qwerd msg three times" in production.
|
|
||||||
getReminderMock.mockResolvedValue({
|
|
||||||
id: "r-1",
|
|
||||||
accountId: "acct-A",
|
|
||||||
status: "active",
|
|
||||||
targets: [],
|
|
||||||
messages: [],
|
|
||||||
createdBy: "op-1",
|
|
||||||
scheduleKind: "one_off",
|
|
||||||
rrule: null,
|
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
|
||||||
deliveryWindowStartHour: 6,
|
|
||||||
deliveryWindowEndHour: 18,
|
|
||||||
name: "Test",
|
|
||||||
});
|
|
||||||
// First call (outer check) returns no recent run → mutex acquired.
|
|
||||||
// Second call (inner check inside fireReminderInner) returns a
|
|
||||||
// freshly-inserted run from the concurrent winner, so the INSERT
|
|
||||||
// path bails. We never reach the .insert(reminderRuns) builder so
|
|
||||||
// the test passes by virtue of the inner-check log + early return.
|
|
||||||
findExistingRunMock
|
|
||||||
.mockResolvedValueOnce(undefined)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
id: "run-just-inserted-by-the-other-worker",
|
|
||||||
reminderId: "r-1",
|
|
||||||
firedAt: new Date(),
|
|
||||||
status: "pending",
|
|
||||||
});
|
|
||||||
|
|
||||||
await fireReminder({ reminderId: "r-1" });
|
|
||||||
|
|
||||||
// The mutex DID get acquired (we got past the outer check), but
|
|
||||||
// the inner check should have stopped us before any side effects.
|
|
||||||
expect(accountMutex.run).toHaveBeenCalledTimes(1);
|
|
||||||
expect(findExistingRunMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => {
|
|
||||||
// Two pg-boss jobs landing within microseconds for the same
|
|
||||||
// reminder should NOT both fire. The first creates the run; the
|
|
||||||
// second sees that run is < DUPLICATE_FIRE_WINDOW_MS old and exits.
|
|
||||||
getReminderMock.mockResolvedValue({
|
|
||||||
id: "r-1",
|
|
||||||
accountId: "acct-A",
|
|
||||||
status: "active",
|
|
||||||
targets: [],
|
|
||||||
messages: [],
|
|
||||||
createdBy: "op-1",
|
|
||||||
scheduleKind: "one_off",
|
|
||||||
rrule: null,
|
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
|
||||||
deliveryWindowStartHour: 6,
|
|
||||||
deliveryWindowEndHour: 18,
|
|
||||||
name: "Test",
|
|
||||||
});
|
|
||||||
// The duplicate-fire check shares the reminderRuns.findFirst mock.
|
|
||||||
// Return a fresh run (firedAt = "just now") to simulate the
|
|
||||||
// collision.
|
|
||||||
findExistingRunMock.mockResolvedValue({
|
|
||||||
id: "run-recent",
|
|
||||||
reminderId: "r-1",
|
|
||||||
firedAt: new Date(),
|
|
||||||
status: "pending",
|
|
||||||
});
|
|
||||||
|
|
||||||
await fireReminder({ reminderId: "r-1" });
|
|
||||||
|
|
||||||
expect(accountMutex.run).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("DOES acquire the mutex on a resume even when the reminder is paused", async () => {
|
|
||||||
// Resume path must allow status='paused' (and 'active') so the
|
|
||||||
// operator can drag a paused reminder back into delivery. Fresh
|
|
||||||
// fires still require status='active'; that's covered by the
|
|
||||||
// earlier "inactive" test.
|
|
||||||
getReminderMock.mockResolvedValue({
|
|
||||||
id: "r-1",
|
|
||||||
accountId: "acct-A",
|
|
||||||
status: "paused",
|
|
||||||
targets: [],
|
|
||||||
messages: [],
|
|
||||||
createdBy: "op-1",
|
|
||||||
scheduleKind: "one_off",
|
|
||||||
rrule: null,
|
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
|
||||||
deliveryWindowStartHour: 6,
|
|
||||||
deliveryWindowEndHour: 18,
|
|
||||||
name: "Test",
|
|
||||||
});
|
|
||||||
findExistingRunMock.mockResolvedValue({ id: "run-existing" });
|
|
||||||
|
|
||||||
await fireReminder({ reminderId: "r-1", runId: "run-existing" });
|
|
||||||
|
|
||||||
expect(accountMutex.run).toHaveBeenCalledTimes(1);
|
|
||||||
expect(accountMutex.run).toHaveBeenCalledWith("acct-A", expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses different mutex keys for different accounts (cross-account isolation)", async () => {
|
it("uses different mutex keys for different accounts (cross-account isolation)", async () => {
|
||||||
getReminderMock.mockResolvedValueOnce({
|
getReminderMock.mockResolvedValueOnce({
|
||||||
id: "r-A",
|
id: "r-A",
|
||||||
@ -224,8 +103,6 @@ describe("fireReminder", () => {
|
|||||||
scheduleKind: "one_off",
|
scheduleKind: "one_off",
|
||||||
rrule: null,
|
rrule: null,
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
deliveryWindowStartHour: 6,
|
|
||||||
deliveryWindowEndHour: 18,
|
|
||||||
name: "A",
|
name: "A",
|
||||||
});
|
});
|
||||||
getReminderMock.mockResolvedValueOnce({
|
getReminderMock.mockResolvedValueOnce({
|
||||||
@ -238,8 +115,6 @@ describe("fireReminder", () => {
|
|||||||
scheduleKind: "one_off",
|
scheduleKind: "one_off",
|
||||||
rrule: null,
|
rrule: null,
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
deliveryWindowStartHour: 6,
|
|
||||||
deliveryWindowEndHour: 18,
|
|
||||||
name: "B",
|
name: "B",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -12,12 +12,7 @@ import { readFile } from "node:fs/promises";
|
|||||||
import { db } from "../db.js";
|
import { db } from "../db.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { sessionManager } from "../whatsapp/session-manager.js";
|
import { sessionManager } from "../whatsapp/session-manager.js";
|
||||||
import {
|
import { absoluteMediaPath, nextOccurrence, resolveDeliveryKind } from "@cmbot/shared";
|
||||||
absoluteMediaPath,
|
|
||||||
nextOccurrence,
|
|
||||||
resolveDeliveryKind,
|
|
||||||
windowEndAt,
|
|
||||||
} from "@cmbot/shared";
|
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { writeAuditLog } from "../audit.js";
|
import { writeAuditLog } from "../audit.js";
|
||||||
import { getReminderWithDetails } from "../reminders/crud.js";
|
import { getReminderWithDetails } from "../reminders/crud.js";
|
||||||
@ -28,23 +23,7 @@ import { accountMutex } from "./per-key-mutex.js";
|
|||||||
import { accountRateLimiter } from "./rate-limiter.js";
|
import { accountRateLimiter } from "./rate-limiter.js";
|
||||||
import { MediaUploadCache } from "./media-upload-cache.js";
|
import { MediaUploadCache } from "./media-upload-cache.js";
|
||||||
|
|
||||||
export type FireReminderPayload = {
|
export type FireReminderPayload = { reminderId: string };
|
||||||
reminderId: string;
|
|
||||||
/** Optional resume hook. When present, fire-reminder ATTACHES to
|
|
||||||
* the existing run instead of creating a new one and only re-tries
|
|
||||||
* targets in `pending` status. Set by the resume server action. */
|
|
||||||
runId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Window in which two fire-reminder jobs for the same reminder are
|
|
||||||
* treated as duplicates. Generous enough to absorb real-world double-
|
|
||||||
* submits (the operator clicks Save twice; pg_notify floods the
|
|
||||||
* command-consumer; pg-boss policy didn't dedupe a microsecond-apart
|
|
||||||
* race) — short enough that a deliberately rapid recurring schedule
|
|
||||||
* (e.g. every minute, in dev) still fires every occurrence.
|
|
||||||
*/
|
|
||||||
const DUPLICATE_FIRE_WINDOW_MS = 30_000;
|
|
||||||
|
|
||||||
/** Random delay between same-group message parts. Just enough for
|
/** Random delay between same-group message parts. Just enough for
|
||||||
* visible ordering in the chat at WA's natural pace. */
|
* visible ordering in the chat at WA's natural pace. */
|
||||||
@ -85,101 +64,20 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
|
|||||||
logger.warn({ reminderId: payload.reminderId }, "fire-reminder: reminder not found");
|
logger.warn({ reminderId: payload.reminderId }, "fire-reminder: reminder not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Resumes are allowed even when the reminder's lifecycle status is
|
if (reminder.status !== "active") {
|
||||||
// 'paused' — we WANT to take a paused reminder back to active mid-
|
logger.info({ reminderId: reminder.id, status: reminder.status }, "fire-reminder: skipping (not active)");
|
||||||
// resume. Fresh fires still require status='active'.
|
|
||||||
if (!payload.runId && reminder.status !== "active") {
|
|
||||||
logger.info(
|
|
||||||
{ reminderId: reminder.id, status: reminder.status },
|
|
||||||
"fire-reminder: skipping (not active)",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defense-in-depth dedupe: if pg-boss enqueues two reminder.fire jobs
|
|
||||||
// for the same reminderId within microseconds (e.g. a duplicate
|
|
||||||
// schedule call slipped past the queue's singletonKey), the second
|
|
||||||
// worker would otherwise create a SECOND run and the same message
|
|
||||||
// gets sent twice. Bail out if a run for this reminder already exists
|
|
||||||
// and was created less than DUPLICATE_FIRE_WINDOW_MS ago.
|
|
||||||
if (!payload.runId) {
|
|
||||||
const recent = await db.query.reminderRuns.findFirst({
|
|
||||||
where: (r, { eq: dEq, and: dAnd, gt: dGt }) =>
|
|
||||||
dAnd(
|
|
||||||
dEq(r.reminderId, reminder.id),
|
|
||||||
dGt(r.firedAt, new Date(Date.now() - DUPLICATE_FIRE_WINDOW_MS)),
|
|
||||||
),
|
|
||||||
orderBy: (r, { desc }) => [desc(r.firedAt)],
|
|
||||||
});
|
|
||||||
if (recent) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
reminderId: reminder.id,
|
|
||||||
recentRunId: recent.id,
|
|
||||||
recentFiredAt: recent.firedAt,
|
|
||||||
},
|
|
||||||
"fire-reminder: duplicate fire detected (a run for this reminder was just created), skipping",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-account mutex: two reminders on the SAME account take turns
|
// Per-account mutex: two reminders on the SAME account take turns
|
||||||
// (running them concurrently would double the effective send rate
|
// (running them concurrently would double the effective send rate
|
||||||
// and risk a ban). Different accounts run in parallel.
|
// and risk a ban). Different accounts run in parallel.
|
||||||
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder, payload.runId));
|
await accountMutex.run(reminder.accountId, () => fireReminderInner(reminder));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fireReminderInner(
|
async function fireReminderInner(
|
||||||
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
|
reminder: NonNullable<Awaited<ReturnType<typeof getReminderWithDetails>>>,
|
||||||
resumeRunId?: string,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Resume path attaches to the existing run row; fresh path inserts a new one.
|
|
||||||
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
|
const [run] = await db
|
||||||
.insert(reminderRuns)
|
.insert(reminderRuns)
|
||||||
.values({
|
.values({
|
||||||
@ -188,25 +86,17 @@ async function fireReminderInner(
|
|||||||
status: "pending",
|
status: "pending",
|
||||||
})
|
})
|
||||||
.returning({ id: reminderRuns.id });
|
.returning({ id: reminderRuns.id });
|
||||||
runId = run!.id;
|
const runId = run!.id;
|
||||||
}
|
|
||||||
|
|
||||||
const session = sessionManager.getSession(reminder.accountId);
|
const session = sessionManager.getSession(reminder.accountId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected");
|
logger.warn({ reminderId: reminder.id }, "fire-reminder: account not connected");
|
||||||
if (!resumeRunId) {
|
|
||||||
await markAllSkipped(runId, reminder, "account not connected");
|
await markAllSkipped(runId, reminder, "account not connected");
|
||||||
}
|
|
||||||
await db
|
await db
|
||||||
.update(reminderRuns)
|
.update(reminderRuns)
|
||||||
.set({ status: "skipped", errorSummary: "account not connected" })
|
.set({ status: "skipped", errorSummary: "account not connected" })
|
||||||
.where(eq(reminderRuns.id, runId));
|
.where(eq(reminderRuns.id, runId));
|
||||||
await pgNotifyWeb({
|
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status: "skipped" });
|
||||||
type: "reminder.fired",
|
|
||||||
reminderId: reminder.id,
|
|
||||||
runId,
|
|
||||||
status: "skipped",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,9 +115,8 @@ async function fireReminderInner(
|
|||||||
: [];
|
: [];
|
||||||
const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
|
const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
|
||||||
|
|
||||||
// Pre-create run_target rows on the fresh path so the Activity tab
|
// Pre-create run_target rows so the Activity tab shows progress mid-run.
|
||||||
// shows progress mid-run. Resume reuses the existing rows.
|
if (reminder.targets.length > 0) {
|
||||||
if (!resumeRunId && reminder.targets.length > 0) {
|
|
||||||
await db.insert(reminderRunTargets).values(
|
await db.insert(reminderRunTargets).values(
|
||||||
reminder.targets.map((t) => ({
|
reminder.targets.map((t) => ({
|
||||||
runId,
|
runId,
|
||||||
@ -238,44 +127,11 @@ async function fireReminderInner(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On resume, only the still-pending rows are processed. On a fresh
|
// Per-run media upload cache. Each unique mediaId is prepared via
|
||||||
// fire that's every row since we just inserted them all as pending.
|
// generateWAMessageContent ONCE (which uploads to WA's CDN through
|
||||||
const pendingRows = await db.query.reminderRunTargets.findMany({
|
// the socket's waUploadToServer); the resulting proto.Message is
|
||||||
where: (t, { eq: dEq, and: dAnd }) => dAnd(dEq(t.runId, runId), dEq(t.status, "pending")),
|
// reused for every group via socket.relayMessage. For 1000 groups
|
||||||
});
|
// × 5 MB image, this turns 5 GB of upload into 5 MB.
|
||||||
const pendingGroupIds = new Set(pendingRows.map((r) => r.groupId));
|
|
||||||
const targetsToProcess = reminder.targets.filter((t) => pendingGroupIds.has(t.groupId));
|
|
||||||
|
|
||||||
// Already-sent / already-failed counts from prior run rounds (resume
|
|
||||||
// case). The final tally adds these to what THIS round produces.
|
|
||||||
const priorSentCount = resumeRunId
|
|
||||||
? (
|
|
||||||
await db.query.reminderRunTargets.findMany({
|
|
||||||
where: (t, { eq: dEq, and: dAnd }) =>
|
|
||||||
dAnd(dEq(t.runId, runId), dEq(t.status, "sent")),
|
|
||||||
})
|
|
||||||
).length
|
|
||||||
: 0;
|
|
||||||
const priorFailedCount = resumeRunId
|
|
||||||
? (
|
|
||||||
await db.query.reminderRunTargets.findMany({
|
|
||||||
where: (t, { eq: dEq, and: dAnd }) =>
|
|
||||||
dAnd(dEq(t.runId, runId), dEq(t.status, "failed")),
|
|
||||||
})
|
|
||||||
).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Window-end timestamp. If the reminder fires AFTER today's deadline
|
|
||||||
// hour (cron miss-fired late, or it's already 7pm) this is in the
|
|
||||||
// past and the FIRST gate check trips immediately, ending the run
|
|
||||||
// as failed without sending anything.
|
|
||||||
const windowEnd = windowEndAt(
|
|
||||||
reminder.timezone,
|
|
||||||
reminder.deliveryWindowEndHour,
|
|
||||||
new Date(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Per-run media upload cache (one prepare call per unique mediaId).
|
|
||||||
const uploadCache = new MediaUploadCache<proto.IMessage>(async (mediaId) => {
|
const uploadCache = new MediaUploadCache<proto.IMessage>(async (mediaId) => {
|
||||||
const media = mediaById.get(mediaId);
|
const media = mediaById.get(mediaId);
|
||||||
if (!media) throw new Error(`media row missing: ${mediaId}`);
|
if (!media) throw new Error(`media row missing: ${mediaId}`);
|
||||||
@ -301,26 +157,19 @@ async function fireReminderInner(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-account rate limiter — gates each socket send.
|
// Per-account rate limiter — gates each socket send to stay within
|
||||||
|
// the account's safe band (BOT_MAX_SEND_PER_MINUTE, default 40).
|
||||||
const rateLimiter = accountRateLimiter.get(reminder.accountId);
|
const rateLimiter = accountRateLimiter.get(reminder.accountId);
|
||||||
|
|
||||||
let sentCount = 0;
|
let sentCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
let skippedCount = 0;
|
let skippedCount = 0;
|
||||||
let windowClosed = false;
|
|
||||||
|
|
||||||
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
|
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
targetsToProcess.map((target) =>
|
reminder.targets.map((target) =>
|
||||||
groupConcurrency(async () => {
|
groupConcurrency(async () => {
|
||||||
// Window-end gate. CRITICAL: leave the row as `pending` (NOT
|
|
||||||
// `skipped`) so the run can be resumed later.
|
|
||||||
if (Date.now() >= windowEnd.getTime()) {
|
|
||||||
windowClosed = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const group = groupById.get(target.groupId);
|
const group = groupById.get(target.groupId);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
await db
|
await db
|
||||||
@ -338,6 +187,8 @@ async function fireReminderInner(
|
|||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
|
// Once per group, before the first send. sendMessage handles
|
||||||
|
// sessions internally; relayMessage does not.
|
||||||
await ensureGroupSessions(session.socket, group.waGroupJid);
|
await ensureGroupSessions(session.socket, group.waGroupJid);
|
||||||
|
|
||||||
let lastMessageId: string | undefined;
|
let lastMessageId: string | undefined;
|
||||||
@ -391,37 +242,14 @@ async function fireReminderInner(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compose the final status. Four shapes:
|
|
||||||
// paused : window closed mid-run with at least one row still pending
|
|
||||||
// AND we delivered at least one in this run or a prior round.
|
|
||||||
// Resumable. Sent rows stay sent, pending stays pending.
|
|
||||||
// success : every target sent.
|
|
||||||
// partial : every target attempted; some sent, some failed/skipped.
|
|
||||||
// failed : zero sent across all rounds, OR window closed before the
|
|
||||||
// first send (no progress to resume).
|
|
||||||
const total = reminder.targets.length;
|
const total = reminder.targets.length;
|
||||||
const totalSent = priorSentCount + sentCount;
|
let status: "success" | "partial" | "failed";
|
||||||
const totalFailed = priorFailedCount + failedCount;
|
|
||||||
const remainingPending = (
|
|
||||||
await db.query.reminderRunTargets.findMany({
|
|
||||||
where: (t, { eq: dEq, and: dAnd }) =>
|
|
||||||
dAnd(dEq(t.runId, runId), dEq(t.status, "pending")),
|
|
||||||
})
|
|
||||||
).length;
|
|
||||||
|
|
||||||
let status: "success" | "partial" | "failed" | "paused";
|
|
||||||
let errorSummary: string | null = null;
|
let errorSummary: string | null = null;
|
||||||
if (windowClosed && remainingPending > 0 && totalSent > 0) {
|
if (sentCount === total) {
|
||||||
status = "paused";
|
|
||||||
errorSummary = `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}). ${totalSent} of ${total} groups delivered, ${remainingPending} still pending. Resume from the Activity tab.`;
|
|
||||||
} else if (windowClosed && totalSent === 0) {
|
|
||||||
status = "failed";
|
|
||||||
errorSummary = `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}) before any group could be sent. The reminder fired too late in the day.`;
|
|
||||||
} else if (totalSent === total) {
|
|
||||||
status = "success";
|
status = "success";
|
||||||
} else if (totalSent > 0) {
|
} else if (sentCount > 0) {
|
||||||
status = "partial";
|
status = "partial";
|
||||||
errorSummary = `${totalSent} of ${total} groups delivered (${totalFailed} failed, ${skippedCount} skipped).`;
|
errorSummary = `${sentCount} of ${total} groups delivered (${failedCount} failed, ${skippedCount} skipped).`;
|
||||||
} else {
|
} else {
|
||||||
status = "failed";
|
status = "failed";
|
||||||
errorSummary = total === 0 ? "No targets attached to reminder." : `All ${total} sends failed.`;
|
errorSummary = total === 0 ? "No targets attached to reminder." : `All ${total} sends failed.`;
|
||||||
@ -432,45 +260,18 @@ async function fireReminderInner(
|
|||||||
.set({ status, errorSummary })
|
.set({ status, errorSummary })
|
||||||
.where(eq(reminderRuns.id, runId));
|
.where(eq(reminderRuns.id, runId));
|
||||||
|
|
||||||
await pgNotifyWeb({
|
await pgNotifyWeb({ type: "reminder.fired", reminderId: reminder.id, runId, status });
|
||||||
type: "reminder.fired",
|
|
||||||
reminderId: reminder.id,
|
|
||||||
runId,
|
|
||||||
status,
|
|
||||||
sent: totalSent,
|
|
||||||
total,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lifecycle bookkeeping. Skip when the run is paused — the reminder
|
|
||||||
// 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") {
|
if (reminder.scheduleKind === "one_off") {
|
||||||
await db
|
await db
|
||||||
.update(reminders)
|
.update(reminders)
|
||||||
.set({ status: "inactive", updatedAt: new Date() })
|
.set({ status: "ended", updatedAt: new Date() })
|
||||||
.where(eq(reminders.id, reminder.id));
|
.where(eq(reminders.id, reminder.id));
|
||||||
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
|
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
|
||||||
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
|
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
|
||||||
await db
|
await db
|
||||||
.update(reminders)
|
.update(reminders)
|
||||||
.set({
|
.set({ lastFiredAt: new Date(), updatedAt: new Date() })
|
||||||
// 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));
|
.where(eq(reminders.id, reminder.id));
|
||||||
if (next) {
|
if (next) {
|
||||||
try {
|
try {
|
||||||
@ -481,8 +282,7 @@ async function fireReminderInner(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
|
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
|
||||||
await db.update(reminders).set({ status: "inactive" }).where(eq(reminders.id, reminder.id));
|
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -496,15 +296,7 @@ async function fireReminderInner(
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{ reminderId: reminder.id, runId, status, sent: sentCount, failed: failedCount, skipped: skippedCount },
|
||||||
reminderId: reminder.id,
|
|
||||||
runId,
|
|
||||||
status,
|
|
||||||
sent: sentCount,
|
|
||||||
failed: failedCount,
|
|
||||||
skipped: skippedCount,
|
|
||||||
windowClosed,
|
|
||||||
},
|
|
||||||
"fire-reminder: done",
|
"fire-reminder: done",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
// pg-boss + db mocks. Hoisted so the vi.mock factories below can refer
|
|
||||||
// to the spies — see https://vitest.dev/api/vi.html#vi-hoisted.
|
|
||||||
const {
|
|
||||||
bossSendMock,
|
|
||||||
dbExecuteMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
bossSendMock: vi.fn(async (..._args: unknown[]) => "new-job-id"),
|
|
||||||
dbExecuteMock: vi.fn(async (..._args: unknown[]) => ({ rows: [] as unknown[] })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../db.js", () => ({
|
|
||||||
db: { execute: (...a: unknown[]) => dbExecuteMock(...a) },
|
|
||||||
}));
|
|
||||||
vi.mock("../logger.js", () => ({
|
|
||||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
// We don't import pg-boss directly — scheduleReminderFire receives a
|
|
||||||
// PgBoss instance as its first arg. Build a minimal stub that exposes
|
|
||||||
// just the .send method (and createQueue / work for registerReminderJobs
|
|
||||||
// if we ever wire it here).
|
|
||||||
const fakeBoss = {
|
|
||||||
send: bossSendMock,
|
|
||||||
} as unknown as Parameters<typeof scheduleReminderFire>[0];
|
|
||||||
|
|
||||||
import { scheduleReminderFire } from "./reminder-jobs.js";
|
|
||||||
|
|
||||||
const REMINDER_ID = "11111111-1111-1111-1111-111111111111";
|
|
||||||
const SINGLETON_KEY = `reminder:${REMINDER_ID}`;
|
|
||||||
const FIRE_AT = new Date("2026-05-10T12:20:00.000Z");
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
bossSendMock.mockReset();
|
|
||||||
bossSendMock.mockResolvedValue("new-job-id");
|
|
||||||
dbExecuteMock.mockReset();
|
|
||||||
dbExecuteMock.mockResolvedValue({ rows: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("scheduleReminderFire — pre-send cancel of stale created jobs", () => {
|
|
||||||
it("ALWAYS runs the cancel-stale UPDATE before boss.send (regression: stately dedupe dropped reschedules)", async () => {
|
|
||||||
// Repro of the dropped-fire bug: the queue was on policy=stately
|
|
||||||
// and a prior schedule had left a 'created' job in pg-boss with
|
|
||||||
// the same singletonKey. The new send returned null and the
|
|
||||||
// user's 8:20 PM fire was silently lost. Under the fix, we MUST
|
|
||||||
// tombstone any prior created jobs FIRST so the new send wins
|
|
||||||
// even under standard policy.
|
|
||||||
dbExecuteMock.mockResolvedValueOnce({ rows: [{ id: "stale-1" }] });
|
|
||||||
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
// Order matters: cancel happens before send.
|
|
||||||
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(bossSendMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(dbExecuteMock.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
bossSendMock.mock.invocationCallOrder[0]!,
|
|
||||||
);
|
|
||||||
expect(result).toBe("new-job-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scopes the cancel to state='created' only — active/completed jobs MUST survive", async () => {
|
|
||||||
// The cancel must NOT touch in-flight runs (state='active') nor
|
|
||||||
// historical fires (state='completed'). Otherwise we'd nuke the
|
|
||||||
// run that's currently sending and the user gets phantom 'failed'
|
|
||||||
// rows in the activity feed.
|
|
||||||
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
const sqlStmt = dbExecuteMock.mock.calls[0]![0];
|
|
||||||
// Drizzle's sql template returns an SQL object; serialise to inspect.
|
|
||||||
const text = JSON.stringify(sqlStmt);
|
|
||||||
expect(text).toMatch(/state\s*=\s*'?created'?/);
|
|
||||||
expect(text).not.toMatch(/state\s*=\s*'?active'?/);
|
|
||||||
expect(text).not.toMatch(/state\s*=\s*'?completed'?/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("targets only THIS reminder's singletonKey (doesn't cross-cancel other reminders)", async () => {
|
|
||||||
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
const sqlStmt = dbExecuteMock.mock.calls[0]![0];
|
|
||||||
const text = JSON.stringify(sqlStmt);
|
|
||||||
// The reminderId must appear in the WHERE clause's bound params
|
|
||||||
// (drizzle stores them in the serialised payload).
|
|
||||||
expect(text).toContain(REMINDER_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes the singleton key through to boss.send for diagnostics", async () => {
|
|
||||||
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
const [, , opts] = bossSendMock.mock.calls[0]!;
|
|
||||||
expect(opts).toMatchObject({
|
|
||||||
singletonKey: SINGLETON_KEY,
|
|
||||||
startAfter: FIRE_AT,
|
|
||||||
retryLimit: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still sends when the cancel UPDATE returns zero rows (first-time schedule)", async () => {
|
|
||||||
// First time scheduling a reminder — no stale rows exist.
|
|
||||||
dbExecuteMock.mockResolvedValueOnce({ rows: [] });
|
|
||||||
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
expect(bossSendMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result).toBe("new-job-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("DEGRADES SAFELY: if the cancel UPDATE throws, the send still runs", async () => {
|
|
||||||
// pg connection blip during cancel must not strand the schedule.
|
|
||||||
// Worst case we end up with two created jobs and the
|
|
||||||
// handler-level recent-run dedupe drops the duplicate fire.
|
|
||||||
dbExecuteMock.mockRejectedValueOnce(new Error("connection reset"));
|
|
||||||
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
expect(bossSendMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result).toBe("new-job-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns boss.send's null when pg-boss itself rejects the send", async () => {
|
|
||||||
// Defense check: if pg-boss returns null for any reason (queue
|
|
||||||
// missing, future stately-style policy quirks, etc), surface that
|
|
||||||
// up so the caller's logger captures jobId: null.
|
|
||||||
bossSendMock.mockResolvedValueOnce(null);
|
|
||||||
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,39 +1,12 @@
|
|||||||
import type { PgBoss } from "pg-boss";
|
import type { PgBoss } from "pg-boss";
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { db } from "../db.js";
|
|
||||||
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
|
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
|
||||||
|
|
||||||
export const REMINDER_FIRE_QUEUE = "reminder.fire";
|
export const REMINDER_FIRE_QUEUE = "reminder.fire";
|
||||||
|
|
||||||
export async function registerReminderJobs(boss: PgBoss): Promise<void> {
|
export async function registerReminderJobs(boss: PgBoss): Promise<void> {
|
||||||
// 'standard' (the default) lets us enqueue a new fire even when an
|
await boss.createQueue(REMINDER_FIRE_QUEUE);
|
||||||
// older one for the same singletonKey is still 'created'. We need
|
|
||||||
// that for the recurring/edit path: when a reminder is rescheduled,
|
|
||||||
// scheduleReminderFire() first cancels the stale 'created' job for
|
|
||||||
// this reminder and then sends a new one — under 'stately' the
|
|
||||||
// SECOND send returns null (it dedupes against the first across
|
|
||||||
// states), so a reschedule silently dropped the new fire and the
|
|
||||||
// reminder never fired at the new time. Duplicate-fire safety is
|
|
||||||
// covered at the handler level by the inner-mutex recent-run check
|
|
||||||
// in fire-reminder.ts (see DUPLICATE_FIRE_WINDOW_MS), which catches
|
|
||||||
// the microsecond-spaced send case 'stately' was supposed to guard.
|
|
||||||
await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "standard" });
|
|
||||||
// pg-boss v12's createQueue is idempotent and DOES NOT update the
|
|
||||||
// policy on an existing queue row. Earlier deployments forced
|
|
||||||
// policy='stately' here, which broke reschedules. Force-flip back to
|
|
||||||
// 'standard' on every boot so an old queue row doesn't strand us.
|
|
||||||
try {
|
|
||||||
await db.execute(
|
|
||||||
sql`UPDATE pgboss.queue SET policy = 'standard' WHERE name = ${REMINDER_FIRE_QUEUE} AND policy <> 'standard'`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
{ err },
|
|
||||||
"reminder.fire: failed to force queue policy=standard (handler-level dedupe still applies)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await boss.work<FireReminderPayload>(
|
await boss.work<FireReminderPayload>(
|
||||||
REMINDER_FIRE_QUEUE,
|
REMINDER_FIRE_QUEUE,
|
||||||
{
|
{
|
||||||
@ -61,33 +34,6 @@ export async function scheduleReminderFire(
|
|||||||
reminderId: string,
|
reminderId: string,
|
||||||
scheduledAt: Date,
|
scheduledAt: Date,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const singletonKey = `reminder:${reminderId}`;
|
|
||||||
// Replace-then-send. Any 'created' (i.e. not yet started) job for
|
|
||||||
// this reminder is the stale next-fire from the previous schedule
|
|
||||||
// attempt; nuke it so the new schedule wins. Active/completed jobs
|
|
||||||
// are left alone — those represent in-flight or already-fired runs
|
|
||||||
// and the handler-level dedupe handles overlap.
|
|
||||||
try {
|
|
||||||
const cancelled = await db.execute(
|
|
||||||
sql`UPDATE pgboss.job
|
|
||||||
SET state = 'cancelled', completed_on = now()
|
|
||||||
WHERE name = ${REMINDER_FIRE_QUEUE}
|
|
||||||
AND singleton_key = ${singletonKey}
|
|
||||||
AND state = 'created'
|
|
||||||
RETURNING id`,
|
|
||||||
);
|
|
||||||
if (cancelled.rows.length > 0) {
|
|
||||||
logger.info(
|
|
||||||
{ reminderId, cancelled: cancelled.rows.length },
|
|
||||||
"reminder.fire: cancelled stale created jobs before reschedule",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// If the cancellation step fails, log but still try to send. Worst
|
|
||||||
// case we end up with two created jobs and the handler-level
|
|
||||||
// recent-run dedupe drops the duplicate fire.
|
|
||||||
logger.warn({ err, reminderId }, "reminder.fire: pre-send cancel failed");
|
|
||||||
}
|
|
||||||
const id = await boss.send(
|
const id = await boss.send(
|
||||||
REMINDER_FIRE_QUEUE,
|
REMINDER_FIRE_QUEUE,
|
||||||
{ reminderId },
|
{ reminderId },
|
||||||
@ -96,41 +42,14 @@ export async function scheduleReminderFire(
|
|||||||
retryLimit: 3,
|
retryLimit: 3,
|
||||||
retryDelay: 30,
|
retryDelay: 30,
|
||||||
retryBackoff: true,
|
retryBackoff: true,
|
||||||
// Singleton key kept on the job row for diagnostics + the
|
// Use the reminderId as a singleton key so re-scheduling cancels the old job
|
||||||
// pre-send cancel above, even though 'standard' policy doesn't
|
singletonKey: `reminder:${reminderId}`,
|
||||||
// dedupe by it.
|
|
||||||
singletonKey,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");
|
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-enqueue a paused run so fire-reminder picks up the still-pending
|
|
||||||
* targets. Different singleton key from scheduleReminderFire so the
|
|
||||||
* resume doesn't clobber the next-occurrence scheduled job and vice
|
|
||||||
* versa.
|
|
||||||
*/
|
|
||||||
export async function enqueueReminderResume(
|
|
||||||
boss: PgBoss,
|
|
||||||
reminderId: string,
|
|
||||||
runId: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const id = await boss.send(
|
|
||||||
REMINDER_FIRE_QUEUE,
|
|
||||||
{ reminderId, runId },
|
|
||||||
{
|
|
||||||
retryLimit: 3,
|
|
||||||
retryDelay: 30,
|
|
||||||
retryBackoff: true,
|
|
||||||
singletonKey: `reminder:resume:${runId}`,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
logger.info({ reminderId, runId, jobId: id }, "reminder.fire: resume enqueued");
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelReminderFire(_boss: PgBoss, reminderId: string): Promise<void> {
|
export async function cancelReminderFire(_boss: PgBoss, reminderId: string): Promise<void> {
|
||||||
// Soft cancel: pg-boss doesn't expose a clean cancel-by-singleton API in v12.
|
// Soft cancel: pg-boss doesn't expose a clean cancel-by-singleton API in v12.
|
||||||
// The scheduled job will still fire, but `fireReminder` exits early when the
|
// The scheduled job will still fire, but `fireReminder` exits early when the
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Corner case under test: fire-reminder writes the run row with
|
|
||||||
* status='pending' UP FRONT. If the bot is killed before it flips to
|
|
||||||
* a terminal status, the row sits at 'pending' indefinitely — pg-boss
|
|
||||||
* won't retry (the job already ran). Activity surfaces, the dashboard
|
|
||||||
* counters, and the paused-banner all read the row at face value, so
|
|
||||||
* the operator sees a "stuck" run that never moves.
|
|
||||||
*
|
|
||||||
* sweepStalePendingRuns recovers from this on bot startup.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// db.execute fan-out: build a list of {sql, return} pairs the test
|
|
||||||
// can assert on, and replay them in order. Ordering matters because
|
|
||||||
// the implementation does TWO updates (runs first, then targets) and
|
|
||||||
// the second one must only run if the first returned anything.
|
|
||||||
const executeMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../db.js", () => ({
|
|
||||||
db: {
|
|
||||||
execute: (...a: unknown[]) => executeMock(...a),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { sweepStalePendingRuns } from "./sweep-stale-runs.js";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
executeMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sweepStalePendingRuns", () => {
|
|
||||||
it("returns 0 when no stale rows exist (skips the second UPDATE)", async () => {
|
|
||||||
executeMock.mockResolvedValueOnce({ rows: [] });
|
|
||||||
const r = await sweepStalePendingRuns();
|
|
||||||
expect(r).toEqual({ runs: 0, targets: 0 });
|
|
||||||
// Only the first UPDATE (runs) runs; no second UPDATE for targets.
|
|
||||||
expect(executeMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fires both UPDATEs when there ARE stale rows", async () => {
|
|
||||||
executeMock
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: "run-A" }, { id: "run-B" }] })
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: "t-1" }, { id: "t-2" }, { id: "t-3" }] });
|
|
||||||
|
|
||||||
const r = await sweepStalePendingRuns();
|
|
||||||
|
|
||||||
expect(r).toEqual({ runs: 2, targets: 3 });
|
|
||||||
expect(executeMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the actual swept counts so the caller can log them", async () => {
|
|
||||||
executeMock
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
rows: [{ id: "run-A" }, { id: "run-B" }, { id: "run-C" }],
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ rows: Array.from({ length: 17 }, (_, i) => ({ id: `t-${i}` })) });
|
|
||||||
|
|
||||||
const r = await sweepStalePendingRuns();
|
|
||||||
|
|
||||||
expect(r.runs).toBe(3);
|
|
||||||
expect(r.targets).toBe(17);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("doesn't throw when the targets UPDATE returns no rows (run with no pending targets)", async () => {
|
|
||||||
// A stale run with zero pending targets is unusual but possible —
|
|
||||||
// the run row got the up-front insert but the per-target inserts
|
|
||||||
// never ran. Still a stale run, still gets cleared.
|
|
||||||
executeMock
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: "run-D" }] })
|
|
||||||
.mockResolvedValueOnce({ rows: [] });
|
|
||||||
|
|
||||||
const r = await sweepStalePendingRuns();
|
|
||||||
expect(r).toEqual({ runs: 1, targets: 0 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { db } from "../db.js";
|
|
||||||
import { logger } from "../logger.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recover from "bot crashed / restarted mid-run" crashes.
|
|
||||||
*
|
|
||||||
* fire-reminder writes the run row with status='pending' UP FRONT so
|
|
||||||
* the Activity tab can show progress mid-run, then flips to a
|
|
||||||
* terminal status (success/partial/failed/paused/skipped) once it's
|
|
||||||
* done. If the bot dies between those two writes, the row sits at
|
|
||||||
* 'pending' forever — pg-boss already marked the job 'completed', so
|
|
||||||
* it won't retry.
|
|
||||||
*
|
|
||||||
* This sweep runs at bot startup. It finds any 'pending' run older
|
|
||||||
* than `maxAgeMs` (default 5 minutes — enough slack that a real
|
|
||||||
* mid-run rebalance to another worker isn't accidentally killed) and:
|
|
||||||
*
|
|
||||||
* • Flips the run to 'failed' with a clear error_summary so the UI
|
|
||||||
* stops showing it as in-flight.
|
|
||||||
* • Flips its pending run_target rows to 'skipped' with the same
|
|
||||||
* reason so per-group counts make sense.
|
|
||||||
*
|
|
||||||
* Does NOT touch the parent reminder's lifecycle status — the row was
|
|
||||||
* 'active' when the run started and stays that way; the next
|
|
||||||
* occurrence (cron) or operator action will fire a fresh run.
|
|
||||||
*/
|
|
||||||
export async function sweepStalePendingRuns(
|
|
||||||
maxAgeMs: number = 5 * 60 * 1000,
|
|
||||||
): Promise<{ runs: number; targets: number }> {
|
|
||||||
const cutoffMs = Date.now() - maxAgeMs;
|
|
||||||
const cutoff = new Date(cutoffMs);
|
|
||||||
|
|
||||||
const runs = await db.execute(sql`
|
|
||||||
UPDATE reminder_runs
|
|
||||||
SET status = 'failed',
|
|
||||||
error_summary = 'Bot restarted before this run completed.'
|
|
||||||
WHERE status = 'pending'
|
|
||||||
AND fired_at < ${cutoff}
|
|
||||||
RETURNING id
|
|
||||||
`);
|
|
||||||
const runRows = runs.rows as Array<{ id: string }>;
|
|
||||||
if (runRows.length === 0) {
|
|
||||||
logger.info("sweep-stale-runs: no stale pending runs");
|
|
||||||
return { runs: 0, targets: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = runRows.map((r) => r.id);
|
|
||||||
const targets = await db.execute(sql`
|
|
||||||
UPDATE reminder_run_targets
|
|
||||||
SET status = 'skipped',
|
|
||||||
error = 'bot restarted before this group could be sent'
|
|
||||||
WHERE status = 'pending'
|
|
||||||
AND run_id IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})
|
|
||||||
RETURNING id
|
|
||||||
`);
|
|
||||||
const targetCount = (targets.rows as Array<unknown>).length;
|
|
||||||
|
|
||||||
logger.warn(
|
|
||||||
{ runs: runRows.length, targets: targetCount, cutoff: cutoff.toISOString() },
|
|
||||||
"sweep-stale-runs: cleared stale pending runs",
|
|
||||||
);
|
|
||||||
return { runs: runRows.length, targets: targetCount };
|
|
||||||
}
|
|
||||||
@ -7,45 +7,35 @@ import { logger } from "../logger.js";
|
|||||||
export async function syncGroupsForAccount(
|
export async function syncGroupsForAccount(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
socket: WASocket,
|
socket: WASocket,
|
||||||
): Promise<{ synced: number; archived: number }> {
|
): Promise<{ synced: number; removed: number }> {
|
||||||
const meta = await socket.groupFetchAllParticipating();
|
const meta = await socket.groupFetchAllParticipating();
|
||||||
const entries = Object.values(meta);
|
const entries = Object.values(meta);
|
||||||
const liveJids = entries.map((g) => g.id);
|
const liveJids = entries.map((g) => g.id);
|
||||||
|
|
||||||
// Mark DB rows as archived when they're no longer in the live
|
// Remove DB rows for groups that are no longer in the live participant list
|
||||||
// participant list (group deleted, bot removed, etc). We don't
|
// (group was deleted, bot was removed, etc.). Only run the delete when we
|
||||||
// physically DELETE because reminder_targets.group_id is a NOT
|
// got at least one live group back — an empty result is more likely a
|
||||||
// NULL FK to this row — a hard delete throws "violates foreign
|
// transient WA fetch failure than a genuine "all groups gone" signal, and
|
||||||
// key constraint reminder_targets_group_id_whatsapp_groups_id_fk"
|
// we don't want to nuke valid data on a hiccup.
|
||||||
// and aborts the WHOLE group-sync transaction (which then strands
|
let removed: { id: string }[] = [];
|
||||||
// the post-pair open event and the operator sees it as a failed
|
|
||||||
// pairing). Soft-archive keeps reminders that targeted the group
|
|
||||||
// intact and gives the operator the option to clean them up
|
|
||||||
// explicitly later. Only run the sweep when we got at least one
|
|
||||||
// live group back — an empty result is usually a transient WA
|
|
||||||
// fetch failure and we don't want to mass-archive valid data.
|
|
||||||
let archived = 0;
|
|
||||||
if (liveJids.length > 0) {
|
if (liveJids.length > 0) {
|
||||||
const rows = await db
|
removed = await db
|
||||||
.update(whatsappGroups)
|
.delete(whatsappGroups)
|
||||||
.set({ isArchived: true, lastSyncedAt: new Date() })
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(whatsappGroups.accountId, accountId),
|
eq(whatsappGroups.accountId, accountId),
|
||||||
notInArray(whatsappGroups.waGroupJid, liveJids),
|
notInArray(whatsappGroups.waGroupJid, liveJids),
|
||||||
eq(whatsappGroups.isArchived, false),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning({ id: whatsappGroups.id });
|
.returning({ id: whatsappGroups.id });
|
||||||
archived = rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ accountId },
|
{ accountId },
|
||||||
"group-sync: empty fetch — skipping archive sweep (treating as transient)",
|
"group-sync: empty fetch — skipping delete sweep (treating as transient)",
|
||||||
);
|
);
|
||||||
return { synced: 0, archived: 0 };
|
return { synced: 0, removed: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = entries.map((g) => ({
|
const rows = entries.map((g) => ({
|
||||||
@ -66,16 +56,12 @@ export async function syncGroupsForAccount(
|
|||||||
name: sql`excluded.name`,
|
name: sql`excluded.name`,
|
||||||
participantCount: sql`excluded.participant_count`,
|
participantCount: sql`excluded.participant_count`,
|
||||||
lastSyncedAt: sql`excluded.last_synced_at`,
|
lastSyncedAt: sql`excluded.last_synced_at`,
|
||||||
// If a previously-archived group reappears in the live list
|
|
||||||
// (operator was re-added, group was un-deleted, etc.), flip
|
|
||||||
// the flag back so it shows up in the picker again.
|
|
||||||
isArchived: sql`excluded.is_archived`,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ accountId, count: rows.length, archived },
|
{ accountId, count: rows.length, removed: removed.length },
|
||||||
"group-sync: synced",
|
"group-sync: synced",
|
||||||
);
|
);
|
||||||
return { synced: rows.length, archived };
|
return { synced: rows.length, removed: removed.length };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,44 +120,6 @@ class SessionManager {
|
|||||||
this.sessions.delete(accountId);
|
this.sessions.delete(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Tell WhatsApp to remove this device from the linked-devices list,
|
|
||||||
* then close the socket. Used by the delete-account flow so the
|
|
||||||
* operator's phone doesn't keep showing a phantom "linked device"
|
|
||||||
* pointing at a row that no longer exists. Best-effort: if the
|
|
||||||
* socket is already torn down or the logout RPC fails (network
|
|
||||||
* blip, already-disconnected, etc.) we still proceed to close +
|
|
||||||
* teardown — no point stranding the delete because WhatsApp didn't
|
|
||||||
* acknowledge.
|
|
||||||
*/
|
|
||||||
async logoutAndStop(accountId: string): Promise<void> {
|
|
||||||
const timer = this.reconnectTimers.get(accountId);
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
this.reconnectTimers.delete(accountId);
|
|
||||||
}
|
|
||||||
const session = this.sessions.get(accountId);
|
|
||||||
if (!session) return;
|
|
||||||
// Suppress reconnect/handleEvent bookkeeping for the close that
|
|
||||||
// logout() emits — the row is about to be deleted entirely so
|
|
||||||
// status writes are pointless.
|
|
||||||
this.intentionalStops.add(accountId);
|
|
||||||
try {
|
|
||||||
await session.socket.logout();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
{ err, accountId },
|
|
||||||
"session-manager: socket.logout() failed (continuing with teardown)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await session.close();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn({ err, accountId }, "session-manager: post-logout close failed");
|
|
||||||
}
|
|
||||||
this.sessions.delete(accountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async stopAll(): Promise<void> {
|
async stopAll(): Promise<void> {
|
||||||
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
|
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
# Required
|
|
||||||
DATABASE_URL=postgres://user:pass@host:5432/dbname
|
|
||||||
|
|
||||||
# Auth — sign cookies. 64+ random chars. Generate via scripts/gen_auth_secret.sh.
|
|
||||||
AUTH_SECRET=replace-me
|
|
||||||
|
|
||||||
# Bump to invalidate all live sessions instantly. Leave at 1 normally.
|
|
||||||
OPERATOR_TOKEN_VERSION=1
|
|
||||||
|
|
||||||
# File-storage paths inside the bot container
|
|
||||||
DATA_DIR=/data
|
|
||||||
SESSIONS_DIR=/data/sessions
|
|
||||||
MEDIA_DIR=/data/media
|
|
||||||
|
|
||||||
# Bot fan-out tuning (see apps/bot/src/env.ts)
|
|
||||||
BOT_HEALTH_PORT=8081
|
|
||||||
BOT_LOG_LEVEL=info
|
|
||||||
BOT_FIRE_CONCURRENCY=8
|
|
||||||
BOT_GROUP_CONCURRENCY=3
|
|
||||||
BOT_MAX_SEND_PER_MINUTE=40
|
|
||||||
|
|
||||||
# Web
|
|
||||||
WEB_PORT=9000
|
|
||||||
|
|
||||||
# Seed (runs once via scripts/db.sh seed)
|
|
||||||
SEED_OPERATOR_USERNAME=admin
|
|
||||||
SEED_OPERATOR_NAME=Operator
|
|
||||||
@ -21,7 +21,6 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
serverActions: {
|
serverActions: {
|
||||||
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
|
|
||||||
// Default Server Action body limit is 1 MB — way under WhatsApp's
|
// Default Server Action body limit is 1 MB — way under WhatsApp's
|
||||||
// 100 MB document cap. Lifted to 100 MB so document uploads reach
|
// 100 MB document cap. Lifted to 100 MB so document uploads reach
|
||||||
// the action; the per-kind WhatsApp validator
|
// the action; the per-kind WhatsApp validator
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@serwist/next": "^9.5.11",
|
"@serwist/next": "^9.5.11",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.36.0",
|
"drizzle-orm": "^0.36.0",
|
||||||
@ -45,7 +44,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
|
||||||
"@types/node": "^22.7.0",
|
"@types/node": "^22.7.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
|||||||
@ -172,16 +172,8 @@ export async function unpairAccountAction(formData: FormData): Promise<void> {
|
|||||||
.update(whatsappAccounts)
|
.update(whatsappAccounts)
|
||||||
.set({ status: "unpaired", phoneNumber: null })
|
.set({ status: "unpaired", phoneNumber: null })
|
||||||
.where(eq(whatsappAccounts.id, accountId));
|
.where(eq(whatsappAccounts.id, accountId));
|
||||||
// Soft-archive synced groups instead of DELETEing. Hard delete
|
// Wipe synced groups too — they belong to a different WA login now.
|
||||||
// failed with "violates foreign key constraint
|
await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId));
|
||||||
// reminder_targets_group_id_whatsapp_groups_id_fk" whenever any
|
|
||||||
// group had ever been used in a reminder, which aborted the
|
|
||||||
// unpair. Archived groups vanish from the picker; a re-pair flips
|
|
||||||
// them back via the on-conflict upsert in syncGroupsForAccount.
|
|
||||||
await db
|
|
||||||
.update(whatsappGroups)
|
|
||||||
.set({ isArchived: true })
|
|
||||||
.where(eq(whatsappGroups.accountId, accountId));
|
|
||||||
revalidatePath("/accounts");
|
revalidatePath("/accounts");
|
||||||
revalidatePath(`/accounts/${accountId}`);
|
revalidatePath(`/accounts/${accountId}`);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -201,12 +193,8 @@ export async function deleteAccountAction(formData: FormData): Promise<void> {
|
|||||||
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
||||||
});
|
});
|
||||||
if (!account) return;
|
if (!account) return;
|
||||||
// Tell the bot to logout() over the live socket FIRST (so WhatsApp
|
// Stop any live session / clean session files first.
|
||||||
// drops this device from the operator's linked-devices list), then
|
await pgNotifyBot({ type: "account.unpair", accountId });
|
||||||
// close + remove session files. Distinct from account.unpair which
|
|
||||||
// never calls logout — keeping linked-devices clean is specific to
|
|
||||||
// the delete flow.
|
|
||||||
await pgNotifyBot({ type: "account.delete", accountId });
|
|
||||||
// Cascade FKs handle groups, reminders, runs, run_targets, messages.
|
// Cascade FKs handle groups, reminders, runs, run_targets, messages.
|
||||||
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
|
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
|
||||||
revalidatePath("/accounts");
|
revalidatePath("/accounts");
|
||||||
|
|||||||
@ -1,367 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
|
|
||||||
const {
|
|
||||||
cookiesSetMock,
|
|
||||||
cookiesDeleteMock,
|
|
||||||
findUserMock,
|
|
||||||
headersGetMock,
|
|
||||||
headerStore,
|
|
||||||
checkRateLimitMock,
|
|
||||||
redirectMock,
|
|
||||||
loggerMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
cookiesSetMock: vi.fn(),
|
|
||||||
cookiesDeleteMock: vi.fn(),
|
|
||||||
findUserMock: vi.fn(),
|
|
||||||
headersGetMock: vi.fn(() => "127.0.0.1"),
|
|
||||||
headerStore: new Map<string, string>(),
|
|
||||||
checkRateLimitMock: vi.fn(),
|
|
||||||
redirectMock: vi.fn((_path: string) => {
|
|
||||||
throw new Error("redirect");
|
|
||||||
}),
|
|
||||||
loggerMock: { warn: vi.fn(), info: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("next/headers", () => ({
|
|
||||||
cookies: async () => ({ set: cookiesSetMock, delete: cookiesDeleteMock }),
|
|
||||||
headers: async () => ({
|
|
||||||
get: (k: string) => {
|
|
||||||
const key = k.toLowerCase();
|
|
||||||
if (key === "x-forwarded-for") return headersGetMock();
|
|
||||||
// Tests opt-in to setting origin/host/etc. via headerStore;
|
|
||||||
// unset = null which lets hasSameOriginRequest treat the
|
|
||||||
// request as same-origin (Origin omitted = same-origin per RFC).
|
|
||||||
return headerStore.get(key) ?? null;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
redirect: (path: string) => redirectMock(path),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
operators: { findFirst: (...a: unknown[]) => findUserMock(...a) },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/rate-limit", () => ({
|
|
||||||
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/logger", () => ({ logger: loggerMock }));
|
|
||||||
|
|
||||||
const SECRET = "test-secret-not-real";
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.AUTH_SECRET = SECRET;
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
||||||
cookiesSetMock.mockReset();
|
|
||||||
cookiesDeleteMock.mockReset();
|
|
||||||
findUserMock.mockReset();
|
|
||||||
checkRateLimitMock.mockReset();
|
|
||||||
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
|
|
||||||
redirectMock.mockReset();
|
|
||||||
redirectMock.mockImplementation((_path: string) => {
|
|
||||||
throw new Error("redirect");
|
|
||||||
});
|
|
||||||
loggerMock.warn.mockReset();
|
|
||||||
headerStore.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
import { loginAction, logoutAction } from "./auth";
|
|
||||||
|
|
||||||
const REAL_HASH = bcrypt.hashSync("correct-horse", 10);
|
|
||||||
const ADMIN_ROW = {
|
|
||||||
id: "11111111-1111-1111-1111-111111111111",
|
|
||||||
username: "admin",
|
|
||||||
role: "admin" as const,
|
|
||||||
displayName: "Admin",
|
|
||||||
defaultTimezone: "UTC",
|
|
||||||
passwordHash: REAL_HASH,
|
|
||||||
};
|
|
||||||
|
|
||||||
function fd(fields: Record<string, string>): FormData {
|
|
||||||
const f = new FormData();
|
|
||||||
for (const [k, v] of Object.entries(fields)) f.append(k, v);
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("loginAction", () => {
|
|
||||||
it("issues a session cookie when credentials are correct", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
const prevEnv = process.env.NODE_ENV;
|
|
||||||
// @ts-expect-error - test override
|
|
||||||
process.env.NODE_ENV = "production";
|
|
||||||
try {
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(
|
|
||||||
(e) => e,
|
|
||||||
);
|
|
||||||
// Successful login redirects, so the redirect mock throws.
|
|
||||||
expect((r as Error).message).toBe("redirect");
|
|
||||||
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
|
|
||||||
const [name, , attrs] = cookiesSetMock.mock.calls[0]!;
|
|
||||||
expect(name).toBe("session");
|
|
||||||
expect(attrs).toMatchObject({
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
path: "/",
|
|
||||||
maxAge: 30 * 86400,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
// @ts-expect-error - test restore
|
|
||||||
process.env.NODE_ENV = prevEnv;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets secure=false on the cookie when NODE_ENV !== production", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
const prevEnv = process.env.NODE_ENV;
|
|
||||||
// @ts-expect-error - test override
|
|
||||||
process.env.NODE_ENV = "development";
|
|
||||||
try {
|
|
||||||
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
|
|
||||||
const [, , attrs] = cookiesSetMock.mock.calls[0]!;
|
|
||||||
expect(attrs).toMatchObject({ secure: false });
|
|
||||||
} finally {
|
|
||||||
// @ts-expect-error - test restore
|
|
||||||
process.env.NODE_ENV = prevEnv;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns ok:false on wrong password and does NOT set a cookie", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "wrong" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Invalid username or password." });
|
|
||||||
expect(cookiesSetMock).not.toHaveBeenCalled();
|
|
||||||
expect(loggerMock.warn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns ok:false on unknown username and STILL invokes bcrypt (timing equivalence)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(undefined);
|
|
||||||
const cmpSpy = vi.spyOn(bcrypt, "compare");
|
|
||||||
await loginAction(fd({ username: "nobody", password: "irrelevant" }));
|
|
||||||
expect(cmpSpy).toHaveBeenCalled(); // compared against DUMMY_HASH
|
|
||||||
cmpSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a clear error when the user has no password_hash set", async () => {
|
|
||||||
findUserMock.mockResolvedValue({ ...ADMIN_ROW, passwordHash: null });
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "anything" }));
|
|
||||||
expect(r).toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "Set a password via scripts/set-password.sh before signing in.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects empty username or password without hitting the DB", async () => {
|
|
||||||
const r = await loginAction(fd({ username: "", password: "x" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Username and password are required." });
|
|
||||||
expect(findUserMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects username/password >256 chars without invoking bcrypt", async () => {
|
|
||||||
const cmpSpy = vi.spyOn(bcrypt, "compare");
|
|
||||||
const long = "x".repeat(300);
|
|
||||||
const r = await loginAction(fd({ username: long, password: long }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Input too long." });
|
|
||||||
expect(cmpSpy).not.toHaveBeenCalled();
|
|
||||||
cmpSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches username case-insensitively", async () => {
|
|
||||||
findUserMock.mockImplementation(async () => ADMIN_ROW);
|
|
||||||
await loginAction(fd({ username: "ADMIN", password: "correct-horse" })).catch(() => {});
|
|
||||||
expect(findUserMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 429 when the rate limit is exhausted", async () => {
|
|
||||||
checkRateLimitMock.mockResolvedValue({ limited: true, count: 11 });
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
|
|
||||||
expect(findUserMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs the failed attempt with username and ip but never the password", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headersGetMock.mockReturnValue("203.0.113.5, 10.0.0.1");
|
|
||||||
await loginAction(fd({ username: "admin", password: "wrong" }));
|
|
||||||
const [meta, msg] = loggerMock.warn.mock.calls[0]!;
|
|
||||||
expect(meta).toMatchObject({ username: "admin", ip: "203.0.113.5" });
|
|
||||||
expect(JSON.stringify(meta)).not.toContain("wrong");
|
|
||||||
expect(msg).toMatch(/login failed/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redirects to safeRedirect(next) on success", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
await loginAction(fd({
|
|
||||||
username: "admin",
|
|
||||||
password: "correct-horse",
|
|
||||||
next: "/dashboard",
|
|
||||||
})).catch(() => {});
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith("/dashboard");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redirects to / when next is unsafe", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
await loginAction(fd({
|
|
||||||
username: "admin",
|
|
||||||
password: "correct-horse",
|
|
||||||
next: "//evil.com",
|
|
||||||
})).catch(() => {});
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith("/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("logoutAction", () => {
|
|
||||||
it("clears the session cookie and redirects to /login", async () => {
|
|
||||||
await logoutAction().catch(() => {});
|
|
||||||
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith("/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent — clears the cookie even when no session exists", async () => {
|
|
||||||
// Real-world: a user with an expired cookie clicks Sign out. cookies.delete
|
|
||||||
// doesn't care about pre-existing state and we still issue the redirect.
|
|
||||||
cookiesDeleteMock.mockReset();
|
|
||||||
await logoutAction().catch(() => {});
|
|
||||||
expect(cookiesDeleteMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("loginAction — additional cases", () => {
|
|
||||||
it("issues a cookie that decrypts to role='user' for a non-admin user", async () => {
|
|
||||||
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "user" });
|
|
||||||
await loginAction(fd({ username: "alice", password: "correct-horse" })).catch(() => {});
|
|
||||||
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
|
|
||||||
const [, cookieValue] = cookiesSetMock.mock.calls[0]!;
|
|
||||||
// The cookie is now AES-GCM encrypted, so we can't peel the payload
|
|
||||||
// off raw — decrypt with the same secret loginAction used. This
|
|
||||||
// also doubles as a confidentiality smoke test: 'user'/'alice'
|
|
||||||
// must NOT appear verbatim in the cookie bytes.
|
|
||||||
expect(cookieValue as string).not.toContain("alice");
|
|
||||||
expect(cookieValue as string).not.toContain("user");
|
|
||||||
const { verifySession } = await import("@/lib/auth-cookie");
|
|
||||||
const decoded = await verifySession(cookieValue as string, SECRET);
|
|
||||||
expect(decoded?.role).toBe("user");
|
|
||||||
expect(decoded?.userId).toBe(ADMIN_ROW.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when the user row has an unrecognised role string", async () => {
|
|
||||||
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "robot" });
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Account is not enabled." });
|
|
||||||
expect(cookiesSetMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns ok:false when AUTH_SECRET is unset (server misconfig)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
const prev = process.env.AUTH_SECRET;
|
|
||||||
delete process.env.AUTH_SECRET;
|
|
||||||
try {
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Server is not configured for sign-in." });
|
|
||||||
expect(cookiesSetMock).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
process.env.AUTH_SECRET = prev;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats whitespace-only username as missing input", async () => {
|
|
||||||
const r = await loginAction(fd({ username: " ", password: "x" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Username and password are required." });
|
|
||||||
expect(findUserMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses three rate-limit layers: per-IP, per-username, global", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headersGetMock.mockReturnValue("198.51.100.42");
|
|
||||||
await loginAction(fd({ username: "Admin", password: "correct-horse" })).catch(() => {});
|
|
||||||
// Three checkRateLimit calls fired in parallel via Promise.all,
|
|
||||||
// in this order: ip / user / global.
|
|
||||||
expect(checkRateLimitMock).toHaveBeenCalledTimes(3);
|
|
||||||
const keys = checkRateLimitMock.mock.calls.map((c) => c[0] as string);
|
|
||||||
expect(keys[0]).toBe("login:198.51.100.42");
|
|
||||||
// Username key is normalised to lowercase so "Admin" and "admin"
|
|
||||||
// share the same bucket — otherwise an attacker rotating case
|
|
||||||
// would dodge per-username throttling.
|
|
||||||
expect(keys[1]).toBe("login-user:admin");
|
|
||||||
expect(keys[2]).toBe("login-global");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects login when the per-username limit alone is hit (rotating-IPs attacker)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
// First call (ip) passes, second (user) is over, third (global) passes.
|
|
||||||
checkRateLimitMock
|
|
||||||
.mockResolvedValueOnce({ limited: false, count: 1 })
|
|
||||||
.mockResolvedValueOnce({ limited: true, count: 6 })
|
|
||||||
.mockResolvedValueOnce({ limited: false, count: 5 });
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
|
|
||||||
expect(findUserMock).not.toHaveBeenCalled();
|
|
||||||
// Logger captures which limit tripped so we can tune thresholds
|
|
||||||
// without leaking the answer to the attacker.
|
|
||||||
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
|
|
||||||
expect(meta).toMatchObject({ limit: "username" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects login when the global limit alone is hit (everyone-pile-on backstop)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
checkRateLimitMock
|
|
||||||
.mockResolvedValueOnce({ limited: false, count: 1 })
|
|
||||||
.mockResolvedValueOnce({ limited: false, count: 1 })
|
|
||||||
.mockResolvedValueOnce({ limited: true, count: 101 });
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
|
|
||||||
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
|
|
||||||
expect(meta).toMatchObject({ limit: "global" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cross-origin POST before checking credentials", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headerStore.set("origin", "https://attacker.example");
|
|
||||||
headerStore.set("host", "wabot.04080616.xyz");
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
|
|
||||||
expect(checkRateLimitMock).not.toHaveBeenCalled();
|
|
||||||
expect(findUserMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a same-origin POST (Origin host matches Host header)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headerStore.set("origin", "https://wabot.04080616.xyz");
|
|
||||||
headerStore.set("host", "wabot.04080616.xyz");
|
|
||||||
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
|
|
||||||
// Got past the origin check → DB lookup ran.
|
|
||||||
expect(findUserMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a missing Origin header as same-origin (legitimate native form POST)", async () => {
|
|
||||||
// Browsers don't always send Origin (e.g. plain top-level form
|
|
||||||
// submissions). Refusing those would brick login on some clients.
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headerStore.delete("origin");
|
|
||||||
headerStore.set("host", "wabot.04080616.xyz");
|
|
||||||
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
|
|
||||||
expect(findUserMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when Origin is malformed (non-URL string)", async () => {
|
|
||||||
findUserMock.mockResolvedValue(ADMIN_ROW);
|
|
||||||
headerStore.set("origin", "not a url");
|
|
||||||
headerStore.set("host", "wabot.04080616.xyz");
|
|
||||||
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
|
|
||||||
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still hits the DB and bcrypt on the unknown-user path so timing equivalence holds", async () => {
|
|
||||||
findUserMock.mockResolvedValue(undefined);
|
|
||||||
const cmpSpy = vi.spyOn(bcrypt, "compare");
|
|
||||||
await loginAction(fd({ username: "ghost", password: "anything" }));
|
|
||||||
// findFirst was called even though we know the user doesn't exist.
|
|
||||||
expect(findUserMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(cmpSpy).toHaveBeenCalled();
|
|
||||||
cmpSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { cookies, headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import {
|
|
||||||
COOKIE_NAME,
|
|
||||||
DEFAULT_TTL_SECONDS,
|
|
||||||
signSession,
|
|
||||||
type Role,
|
|
||||||
} from "@/lib/auth-cookie";
|
|
||||||
import { checkRateLimit } from "@/lib/rate-limit";
|
|
||||||
import { safeRedirect } from "@/lib/safe-redirect";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
|
|
||||||
export type LoginResult = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
const MAX_FIELD_LEN = 256;
|
|
||||||
|
|
||||||
// Precomputed bcryptjs hash of the throwaway string "x", cost 10.
|
|
||||||
// Compared against on the user-not-found path so timing matches the
|
|
||||||
// wrong-password path. Generating fresh per request would double the
|
|
||||||
// bcrypt work and create its own timing signal.
|
|
||||||
const DUMMY_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
|
|
||||||
|
|
||||||
async function clientIp(): Promise<string> {
|
|
||||||
const h = await headers();
|
|
||||||
const fwd = h.get("x-forwarded-for");
|
|
||||||
if (fwd) return fwd.split(",")[0]!.trim();
|
|
||||||
return h.get("x-real-ip") ?? "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare the inbound Origin to the request's Host. Server Actions
|
|
||||||
* already get an Origin check via Next 16's
|
|
||||||
* `serverActions.allowedOrigins`, but that's a global config — running
|
|
||||||
* the same comparison here is cheap belt-and-braces and lets us log
|
|
||||||
* mismatches with action-level context. Returns true when:
|
|
||||||
* - no Origin header is present (same-origin POSTs from the same
|
|
||||||
* server), OR
|
|
||||||
* - Origin's host matches the Host header (same-origin)
|
|
||||||
* Anything else (cross-origin POST, malformed Origin, etc.) → false.
|
|
||||||
*/
|
|
||||||
async function hasSameOriginRequest(): Promise<boolean> {
|
|
||||||
const h = await headers();
|
|
||||||
const origin = h.get("origin");
|
|
||||||
if (!origin) return true; // RFC: same-origin requests may omit Origin
|
|
||||||
const host = h.get("host");
|
|
||||||
if (!host) return false;
|
|
||||||
try {
|
|
||||||
const u = new URL(origin);
|
|
||||||
return u.host === host;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginAction(formData: FormData): Promise<LoginResult> {
|
|
||||||
const username = (formData.get("username") ?? "").toString();
|
|
||||||
const password = (formData.get("password") ?? "").toString();
|
|
||||||
const next = (formData.get("next") ?? "").toString();
|
|
||||||
|
|
||||||
if (!username.trim() || !password) {
|
|
||||||
return { ok: false, error: "Username and password are required." };
|
|
||||||
}
|
|
||||||
if (username.length > MAX_FIELD_LEN || password.length > MAX_FIELD_LEN) {
|
|
||||||
return { ok: false, error: "Input too long." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action-level Origin check. Next 16's serverActions.allowedOrigins
|
|
||||||
// already gates this at the framework boundary, but doing it here
|
|
||||||
// with action context lets us log the mismatch and surface a clean
|
|
||||||
// error instead of relying on the global config alone.
|
|
||||||
if (!(await hasSameOriginRequest())) {
|
|
||||||
logger.warn({}, "login rejected: cross-origin request");
|
|
||||||
return { ok: false, error: "Cross-origin request blocked." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = await clientIp();
|
|
||||||
// Three-layer rate limit:
|
|
||||||
// per-IP — typical brute-forcer
|
|
||||||
// per-username — attacker who rotates IPs (X-Forwarded-For
|
|
||||||
// spoofing, residential proxy pool) but pounds
|
|
||||||
// a single account
|
|
||||||
// global — backstop. If the attacker controls enough
|
|
||||||
// IP+username combos to slip past the first two,
|
|
||||||
// this caps the total login attempts per minute
|
|
||||||
// across the install. Lock occurs at the FIRST
|
|
||||||
// limit hit; we don't reveal which one.
|
|
||||||
const usernameKey = username.trim().toLowerCase();
|
|
||||||
const [rlIp, rlUser, rlGlobal] = await Promise.all([
|
|
||||||
checkRateLimit(`login:${ip}`, { max: 10, windowSec: 300 }),
|
|
||||||
checkRateLimit(`login-user:${usernameKey}`, { max: 5, windowSec: 900 }),
|
|
||||||
checkRateLimit(`login-global`, { max: 100, windowSec: 60 }),
|
|
||||||
]);
|
|
||||||
if (rlIp.limited || rlUser.limited || rlGlobal.limited) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
ip,
|
|
||||||
username: usernameKey,
|
|
||||||
limit: rlIp.limited ? "ip" : rlUser.limited ? "username" : "global",
|
|
||||||
},
|
|
||||||
"login rate-limited",
|
|
||||||
);
|
|
||||||
return { ok: false, error: "Too many attempts. Try again later." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = await db.query.operators.findFirst({
|
|
||||||
where: (o) => sql`lower(${o.username}) = lower(${username})`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// User exists but has no password configured: this is a server-side
|
|
||||||
// setup error, not a credential mismatch. Surface a distinct message
|
|
||||||
// so the operator knows to run scripts/set-password.sh. We still ran
|
|
||||||
// the DB lookup, so the username-enumeration concern is not relevant
|
|
||||||
// here (the attacker would already need a known username).
|
|
||||||
if (row && row.passwordHash === null) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: "Set a password via scripts/set-password.sh before signing in.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run bcrypt regardless to keep the user-not-found path timing-
|
|
||||||
// equivalent to the wrong-password path.
|
|
||||||
const hash = row?.passwordHash ?? DUMMY_HASH;
|
|
||||||
const ok = await bcrypt.compare(password, hash);
|
|
||||||
|
|
||||||
if (!row || !ok) {
|
|
||||||
logger.warn({ username, ip }, "login failed");
|
|
||||||
return { ok: false, error: "Invalid username or password." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.role !== "admin" && row.role !== "user") {
|
|
||||||
return { ok: false, error: "Account is not enabled." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const secret = process.env.AUTH_SECRET;
|
|
||||||
if (!secret) {
|
|
||||||
logger.warn({}, "AUTH_SECRET unset — cannot issue cookie");
|
|
||||||
return { ok: false, error: "Server is not configured for sign-in." };
|
|
||||||
}
|
|
||||||
const v = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const cookie = await signSession(
|
|
||||||
{
|
|
||||||
userId: row.id,
|
|
||||||
role: row.role as Role,
|
|
||||||
iat: now,
|
|
||||||
exp: now + DEFAULT_TTL_SECONDS,
|
|
||||||
v,
|
|
||||||
},
|
|
||||||
secret,
|
|
||||||
);
|
|
||||||
const jar = await cookies();
|
|
||||||
// Secure: only require https in production. In dev we hit
|
|
||||||
// http://localhost:9000 directly, and Firefox/Safari silently drop
|
|
||||||
// Set-Cookie when Secure is set on http origins (Chrome has a
|
|
||||||
// localhost exception, others don't), which manifested as the
|
|
||||||
// session cookie never being persisted across requests.
|
|
||||||
jar.set(COOKIE_NAME, cookie, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
|
||||||
path: "/",
|
|
||||||
maxAge: DEFAULT_TTL_SECONDS,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Typed-routes is on (next.config.ts experimental.typedRoutes); the
|
|
||||||
// `next` value is a runtime string from the form so we cast through any.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
redirect(safeRedirect(next) as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logoutAction(): Promise<void> {
|
|
||||||
const jar = await cookies();
|
|
||||||
jar.delete(COOKIE_NAME);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
redirect("/login" as any);
|
|
||||||
}
|
|
||||||
@ -33,12 +33,6 @@ export async function sendTestAction(_prev: unknown, formData: FormData): Promis
|
|||||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupId = parsed.data.groupId;
|
|
||||||
const groupRl = await checkRateLimit(`send-test:${groupId}`, { max: 3, windowSec: 60 });
|
|
||||||
if (groupRl.limited) {
|
|
||||||
return { ok: false, error: "Too many tests for this group. Try again later." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const group = await db.query.whatsappGroups.findFirst({
|
const group = await db.query.whatsappGroups.findFirst({
|
||||||
where: (g, { eq }) => eq(g.id, parsed.data.groupId),
|
where: (g, { eq }) => eq(g.id, parsed.data.groupId),
|
||||||
|
|||||||
@ -1,211 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit-tests the resume + cancel server actions in isolation. We mock
|
|
||||||
* the seeded operator, drizzle db, and the pgNotifyBot helper so the
|
|
||||||
* tests exercise the action's auth / status / lifecycle logic without
|
|
||||||
* a real Postgres connection.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
const findRunMock = vi.fn();
|
|
||||||
const findReminderMock = vi.fn();
|
|
||||||
const findAccountMock = vi.fn();
|
|
||||||
const updateMock = vi.fn();
|
|
||||||
const transactionMock = vi.fn();
|
|
||||||
const pgNotifyMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
reminderRuns: { findFirst: (...a: unknown[]) => findRunMock(...a) },
|
|
||||||
reminders: { findFirst: (...a: unknown[]) => findReminderMock(...a) },
|
|
||||||
whatsappAccounts: {
|
|
||||||
findFirst: (...a: unknown[]) => findAccountMock(...a),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: () => ({
|
|
||||||
set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }),
|
|
||||||
}),
|
|
||||||
// The cancel action does its DB mutations inside a transaction.
|
|
||||||
// Run the callback against the same shape as `db` so its inner
|
|
||||||
// `tx.update(...).set(...).where(...)` calls land in updateMock.
|
|
||||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
||||||
transactionMock();
|
|
||||||
const tx = {
|
|
||||||
update: () => ({
|
|
||||||
set: () => ({
|
|
||||||
where: async (...a: unknown[]) => updateMock(...a),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
return fn(tx);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/operator", () => ({
|
|
||||||
getSeededOperator: async () => ({ id: "op-1" }),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/notify", () => ({
|
|
||||||
pgNotifyBot: (...a: unknown[]) => pgNotifyMock(...a),
|
|
||||||
}));
|
|
||||||
// Rate limiter doesn't fire from these actions, but stub it anyway in
|
|
||||||
// case the implementation grows it later.
|
|
||||||
vi.mock("@/lib/rate-limit", () => ({
|
|
||||||
checkRateLimit: async () => ({ limited: false }),
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
||||||
vi.mock("next/headers", () => ({ headers: async () => new Map() }));
|
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
|
||||||
|
|
||||||
import {
|
|
||||||
resumeReminderRunAction,
|
|
||||||
cancelReminderRunAction,
|
|
||||||
} from "./reminders";
|
|
||||||
|
|
||||||
const PAUSED_RUN = { id: "11111111-1111-1111-1111-111111111111", reminderId: "r-1", status: "paused" };
|
|
||||||
const REMINDER = { id: "r-1", accountId: "acc-1", scheduleKind: "recurring" };
|
|
||||||
const REMINDER_ONE_OFF = { ...REMINDER, scheduleKind: "one_off" };
|
|
||||||
const ACCOUNT = { id: "acc-1", operatorId: "op-1" };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
findRunMock.mockReset();
|
|
||||||
findReminderMock.mockReset();
|
|
||||||
findAccountMock.mockReset();
|
|
||||||
updateMock.mockReset();
|
|
||||||
transactionMock.mockReset();
|
|
||||||
pgNotifyMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resumeReminderRunAction", () => {
|
|
||||||
it("rejects a non-uuid runId", async () => {
|
|
||||||
const r = await resumeReminderRunAction({ runId: "not-a-uuid" });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/Invalid/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 'Run not found' when the run row is missing", async () => {
|
|
||||||
findRunMock.mockResolvedValue(undefined);
|
|
||||||
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
|
|
||||||
expect(r).toEqual({ ok: false, error: "Run not found" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 'Reminder not found' when the run is orphaned", async () => {
|
|
||||||
findRunMock.mockResolvedValue(PAUSED_RUN);
|
|
||||||
findReminderMock.mockResolvedValue(undefined);
|
|
||||||
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
|
|
||||||
expect(r).toEqual({ ok: false, error: "Reminder not found" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 'Run not yours' when another operator owns the account", async () => {
|
|
||||||
findRunMock.mockResolvedValue(PAUSED_RUN);
|
|
||||||
findReminderMock.mockResolvedValue(REMINDER);
|
|
||||||
findAccountMock.mockResolvedValue(undefined);
|
|
||||||
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
|
|
||||||
expect(r).toEqual({ ok: false, error: "Run not yours" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when run.status !== 'paused'", async () => {
|
|
||||||
findRunMock.mockResolvedValue({ ...PAUSED_RUN, status: "success" });
|
|
||||||
findReminderMock.mockResolvedValue(REMINDER);
|
|
||||||
findAccountMock.mockResolvedValue(ACCOUNT);
|
|
||||||
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/Cannot resume a success run/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("happy path: notifies the bot with reminder.resume and runId", async () => {
|
|
||||||
findRunMock.mockResolvedValue(PAUSED_RUN);
|
|
||||||
findReminderMock.mockResolvedValue(REMINDER);
|
|
||||||
findAccountMock.mockResolvedValue(ACCOUNT);
|
|
||||||
const r = await resumeReminderRunAction({ runId: PAUSED_RUN.id });
|
|
||||||
expect(r).toEqual({ ok: true });
|
|
||||||
expect(pgNotifyMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(pgNotifyMock).toHaveBeenCalledWith({
|
|
||||||
type: "reminder.resume",
|
|
||||||
reminderId: REMINDER.id,
|
|
||||||
runId: PAUSED_RUN.id,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cancelReminderRunAction", () => {
|
|
||||||
it("rejects a non-uuid runId", async () => {
|
|
||||||
const r = await cancelReminderRunAction({ runId: "nope" });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/Invalid/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when the run isn't paused", async () => {
|
|
||||||
findRunMock.mockResolvedValue({ ...PAUSED_RUN, status: "success" });
|
|
||||||
findReminderMock.mockResolvedValue(REMINDER);
|
|
||||||
findAccountMock.mockResolvedValue(ACCOUNT);
|
|
||||||
const r = await cancelReminderRunAction({ runId: PAUSED_RUN.id });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/Cannot cancel/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("happy path: opens a transaction and runs three updates (targets / run / reminder)", async () => {
|
|
||||||
findRunMock.mockResolvedValue(PAUSED_RUN);
|
|
||||||
findReminderMock.mockResolvedValue(REMINDER);
|
|
||||||
findAccountMock.mockResolvedValue(ACCOUNT);
|
|
||||||
const r = await cancelReminderRunAction({ runId: PAUSED_RUN.id });
|
|
||||||
expect(r).toEqual({ ok: true });
|
|
||||||
expect(transactionMock).toHaveBeenCalledTimes(1);
|
|
||||||
// Three separate set/where calls inside the tx: update targets,
|
|
||||||
// update run, update reminder lifecycle.
|
|
||||||
expect(updateMock).toHaveBeenCalledTimes(3);
|
|
||||||
// Cancel does NOT enqueue the bot — it's purely a DB-side operation.
|
|
||||||
expect(pgNotifyMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recurring reminder: lifecycle goes back to active so the next occurrence fires", async () => {
|
|
||||||
// Use a tx-update spy that captures the SET payload.
|
|
||||||
const setSpy = vi.fn();
|
|
||||||
const { db } = await import("@/lib/db");
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(db as any).transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
||||||
const tx = {
|
|
||||||
update: () => ({
|
|
||||||
set: (payload: unknown) => {
|
|
||||||
setSpy(payload);
|
|
||||||
return { where: async () => undefined };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
return fn(tx);
|
|
||||||
};
|
|
||||||
|
|
||||||
findRunMock.mockResolvedValue(PAUSED_RUN);
|
|
||||||
findReminderMock.mockResolvedValue(REMINDER); // recurring
|
|
||||||
findAccountMock.mockResolvedValue(ACCOUNT);
|
|
||||||
await cancelReminderRunAction({ runId: PAUSED_RUN.id });
|
|
||||||
// Last set call is on the reminders table — status flips to active.
|
|
||||||
const calls = setSpy.mock.calls;
|
|
||||||
const lastPayload = calls[calls.length - 1]?.[0] as Record<string, unknown>;
|
|
||||||
expect(lastPayload.status).toBe("active");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("one-off reminder: lifecycle ends (no future occurrence to wait for)", async () => {
|
|
||||||
const setSpy = vi.fn();
|
|
||||||
const { db } = await import("@/lib/db");
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
(db as any).transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
||||||
const tx = {
|
|
||||||
update: () => ({
|
|
||||||
set: (payload: unknown) => {
|
|
||||||
setSpy(payload);
|
|
||||||
return { where: async () => undefined };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
return fn(tx);
|
|
||||||
};
|
|
||||||
|
|
||||||
findRunMock.mockResolvedValue(PAUSED_RUN);
|
|
||||||
findReminderMock.mockResolvedValue(REMINDER_ONE_OFF);
|
|
||||||
findAccountMock.mockResolvedValue(ACCOUNT);
|
|
||||||
await cancelReminderRunAction({ runId: PAUSED_RUN.id });
|
|
||||||
const calls = setSpy.mock.calls;
|
|
||||||
const lastPayload = calls[calls.length - 1]?.[0] as Record<string, unknown>;
|
|
||||||
expect(lastPayload.status).toBe("inactive");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -6,13 +6,7 @@ import { headers } from "next/headers";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import {
|
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
|
||||||
reminders,
|
|
||||||
reminderTargets,
|
|
||||||
reminderMessages,
|
|
||||||
reminderRuns,
|
|
||||||
reminderRunTargets,
|
|
||||||
} from "@cmbot/db";
|
|
||||||
import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared";
|
import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
@ -271,7 +265,7 @@ const createReminderSchema = z
|
|||||||
path: ["messages"],
|
path: ["messages"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 24), {
|
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 18), {
|
||||||
message: "Delivery window start must be earlier than end",
|
message: "Delivery window start must be earlier than end",
|
||||||
path: ["deliveryWindowStartHour"],
|
path: ["deliveryWindowStartHour"],
|
||||||
});
|
});
|
||||||
@ -328,11 +322,7 @@ export async function createReminderAction(
|
|||||||
timezone,
|
timezone,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
||||||
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the
|
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
|
||||||
// operator hasn't ticked the optional "Pause sending by" checkbox;
|
|
||||||
// fall back to 24 here so legacy payloads / direct API calls don't
|
|
||||||
// accidentally enable the deadline at 6pm.
|
|
||||||
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
|
|
||||||
const parts = resolveMessageParts(parsed.data);
|
const parts = resolveMessageParts(parsed.data);
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
@ -446,11 +436,7 @@ export async function updateReminderAction(
|
|||||||
timezone,
|
timezone,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
|
||||||
// 24 = "no deadline" (off). The wizard sends 24 explicitly when the
|
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18;
|
||||||
// operator hasn't ticked the optional "Pause sending by" checkbox;
|
|
||||||
// fall back to 24 here so legacy payloads / direct API calls don't
|
|
||||||
// accidentally enable the deadline at 6pm.
|
|
||||||
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
|
|
||||||
const parts = resolveMessageParts(parsed.data);
|
const parts = resolveMessageParts(parsed.data);
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
@ -552,141 +538,3 @@ export async function updateReminderAction(
|
|||||||
revalidatePath(`/reminders/${reminderId}`);
|
revalidatePath(`/reminders/${reminderId}`);
|
||||||
return { ok: true, reminderId };
|
return { ok: true, reminderId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Resume / cancel a paused run
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const runIdSchema = z.object({ runId: z.string().uuid() });
|
|
||||||
|
|
||||||
export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-enqueue a paused reminder run. The bot picks it up, attaches to the
|
|
||||||
* existing run row, and only re-tries the rows still in `pending` state.
|
|
||||||
*
|
|
||||||
* Validates that the operator owns the underlying reminder + account
|
|
||||||
* pair and that the run is actually in 'paused' state — anything else
|
|
||||||
* is a no-op (so a stale UI button doesn't double-fire a run).
|
|
||||||
*/
|
|
||||||
export async function resumeReminderRunAction(input: {
|
|
||||||
runId: string;
|
|
||||||
}): Promise<ResumeReminderRunResult> {
|
|
||||||
const ip =
|
|
||||||
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
|
|
||||||
if (rl.limited) {
|
|
||||||
return { ok: false, error: "Too many requests. Try again later." };
|
|
||||||
}
|
|
||||||
const op = await getSeededOperator();
|
|
||||||
const parsed = runIdSchema.safeParse(input);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false, error: "Invalid runId" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = await db.query.reminderRuns.findFirst({
|
|
||||||
where: (r, { eq: dEq }) => dEq(r.id, parsed.data.runId),
|
|
||||||
});
|
|
||||||
if (!run || !run.reminderId) return { ok: false, error: "Run not found" };
|
|
||||||
|
|
||||||
const reminder = await db.query.reminders.findFirst({
|
|
||||||
where: (r, { eq: dEq }) => dEq(r.id, run.reminderId!),
|
|
||||||
});
|
|
||||||
if (!reminder) return { ok: false, error: "Reminder not found" };
|
|
||||||
|
|
||||||
// Operator must own the account the reminder belongs to.
|
|
||||||
const owned = await db.query.whatsappAccounts.findFirst({
|
|
||||||
where: (a, { eq: dEq, and: dAnd }) =>
|
|
||||||
dAnd(dEq(a.id, reminder.accountId), dEq(a.operatorId, op.id)),
|
|
||||||
});
|
|
||||||
if (!owned) return { ok: false, error: "Run not yours" };
|
|
||||||
|
|
||||||
if (run.status !== "paused") {
|
|
||||||
return { ok: false, error: `Cannot resume a ${run.status} run` };
|
|
||||||
}
|
|
||||||
|
|
||||||
await pgNotifyBot({
|
|
||||||
type: "reminder.resume",
|
|
||||||
reminderId: reminder.id,
|
|
||||||
runId: run.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/activity");
|
|
||||||
revalidatePath(`/reminders/${reminder.id}`);
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CancelReminderRunResult = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Permanently end a paused run. Remaining `pending` targets become
|
|
||||||
* `skipped` with a clear "canceled by operator" reason; the run row
|
|
||||||
* resolves to `partial`. The reminder lifecycle is lifted out of
|
|
||||||
* 'paused' — recurring goes back to 'active' so the next occurrence
|
|
||||||
* fires; one-off ends.
|
|
||||||
*/
|
|
||||||
export async function cancelReminderRunAction(input: {
|
|
||||||
runId: string;
|
|
||||||
}): Promise<CancelReminderRunResult> {
|
|
||||||
const ip =
|
|
||||||
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
|
|
||||||
if (rl.limited) {
|
|
||||||
return { ok: false, error: "Too many requests. Try again later." };
|
|
||||||
}
|
|
||||||
const op = await getSeededOperator();
|
|
||||||
const parsed = runIdSchema.safeParse(input);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { ok: false, error: "Invalid runId" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = await db.query.reminderRuns.findFirst({
|
|
||||||
where: (r, { eq: dEq }) => dEq(r.id, parsed.data.runId),
|
|
||||||
});
|
|
||||||
if (!run || !run.reminderId) return { ok: false, error: "Run not found" };
|
|
||||||
|
|
||||||
const reminder = await db.query.reminders.findFirst({
|
|
||||||
where: (r, { eq: dEq }) => dEq(r.id, run.reminderId!),
|
|
||||||
});
|
|
||||||
if (!reminder) return { ok: false, error: "Reminder not found" };
|
|
||||||
|
|
||||||
const owned = await db.query.whatsappAccounts.findFirst({
|
|
||||||
where: (a, { eq: dEq, and: dAnd }) =>
|
|
||||||
dAnd(dEq(a.id, reminder.accountId), dEq(a.operatorId, op.id)),
|
|
||||||
});
|
|
||||||
if (!owned) return { ok: false, error: "Run not yours" };
|
|
||||||
|
|
||||||
if (run.status !== "paused") {
|
|
||||||
return { ok: false, error: `Cannot cancel a ${run.status} run` };
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
// Pending → skipped with a clear cause.
|
|
||||||
await tx
|
|
||||||
.update(reminderRunTargets)
|
|
||||||
.set({ status: "skipped", error: "canceled by operator" })
|
|
||||||
.where(eq(reminderRunTargets.runId, run.id));
|
|
||||||
|
|
||||||
await tx
|
|
||||||
.update(reminderRuns)
|
|
||||||
.set({
|
|
||||||
status: "partial",
|
|
||||||
errorSummary:
|
|
||||||
"Canceled by operator before all groups received the message.",
|
|
||||||
})
|
|
||||||
.where(eq(reminderRuns.id, run.id));
|
|
||||||
|
|
||||||
// Lift the reminder out of 'paused'. Recurring goes back to active
|
|
||||||
// so the next occurrence can fire; one-off has no future occurrence.
|
|
||||||
await tx
|
|
||||||
.update(reminders)
|
|
||||||
.set({
|
|
||||||
status: reminder.scheduleKind === "recurring" ? "active" : "inactive",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(reminders.id, reminder.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/activity");
|
|
||||||
revalidatePath(`/reminders/${reminder.id}`);
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,192 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
const {
|
|
||||||
requireAdminMock,
|
|
||||||
findUserMock,
|
|
||||||
findManyAdminsMock,
|
|
||||||
insertReturningMock,
|
|
||||||
updateMock,
|
|
||||||
deleteMock,
|
|
||||||
checkRateLimitMock,
|
|
||||||
revalidateMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
requireAdminMock: vi.fn(),
|
|
||||||
findUserMock: vi.fn(),
|
|
||||||
findManyAdminsMock: vi.fn(),
|
|
||||||
insertReturningMock: vi.fn(),
|
|
||||||
updateMock: vi.fn(),
|
|
||||||
deleteMock: vi.fn(),
|
|
||||||
checkRateLimitMock: vi.fn(),
|
|
||||||
revalidateMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/auth", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("@/lib/auth")>("@/lib/auth");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
requireAdmin: () => requireAdminMock(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
operators: {
|
|
||||||
findFirst: (...a: unknown[]) => findUserMock(...a),
|
|
||||||
findMany: (...a: unknown[]) => findManyAdminsMock(...a),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
insert: () => ({ values: () => ({ returning: async () => insertReturningMock() }) }),
|
|
||||||
update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }) }),
|
|
||||||
delete: () => ({ where: async (...a: unknown[]) => deleteMock(...a) }),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/rate-limit", () => ({
|
|
||||||
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: revalidateMock }));
|
|
||||||
vi.mock("next/headers", () => ({
|
|
||||||
headers: async () => ({ get: () => "127.0.0.1" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
requireAdminMock.mockReset();
|
|
||||||
findUserMock.mockReset();
|
|
||||||
findManyAdminsMock.mockReset();
|
|
||||||
insertReturningMock.mockReset();
|
|
||||||
updateMock.mockReset();
|
|
||||||
deleteMock.mockReset();
|
|
||||||
checkRateLimitMock.mockReset();
|
|
||||||
revalidateMock.mockReset();
|
|
||||||
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const ADMIN = {
|
|
||||||
id: "11111111-1111-1111-1111-111111111111",
|
|
||||||
username: "admin",
|
|
||||||
role: "admin" as const,
|
|
||||||
};
|
|
||||||
const OTHER_ADMIN = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice" };
|
|
||||||
const USER = { ...ADMIN, id: "33333333-3333-3333-3333-333333333333", username: "bob", role: "user" as const };
|
|
||||||
|
|
||||||
import {
|
|
||||||
createUserAction,
|
|
||||||
setUserRoleAction,
|
|
||||||
resetUserPasswordAction,
|
|
||||||
deleteUserAction,
|
|
||||||
} from "./users";
|
|
||||||
|
|
||||||
describe("createUserAction", () => {
|
|
||||||
it("admin can create a user with role 'user'", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
insertReturningMock.mockResolvedValue([{ id: USER.id }]);
|
|
||||||
const r = await createUserAction({
|
|
||||||
username: "bob",
|
|
||||||
password: "longpw1",
|
|
||||||
role: "user",
|
|
||||||
});
|
|
||||||
expect(r).toEqual({ ok: true, userId: USER.id });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects username/password under length limits", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
const r = await createUserAction({ username: "a", password: "shortpw", role: "user" });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setUserRoleAction — self-demote guard", () => {
|
|
||||||
it("admin demoting themselves is rejected", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(ADMIN);
|
|
||||||
const r = await setUserRoleAction({ userId: ADMIN.id, role: "user" });
|
|
||||||
expect(r).toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "You can't demote your own account.",
|
|
||||||
});
|
|
||||||
expect(updateMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin demoting another admin is allowed when others remain", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(OTHER_ADMIN);
|
|
||||||
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); // 2 admins
|
|
||||||
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
|
|
||||||
expect(r).toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin demoting the last remaining admin is rejected", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(OTHER_ADMIN);
|
|
||||||
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // only OTHER_ADMIN is an admin
|
|
||||||
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/last admin/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deleteUserAction", () => {
|
|
||||||
it("admin deleting themselves is rejected", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(ADMIN);
|
|
||||||
const r = await deleteUserAction({ userId: ADMIN.id });
|
|
||||||
expect(r).toEqual({ ok: false, error: "You can't delete your own account." });
|
|
||||||
expect(deleteMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin deleting another user is allowed", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]);
|
|
||||||
const r = await deleteUserAction({ userId: USER.id });
|
|
||||||
expect(r).toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin deleting the last admin is rejected", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(OTHER_ADMIN);
|
|
||||||
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // 1 admin total
|
|
||||||
const r = await deleteUserAction({ userId: OTHER_ADMIN.id });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/last admin/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resetUserPasswordAction", () => {
|
|
||||||
it("admin can reset another user's password", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "alpha7!" });
|
|
||||||
expect(r).toEqual({ ok: true });
|
|
||||||
expect(updateMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects too-short passwords", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "ab1" });
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects letters-only passwords (no number or symbol)", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
const r = await resetUserPasswordAction({
|
|
||||||
userId: USER.id,
|
|
||||||
newPassword: "abcdefghij",
|
|
||||||
});
|
|
||||||
expect(r).toEqual({
|
|
||||||
ok: false,
|
|
||||||
error: "Password must mix letters with numbers or symbols.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects digits-only passwords", async () => {
|
|
||||||
requireAdminMock.mockResolvedValue(ADMIN);
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
const r = await resetUserPasswordAction({
|
|
||||||
userId: USER.id,
|
|
||||||
newPassword: "1234567890",
|
|
||||||
});
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { operators } from "@cmbot/db";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { requireAdmin } from "@/lib/auth";
|
|
||||||
import { checkRateLimit } from "@/lib/rate-limit";
|
|
||||||
import { validatePassword } from "@/lib/password-policy";
|
|
||||||
|
|
||||||
const MAX_FIELD_LEN = 256;
|
|
||||||
|
|
||||||
async function rateLimit(key: string): Promise<{ limited: boolean }> {
|
|
||||||
const h = await headers();
|
|
||||||
const ip =
|
|
||||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
return checkRateLimit(`${key}:${ip}`, { max: 5, windowSec: 60 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateUserResult =
|
|
||||||
| { ok: true; userId: string }
|
|
||||||
| { ok: false; error: string };
|
|
||||||
|
|
||||||
export async function createUserAction(input: {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
role: "admin" | "user";
|
|
||||||
}): Promise<CreateUserResult> {
|
|
||||||
await requireAdmin();
|
|
||||||
const rl = await rateLimit("create-user");
|
|
||||||
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
|
|
||||||
const u = input.username.trim();
|
|
||||||
if (u.length < 3 || u.length > MAX_FIELD_LEN) {
|
|
||||||
return { ok: false, error: "Username must be 3..256 chars." };
|
|
||||||
}
|
|
||||||
const pwCheck = validatePassword(input.password);
|
|
||||||
if (!pwCheck.ok) return pwCheck;
|
|
||||||
if (input.role !== "admin" && input.role !== "user") {
|
|
||||||
return { ok: false, error: "Role must be admin or user." };
|
|
||||||
}
|
|
||||||
const hash = await bcrypt.hash(input.password, 12);
|
|
||||||
const [row] = await db
|
|
||||||
.insert(operators)
|
|
||||||
.values({
|
|
||||||
username: u,
|
|
||||||
passwordHash: hash,
|
|
||||||
displayName: u,
|
|
||||||
role: input.role,
|
|
||||||
defaultTimezone: "Asia/Kuala_Lumpur",
|
|
||||||
})
|
|
||||||
.returning({ id: operators.id });
|
|
||||||
revalidatePath("/settings/users");
|
|
||||||
return { ok: true, userId: row!.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SetRoleResult = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
export async function setUserRoleAction(input: {
|
|
||||||
userId: string;
|
|
||||||
role: "admin" | "user";
|
|
||||||
}): Promise<SetRoleResult> {
|
|
||||||
const me = await requireAdmin();
|
|
||||||
if (input.userId === me.id && input.role !== "admin") {
|
|
||||||
return { ok: false, error: "You can't demote your own account." };
|
|
||||||
}
|
|
||||||
const target = await db.query.operators.findFirst({
|
|
||||||
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
|
|
||||||
});
|
|
||||||
if (!target) return { ok: false, error: "User not found." };
|
|
||||||
|
|
||||||
// If we're demoting an admin, make sure at least one admin remains.
|
|
||||||
if (target.role === "admin" && input.role !== "admin") {
|
|
||||||
const admins = await db.query.operators.findMany({
|
|
||||||
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
|
|
||||||
});
|
|
||||||
if (admins.length <= 1) {
|
|
||||||
return { ok: false, error: "Can't demote the last admin. Promote another user first." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(operators)
|
|
||||||
.set({ role: input.role })
|
|
||||||
.where(eq(operators.id, input.userId));
|
|
||||||
revalidatePath("/settings/users");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DeleteUserResult = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
export async function deleteUserAction(input: {
|
|
||||||
userId: string;
|
|
||||||
}): Promise<DeleteUserResult> {
|
|
||||||
const me = await requireAdmin();
|
|
||||||
if (input.userId === me.id) {
|
|
||||||
return { ok: false, error: "You can't delete your own account." };
|
|
||||||
}
|
|
||||||
const target = await db.query.operators.findFirst({
|
|
||||||
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
|
|
||||||
});
|
|
||||||
if (!target) return { ok: false, error: "User not found." };
|
|
||||||
if (target.role === "admin") {
|
|
||||||
const admins = await db.query.operators.findMany({
|
|
||||||
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
|
|
||||||
});
|
|
||||||
if (admins.length <= 1) {
|
|
||||||
return { ok: false, error: "Can't delete the last admin. Promote another user first." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await db.delete(operators).where(eq(operators.id, input.userId));
|
|
||||||
revalidatePath("/settings/users");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ResetPasswordResult = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
export async function resetUserPasswordAction(input: {
|
|
||||||
userId: string;
|
|
||||||
newPassword: string;
|
|
||||||
}): Promise<ResetPasswordResult> {
|
|
||||||
await requireAdmin();
|
|
||||||
const rl = await rateLimit("reset-password");
|
|
||||||
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
|
|
||||||
const pwCheck = validatePassword(input.newPassword);
|
|
||||||
if (!pwCheck.ok) return pwCheck;
|
|
||||||
const target = await db.query.operators.findFirst({
|
|
||||||
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
|
|
||||||
});
|
|
||||||
if (!target) return { ok: false, error: "User not found." };
|
|
||||||
const hash = await bcrypt.hash(input.newPassword, 12);
|
|
||||||
await db
|
|
||||||
.update(operators)
|
|
||||||
.set({ passwordHash: hash })
|
|
||||||
.where(eq(operators.id, input.userId));
|
|
||||||
revalidatePath("/settings/users");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { ChevronRightIcon, Loader2Icon, Trash2Icon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { deleteAccountAction } from "@/actions/accounts";
|
|
||||||
|
|
||||||
interface DeleteAccountCardProps {
|
|
||||||
accountId: string;
|
|
||||||
accountLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteAccountCard({
|
|
||||||
accountId,
|
|
||||||
accountLabel,
|
|
||||||
}: DeleteAccountCardProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
|
|
||||||
function confirm() {
|
|
||||||
start(async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("accountId", accountId);
|
|
||||||
await deleteAccountAction(fd);
|
|
||||||
setOpen(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<Card
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Delete account"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
|
||||||
<Trash2Icon className="size-4 text-destructive" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-destructive">
|
|
||||||
Delete Account
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Remove the account and all its reminders, groups, and history
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete this account permanently?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<strong>{accountLabel}</strong> will be removed along with its
|
|
||||||
synced groups, scheduled reminders, and all run history. This
|
|
||||||
cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="ghost" size="sm">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={confirm}
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2Icon className="size-4" />
|
|
||||||
)}
|
|
||||||
Yes, delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,6 +4,7 @@ import {
|
|||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
Users2Icon,
|
Users2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -15,7 +16,6 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { listGroupsForAccount } from "@/lib/queries";
|
import { listGroupsForAccount } from "@/lib/queries";
|
||||||
import { RefreshGroupsClient } from "./refresh-groups-client";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@ -57,7 +57,13 @@ export default async function GroupsListPage({ params, searchParams }: Props) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RefreshGroupsClient accountId={account.id} />
|
{/* Refresh button — no-op placeholder, wired in Task 17 */}
|
||||||
|
<form action={async () => { "use server"; /* wired in Task 17 */ }}>
|
||||||
|
<Button type="submit" variant="outline" size="sm" className="shrink-0">
|
||||||
|
<RefreshCwIcon />
|
||||||
|
Refresh Groups
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Loader2Icon, RefreshCwIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useEvents } from "@/hooks/use-events";
|
|
||||||
import { syncGroupsAction } from "@/actions/accounts";
|
|
||||||
|
|
||||||
interface RefreshGroupsClientProps {
|
|
||||||
accountId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Two-stage refresh button:
|
|
||||||
* 1. Click → server action pgNotifies the bot to start a sync.
|
|
||||||
* 2. Bot finishes → emits `groups.synced` over SSE → router.refresh()
|
|
||||||
* re-fetches the page so the new rows appear without the operator
|
|
||||||
* having to reload manually.
|
|
||||||
*
|
|
||||||
* The button stays in its "syncing" state until either the
|
|
||||||
* `groups.synced` event arrives for this account or 15 s pass (so a
|
|
||||||
* disconnected bot doesn't strand the spinner forever).
|
|
||||||
*/
|
|
||||||
export function RefreshGroupsClient({ accountId }: RefreshGroupsClientProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
const [waiting, setWaiting] = useState(false);
|
|
||||||
|
|
||||||
useEvents({
|
|
||||||
"groups.synced": (data) => {
|
|
||||||
if (data.accountId !== accountId) return;
|
|
||||||
setWaiting(false);
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function trigger() {
|
|
||||||
start(async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("accountId", accountId);
|
|
||||||
await syncGroupsAction(fd);
|
|
||||||
setWaiting(true);
|
|
||||||
// Belt-and-braces: if the bot is unreachable or the SSE channel
|
|
||||||
// drops, drop the spinner after 15 s instead of leaving it stuck.
|
|
||||||
window.setTimeout(() => setWaiting(false), 15_000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const busy = pending || waiting;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={trigger}
|
|
||||||
>
|
|
||||||
{busy ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCwIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
{busy ? "Syncing…" : "Refresh Groups"}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
Trash2Icon,
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
SmartphoneIcon,
|
SmartphoneIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
@ -9,6 +10,7 @@ import {
|
|||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PowerIcon,
|
PowerIcon,
|
||||||
|
PowerOffIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -18,12 +20,23 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { getAccount } from "@/lib/queries";
|
import { getAccount } from "@/lib/queries";
|
||||||
import { pairAccountAction } from "@/actions/accounts";
|
import {
|
||||||
import { DeleteAccountCard } from "./delete-account-card";
|
unpairAccountAction,
|
||||||
import { UnpairAccountCard } from "./unpair-account-card";
|
pairAccountAction,
|
||||||
|
deleteAccountAction,
|
||||||
|
} from "@/actions/accounts";
|
||||||
|
|
||||||
interface AccountDetailPageProps {
|
interface AccountDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@ -143,11 +156,102 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
|||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<UnpairAccountCard accountId={account.id} accountLabel={account.label} />
|
{/* Unpair — transparent <button> overlay opens the dialog
|
||||||
|
so we don't pass button-specific props onto the Card div
|
||||||
|
(Radix asChild does that and it produces a hydration
|
||||||
|
mismatch on a div). */}
|
||||||
|
<Dialog>
|
||||||
|
<Card className="relative transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
|
||||||
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
|
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Unpair</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Disconnect from WhatsApp; keep the account so you can re-pair later
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||||
|
</CardContent>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Unpair WhatsApp"
|
||||||
|
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</DialogTrigger>
|
||||||
|
</Card>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Unpair this account?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<strong>{account.label}</strong> will disconnect from WhatsApp and
|
||||||
|
scheduled reminders using it will stop firing until you re-pair.
|
||||||
|
The account itself is kept; reminders and other data are not deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<form action={unpairAccountAction}>
|
||||||
|
<input type="hidden" name="accountId" value={account.id} />
|
||||||
|
<Button type="submit" variant="default" size="sm">
|
||||||
|
<PowerOffIcon />
|
||||||
|
Yes, unpair
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DeleteAccountCard accountId={account.id} accountLabel={account.label} />
|
{/* Delete — transparent <button> overlay opens the dialog. */}
|
||||||
|
<Dialog>
|
||||||
|
<Card className="relative transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
|
||||||
|
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
|
<Trash2Icon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-destructive">Delete Account</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Remove the account and all its reminders, groups, and history
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||||
|
</CardContent>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Delete account"
|
||||||
|
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</DialogTrigger>
|
||||||
|
</Card>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this account permanently?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<strong>{account.label}</strong> will be removed along with its
|
||||||
|
synced groups, scheduled reminders, and all run history. This cannot be
|
||||||
|
undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<form action={deleteAccountAction}>
|
||||||
|
<input type="hidden" name="accountId" value={account.id} />
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2Icon />
|
||||||
|
Yes, delete
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { ChevronRightIcon, Loader2Icon, PowerOffIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { unpairAccountAction } from "@/actions/accounts";
|
|
||||||
|
|
||||||
interface UnpairAccountCardProps {
|
|
||||||
accountId: string;
|
|
||||||
accountLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UnpairAccountCard({
|
|
||||||
accountId,
|
|
||||||
accountLabel,
|
|
||||||
}: UnpairAccountCardProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
|
|
||||||
function confirm() {
|
|
||||||
start(async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("accountId", accountId);
|
|
||||||
await unpairAccountAction(fd);
|
|
||||||
setOpen(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<Card
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Unpair WhatsApp"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
|
||||||
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Unpair</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Disconnect from WhatsApp; keep the account so you can re-pair later
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Unpair this account?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<strong>{accountLabel}</strong> will disconnect from WhatsApp and
|
|
||||||
scheduled reminders using it will stop firing until you re-pair.
|
|
||||||
The account itself is kept; reminders and other data are not
|
|
||||||
deleted.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="ghost" size="sm">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={confirm}
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<PowerOffIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
Yes, unpair
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -6,14 +6,21 @@ import {
|
|||||||
ArchiveRestoreIcon,
|
ArchiveRestoreIcon,
|
||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
MinusCircleIcon,
|
MinusCircleIcon,
|
||||||
PauseCircleIcon,
|
|
||||||
PlayIcon,
|
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -29,11 +36,11 @@ import { getSeededOperator } from "@/lib/operator";
|
|||||||
import { listActivityRuns } from "@/lib/queries";
|
import { listActivityRuns } from "@/lib/queries";
|
||||||
import {
|
import {
|
||||||
archiveRunAction,
|
archiveRunAction,
|
||||||
|
clearHistoryAction,
|
||||||
deleteRunAction,
|
deleteRunAction,
|
||||||
unarchiveRunAction,
|
unarchiveRunAction,
|
||||||
} from "@/actions/history";
|
} from "@/actions/history";
|
||||||
import { SwipeableRow } from "@/components/swipeable-row";
|
import { SwipeableRow } from "@/components/swipeable-row";
|
||||||
import { ResumeRunButton } from "@/components/activity/resume-run-button";
|
|
||||||
|
|
||||||
function relativeTime(date: Date | string): string {
|
function relativeTime(date: Date | string): string {
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
@ -55,12 +62,6 @@ const RUN_STATUS_CONFIG: Record<
|
|||||||
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||||
icon: CheckCircle2Icon,
|
icon: CheckCircle2Icon,
|
||||||
},
|
},
|
||||||
paused: {
|
|
||||||
label: "Paused",
|
|
||||||
className:
|
|
||||||
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
|
||||||
icon: PauseCircleIcon,
|
|
||||||
},
|
|
||||||
partial: {
|
partial: {
|
||||||
label: "Partial",
|
label: "Partial",
|
||||||
className:
|
className:
|
||||||
@ -96,24 +97,16 @@ function RunStatusBadge({ status }: { status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterValue = "success" | "paused" | "failed" | "archived";
|
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped" | "archived";
|
||||||
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
{ value: "success", label: "Success" },
|
{ value: "success", label: "Success" },
|
||||||
{ value: "paused", label: "Paused" },
|
{ value: "partial", label: "Partial" },
|
||||||
{ value: "failed", label: "Failed" },
|
{ value: "failed", label: "Failed" },
|
||||||
|
{ value: "skipped", label: "Skipped" },
|
||||||
{ value: "archived", label: "Archived" },
|
{ value: "archived", label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Partial runs (some recipients ok, some failed) surface under BOTH the
|
|
||||||
// Paused and Failed tabs — the operator wants to see anything that didn't
|
|
||||||
// fully succeed on either page. Skipped runs collapse into Archived since
|
|
||||||
// they're effectively "history that the operator chose not to send".
|
|
||||||
const FILTER_STATUSES: Record<Exclude<FilterValue, "archived">, string[]> = {
|
|
||||||
success: ["success"],
|
|
||||||
paused: ["paused", "partial"],
|
|
||||||
failed: ["failed", "partial"],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ filter?: string }>;
|
searchParams: Promise<{ filter?: string }>;
|
||||||
}
|
}
|
||||||
@ -174,42 +167,76 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
|||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const filter: FilterValue =
|
const filter: FilterValue =
|
||||||
sp.filter === "success" ||
|
sp.filter === "success" ||
|
||||||
sp.filter === "paused" ||
|
sp.filter === "partial" ||
|
||||||
sp.filter === "failed" ||
|
sp.filter === "failed" ||
|
||||||
|
sp.filter === "skipped" ||
|
||||||
sp.filter === "archived"
|
sp.filter === "archived"
|
||||||
? sp.filter
|
? sp.filter
|
||||||
: "success";
|
: "all";
|
||||||
const showingArchived = filter === "archived";
|
const showingArchived = filter === "archived";
|
||||||
|
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const runs = await listActivityRuns(op.id, { archived: showingArchived });
|
const runs = await listActivityRuns(op.id, { archived: showingArchived });
|
||||||
const filtered =
|
const filtered =
|
||||||
filter === "archived"
|
filter === "all" || filter === "archived"
|
||||||
? runs
|
? runs
|
||||||
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
|
: runs.filter((r) => r.status === filter);
|
||||||
const hasAny = runs.length > 0;
|
const hasAny = runs.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Activity">
|
<PageShell
|
||||||
{/* Filter tabs span the full row and wrap onto a second line when the
|
title="Activity"
|
||||||
viewport can't fit them all. Each trigger has a small basis so they
|
action={
|
||||||
share space evenly while still keeping a readable label on mobile. */}
|
hasAny && !showingArchived ? (
|
||||||
<Tabs value={filter}>
|
<Dialog>
|
||||||
<TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1">
|
<DialogTrigger asChild>
|
||||||
{FILTER_TABS.map(({ value, label }) => (
|
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
|
||||||
<TabsTrigger
|
<Trash2Icon />
|
||||||
key={value}
|
Clear history
|
||||||
value={value}
|
</Button>
|
||||||
asChild
|
</DialogTrigger>
|
||||||
className="h-8 grow basis-20"
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Clear all run history?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This permanently removes every reminder run record, including
|
||||||
|
runs from reminders that have already been deleted. Reminders
|
||||||
|
themselves are not affected.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<form action={clearHistoryAction}>
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2Icon />
|
||||||
|
Yes, clear history
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
|
||||||
|
packed into a phone-width row left every label squeezed to
|
||||||
|
~50px. Wrap the list in an overflow-x scroller so each tab
|
||||||
|
keeps a readable label + comfortable touch target on mobile;
|
||||||
|
on desktop the row fits naturally and no scroll bar appears.
|
||||||
|
Negative margins extend the scroller to the page edges so the
|
||||||
|
first/last tabs don't look clipped against the container. */}
|
||||||
|
<Tabs value={filter}>
|
||||||
|
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
|
<TabsList>
|
||||||
|
{FILTER_TABS.map(({ value, label }) => (
|
||||||
|
<TabsTrigger key={value} value={value} asChild>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={`/activity?filter=${value}` as any}>
|
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{filtered.length > 0 ? (
|
{filtered.length > 0 ? (
|
||||||
@ -327,9 +354,6 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right pr-2 whitespace-nowrap">
|
<TableCell className="text-right pr-2 whitespace-nowrap">
|
||||||
<div className="inline-flex items-center gap-0.5">
|
<div className="inline-flex items-center gap-0.5">
|
||||||
{run.status === "paused" && (
|
|
||||||
<ResumeRunButton runId={run.id} />
|
|
||||||
)}
|
|
||||||
<form
|
<form
|
||||||
action={
|
action={
|
||||||
isArchived ? unarchiveRunAction : archiveRunAction
|
isArchived ? unarchiveRunAction : archiveRunAction
|
||||||
@ -377,7 +401,11 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ActivityIcon}
|
icon={ActivityIcon}
|
||||||
title={
|
title={
|
||||||
showingArchived ? "No archived runs." : `No ${filter} runs yet.`
|
filter === "all"
|
||||||
|
? "No activity yet."
|
||||||
|
: showingArchived
|
||||||
|
? "No archived runs."
|
||||||
|
: `No ${filter} runs yet.`
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
hasAny
|
hasAny
|
||||||
|
|||||||
@ -1,15 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
// Without these, `next build`'s "Collecting page data" pass invokes
|
|
||||||
// the GET handler in the build container — which has no
|
|
||||||
// DATABASE_URL — and the env access throws ZodError, killing the
|
|
||||||
// docker build. Marking the route force-dynamic + nodejs runtime
|
|
||||||
// tells Next to skip the build-time call and only run at request
|
|
||||||
// time.
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
params: Promise<{ accountId: string }>;
|
params: Promise<{ accountId: string }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,12 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { NotificationManager } from "@/components/notification-manager";
|
import { NotificationManager } from "@/components/notification-manager";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "cm WhatsApp Bot",
|
title: "cm WhatsApp Bot",
|
||||||
description: "Self-hosted WhatsApp reminder bot",
|
description: "Self-hosted WhatsApp reminder bot",
|
||||||
applicationName: "cm WhatsApp Bot",
|
applicationName: "cm WhatsApp Bot",
|
||||||
robots: { index: false, follow: false },
|
|
||||||
// PWA wiring: the manifest comes from the dynamic route at
|
// PWA wiring: the manifest comes from the dynamic route at
|
||||||
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
|
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
|
||||||
// emitted from public/, and `appleWebApp.capable` lets iOS treat the
|
// emitted from public/, and `appleWebApp.capable` lets iOS treat the
|
||||||
@ -34,13 +32,7 @@ export const viewport: Viewport = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
// Pass the role into AppShell so the nav can hide admin-only entries
|
|
||||||
// for the 'user' role. On /login getCurrentUser returns null and
|
|
||||||
// AppShell short-circuits to the bare header anyway.
|
|
||||||
const me = await getCurrentUser();
|
|
||||||
const role = me?.role ?? null;
|
|
||||||
const username = me?.username ?? null;
|
|
||||||
return (
|
return (
|
||||||
// `suppressHydrationWarning` here is for *attribute* differences only.
|
// `suppressHydrationWarning` here is for *attribute* differences only.
|
||||||
// Two sources legitimately mutate <html>/<body> attributes after the
|
// Two sources legitimately mutate <html>/<body> attributes after the
|
||||||
@ -53,7 +45,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
|
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
|
||||||
<body suppressHydrationWarning>
|
<body suppressHydrationWarning>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AppShell role={role} username={username}>{children}</AppShell>
|
<AppShell>{children}</AppShell>
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
{/* SSE → browser notification bridge. Renders no DOM. */}
|
{/* SSE → browser notification bridge. Renders no DOM. */}
|
||||||
<NotificationManager />
|
<NotificationManager />
|
||||||
|
|||||||
@ -1,101 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { Loader2Icon, LockIcon, HelpCircleIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogClose,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { loginAction } from "@/actions/auth";
|
|
||||||
|
|
||||||
export function LoginFormClient({ next }: { next: string }) {
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function handle(formData: FormData) {
|
|
||||||
formData.append("next", next);
|
|
||||||
start(async () => {
|
|
||||||
setError(null);
|
|
||||||
const r = await loginAction(formData);
|
|
||||||
// On success, the action redirects (no return). If we land here,
|
|
||||||
// something failed and `r` is the error shape.
|
|
||||||
if (r && !r.ok) setError(r.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={handle} className="space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="username">Username</Label>
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
autoComplete="username"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
maxLength={256}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
maxLength={256}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-destructive">{error}</div>
|
|
||||||
)}
|
|
||||||
<Button type="submit" disabled={pending} className="w-full gap-2">
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<LockIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
className="w-full text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<HelpCircleIcon className="size-3.5" />
|
|
||||||
Forgot password?
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Forgot your password?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Contact your administrator to reset it.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" size="sm">
|
|
||||||
Got it
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { LoginFormClient } from "./login-form-client";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Sign in",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
searchParams: Promise<{ next?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function LoginPage({ searchParams }: PageProps) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
const next = sp.next ?? "/";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center px-4 py-8 min-h-[calc(100dvh-3.5rem)]">
|
|
||||||
<Card className="w-full max-w-sm">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<LoginFormClient next={next} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -182,9 +182,9 @@ export default async function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Reminders"
|
title="Reminders"
|
||||||
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.inactiveReminders} / ${stats.totalReminders}`}
|
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.endedReminders} / ${stats.totalReminders}`}
|
||||||
icon={BellIcon}
|
icon={BellIcon}
|
||||||
description="Active / Paused / Inactive / Total"
|
description="Active / Paused / Ended / Total"
|
||||||
href="/reminders"
|
href="/reminders"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -217,7 +217,7 @@ export default async function DashboardPage() {
|
|||||||
themselves are not affected.
|
themselves are not affected.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter showCloseButton>
|
||||||
<form action={clearHistoryAction}>
|
<form action={clearHistoryAction}>
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
|
|||||||
@ -41,9 +41,9 @@ describe("ActionsBar — card visibility by status", () => {
|
|||||||
expect(html).not.toMatch(/aria-label="Pause"/);
|
expect(html).not.toMatch(/aria-label="Pause"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inactive: shows Restart and Delete (no Pause)", () => {
|
it("ended: shows Restart and Delete (no Pause)", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<ActionsBar reminderId="r-1" status="inactive" isRecurring={false} />,
|
<ActionsBar reminderId="r-1" status="ended" isRecurring={false} />,
|
||||||
);
|
);
|
||||||
expect(html).toMatch(/aria-label="Restart"/);
|
expect(html).toMatch(/aria-label="Restart"/);
|
||||||
expect(html).toMatch(/aria-label="Delete"/);
|
expect(html).toMatch(/aria-label="Delete"/);
|
||||||
|
|||||||
@ -38,7 +38,7 @@ interface ActionsBarProps {
|
|||||||
* on desktop, stacked on mobile:
|
* on desktop, stacked on mobile:
|
||||||
*
|
*
|
||||||
* - Pause — only when status === "active"
|
* - Pause — only when status === "active"
|
||||||
* - Restart — when status is "paused" or "inactive"
|
* - Restart — when status is "paused" or "ended"
|
||||||
* - Delete — always available (terminal)
|
* - Delete — always available (terminal)
|
||||||
*
|
*
|
||||||
* Each Dialog confirms before firing the corresponding server action.
|
* Each Dialog confirms before firing the corresponding server action.
|
||||||
@ -46,7 +46,7 @@ interface ActionsBarProps {
|
|||||||
*/
|
*/
|
||||||
export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) {
|
export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) {
|
||||||
const canPause = status === "active";
|
const canPause = status === "active";
|
||||||
const canRestart = status === "paused" || status === "inactive";
|
const canRestart = status === "paused" || status === "ended";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
|
||||||
@ -177,7 +177,7 @@ function ConfirmCard(props: ConfirmCardProps) {
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter showCloseButton>
|
||||||
<form
|
<form
|
||||||
action={async (fd: FormData) => {
|
action={async (fd: FormData) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|||||||
@ -30,7 +30,6 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { getReminderWithRuns } from "@/lib/queries";
|
import { getReminderWithRuns } from "@/lib/queries";
|
||||||
import { PausedRunBanner } from "@/components/reminder-detail/paused-run-banner";
|
|
||||||
import { ActionsBar } from "./actions-bar";
|
import { ActionsBar } from "./actions-bar";
|
||||||
|
|
||||||
function formatWhen(date: Date | null, tz: string): string {
|
function formatWhen(date: Date | null, tz: string): string {
|
||||||
@ -48,7 +47,7 @@ function formatWhen(date: Date | null, tz: string): string {
|
|||||||
const STATUS_STYLES: Record<string, string> = {
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
active:
|
active:
|
||||||
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||||
inactive:
|
ended:
|
||||||
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
|
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
|
||||||
paused:
|
paused:
|
||||||
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
||||||
@ -120,22 +119,6 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Most recent paused run gets a banner — Resume / Cancel are
|
|
||||||
one click away. Pause notifications deep-link here. */}
|
|
||||||
{(() => {
|
|
||||||
const pausedRun = runs.find((r) => r.status === "paused");
|
|
||||||
if (!pausedRun) return null;
|
|
||||||
return (
|
|
||||||
<PausedRunBanner
|
|
||||||
runId={pausedRun.id}
|
|
||||||
sent={pausedRun.sent}
|
|
||||||
total={pausedRun.total}
|
|
||||||
windowEndHour={reminder.deliveryWindowEndHour}
|
|
||||||
timezone={reminder.timezone}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Name — click to edit. Required field, the operator's
|
{/* Name — click to edit. Required field, the operator's
|
||||||
@ -230,28 +213,12 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
|
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
|
||||||
{reminder.rrule && reminder.scheduledAt ? (
|
{reminder.rrule && reminder.scheduledAt ? (
|
||||||
// Single-line summary with mid-string ellipsis. Long
|
<p className="flex items-center gap-1.5 text-xs text-primary/80">
|
||||||
// descriptions ("Every month on days 4, 6, 11, 13, 18,
|
|
||||||
// 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" />
|
<RepeatIcon className="size-3 shrink-0" />
|
||||||
<span className="truncate min-w-0">
|
|
||||||
{describeRecurrence(
|
{describeRecurrence(
|
||||||
specFromRrule(reminder.rrule),
|
specFromRrule(reminder.rrule),
|
||||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">One-off</p>
|
<p className="text-xs text-muted-foreground">One-off</p>
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import {
|
|||||||
restartReminderAction,
|
restartReminderAction,
|
||||||
} from "@/actions/reminders";
|
} from "@/actions/reminders";
|
||||||
|
|
||||||
type FilterValue = "all" | "active" | "inactive" | "paused";
|
type FilterValue = "all" | "active" | "ended" | "paused";
|
||||||
|
|
||||||
function formatWhen(date: Date | null, tz: string): string {
|
function formatWhen(date: Date | null, tz: string): string {
|
||||||
if (!date) return "—";
|
if (!date) return "—";
|
||||||
@ -48,7 +48,7 @@ function formatWhen(date: Date | null, tz: string): string {
|
|||||||
const STATUS_STYLES: Record<string, string> = {
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
active:
|
active:
|
||||||
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||||
inactive:
|
ended:
|
||||||
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
|
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
|
||||||
paused:
|
paused:
|
||||||
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
||||||
@ -104,7 +104,7 @@ function StatusPill({ status }: { status: string }) {
|
|||||||
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
{ value: "active", label: "Active" },
|
{ value: "active", label: "Active" },
|
||||||
{ value: "inactive", label: "Inactive" },
|
{ value: "ended", label: "Ended" },
|
||||||
{ value: "paused", label: "Paused" },
|
{ value: "paused", label: "Paused" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -127,7 +127,7 @@ interface PageProps {
|
|||||||
export default async function RemindersPage({ searchParams }: PageProps) {
|
export default async function RemindersPage({ searchParams }: PageProps) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const status: FilterValue =
|
const status: FilterValue =
|
||||||
sp.filter === "active" || sp.filter === "inactive" || sp.filter === "paused"
|
sp.filter === "active" || sp.filter === "ended" || sp.filter === "paused"
|
||||||
? sp.filter
|
? sp.filter
|
||||||
: "all";
|
: "all";
|
||||||
// Sort is now fixed to `created_desc`. Reordering on every status flip
|
// Sort is now fixed to `created_desc`. Reordering on every status flip
|
||||||
@ -225,7 +225,7 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
{visible.map((reminder) => {
|
{visible.map((reminder) => {
|
||||||
const canPause = reminder.status === "active";
|
const canPause = reminder.status === "active";
|
||||||
const canRestart =
|
const canRestart =
|
||||||
reminder.status === "paused" || reminder.status === "inactive";
|
reminder.status === "paused" || reminder.status === "ended";
|
||||||
const cardBody = (
|
const cardBody = (
|
||||||
<Link
|
<Link
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -247,30 +247,15 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right meta column. Capped at ~14rem so a long
|
<div className="shrink-0 text-right space-y-1">
|
||||||
recurrence description ("Every month on days
|
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||||
4, 6, 7, 11, 13, 14 +6 more at 11:32") can't
|
|
||||||
starve the reminder name on the left. min-w-0
|
|
||||||
+ truncate on each span ellipsises overflow
|
|
||||||
inside the cap. Title tooltip preserves the
|
|
||||||
full text on hover. */}
|
|
||||||
<div className="min-w-0 max-w-[34%] sm:max-w-[9.5rem] text-right space-y-1">
|
|
||||||
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
|
|
||||||
<CalendarIcon className="size-3 shrink-0" />
|
<CalendarIcon className="size-3 shrink-0" />
|
||||||
<span className="truncate">
|
<span>{formatWhen(reminder.scheduledAt, tz)}</span>
|
||||||
{formatWhen(reminder.scheduledAt, tz)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{reminder.rrule && reminder.scheduledAt ? (
|
{reminder.rrule && reminder.scheduledAt ? (
|
||||||
<div
|
<div className="flex items-center justify-end gap-1 text-xs text-primary/80">
|
||||||
className="flex min-w-0 items-center justify-end gap-1 text-xs text-primary/80"
|
|
||||||
title={describeRecurrence(
|
|
||||||
specFromRrule(reminder.rrule),
|
|
||||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RepeatIcon className="size-3 shrink-0" />
|
<RepeatIcon className="size-3 shrink-0" />
|
||||||
<span className="truncate">
|
<span>
|
||||||
{describeRecurrence(
|
{describeRecurrence(
|
||||||
specFromRrule(reminder.rrule),
|
specFromRrule(reminder.rrule),
|
||||||
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
|
||||||
@ -279,9 +264,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{reminder.groupCount > 0 && (
|
{reminder.groupCount > 0 && (
|
||||||
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||||
<UsersIcon className="size-3 shrink-0" />
|
<UsersIcon className="size-3 shrink-0" />
|
||||||
<span className="truncate">
|
<span>
|
||||||
{reminder.groupCount}{" "}
|
{reminder.groupCount}{" "}
|
||||||
{reminder.groupCount === 1 ? "group" : "groups"}
|
{reminder.groupCount === 1 ? "group" : "groups"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
import type { MetadataRoute } from "next";
|
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
|
||||||
return { rules: [{ userAgent: "*", disallow: "/" }] };
|
|
||||||
}
|
|
||||||
@ -7,7 +7,6 @@ import { PageShell } from "@/components/page-shell";
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const isAdmin = op.role === "admin";
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Settings" narrow>
|
<PageShell title="Settings" narrow>
|
||||||
<Card>
|
<Card>
|
||||||
@ -15,15 +14,13 @@ export default async function SettingsPage() {
|
|||||||
<CardTitle>Operator</CardTitle>
|
<CardTitle>Operator</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent className="space-y-3 text-sm">
|
||||||
<Row label="Username" value={op.username} mono />
|
<Row label="Display name" value={op.displayName} />
|
||||||
|
<Separator />
|
||||||
|
<Row label="Operator ID" value={String(op.telegramUserId)} mono />
|
||||||
<Separator />
|
<Separator />
|
||||||
<Row label="Default timezone" value={op.defaultTimezone} mono />
|
<Row label="Default timezone" value={op.defaultTimezone} mono />
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<Row label="Role" value={op.role} mono />
|
<Row label="Role" value={op.role} mono />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -50,6 +47,10 @@ export default async function SettingsPage() {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
cm WhatsApp Bot · self-hosted
|
||||||
|
</p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import { Loader2Icon, UserPlusIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { createUserAction } from "@/actions/users";
|
|
||||||
|
|
||||||
export function AddUserFormClient() {
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [role, setRole] = useState<"admin" | "user">("user");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [ok, setOk] = useState(false);
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
start(async () => {
|
|
||||||
setError(null);
|
|
||||||
setOk(false);
|
|
||||||
const r = await createUserAction({
|
|
||||||
username: username.trim(),
|
|
||||||
password,
|
|
||||||
role,
|
|
||||||
});
|
|
||||||
if (!r.ok) {
|
|
||||||
setError(r.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUsername("");
|
|
||||||
setPassword("");
|
|
||||||
setRole("user");
|
|
||||||
setOk(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="new-username">Username</Label>
|
|
||||||
<Input
|
|
||||||
id="new-username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
maxLength={256}
|
|
||||||
placeholder="alice"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="new-password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="new-password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
maxLength={256}
|
|
||||||
placeholder="≥6 chars · letters + number/symbol"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="new-role">Role</Label>
|
|
||||||
<select
|
|
||||||
id="new-role"
|
|
||||||
value={role}
|
|
||||||
onChange={(e) => setRole(e.target.value === "admin" ? "admin" : "user")}
|
|
||||||
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="user">user</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{error && <p className="mr-auto text-xs text-destructive">{error}</p>}
|
|
||||||
{ok && (
|
|
||||||
<p className="mr-auto text-xs text-emerald-600 dark:text-emerald-400">
|
|
||||||
User created.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Button type="button" size="sm" disabled={pending} onClick={submit}>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<UserPlusIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
Add user
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import { requireAdmin } from "@/lib/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { PageShell } from "@/components/page-shell";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { UserRowClient } from "./user-row-client";
|
|
||||||
import { AddUserFormClient } from "./add-user-form-client";
|
|
||||||
|
|
||||||
export default async function UsersPage() {
|
|
||||||
const me = await requireAdmin();
|
|
||||||
const rows = await db.query.operators.findMany({
|
|
||||||
orderBy: (o, { asc }) => [asc(o.username)],
|
|
||||||
});
|
|
||||||
const adminCount = rows.filter((r) => r.role === "admin").length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell title="Users">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Add user</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Create a sign-in account. Passwords must be at least 10
|
|
||||||
characters.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<AddUserFormClient />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>All users</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Promote a user to admin, demote them back, reset their
|
|
||||||
password, or delete the account. The last admin cannot be
|
|
||||||
demoted or deleted.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{rows.map((u) => (
|
|
||||||
<UserRowClient
|
|
||||||
key={u.id}
|
|
||||||
user={{
|
|
||||||
id: u.id,
|
|
||||||
username: u.username,
|
|
||||||
role: u.role === "admin" ? "admin" : "user",
|
|
||||||
}}
|
|
||||||
isSelf={u.id === me.id}
|
|
||||||
isLastAdmin={u.role === "admin" && adminCount === 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</PageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,197 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import {
|
|
||||||
Loader2Icon,
|
|
||||||
Trash2Icon,
|
|
||||||
KeyIcon,
|
|
||||||
ArrowUpIcon,
|
|
||||||
ArrowDownIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogClose,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
setUserRoleAction,
|
|
||||||
resetUserPasswordAction,
|
|
||||||
deleteUserAction,
|
|
||||||
} from "@/actions/users";
|
|
||||||
import { validatePassword } from "@/lib/password-policy";
|
|
||||||
|
|
||||||
interface UserRowClientProps {
|
|
||||||
user: { id: string; username: string; role: "admin" | "user" };
|
|
||||||
isSelf: boolean;
|
|
||||||
/** True when this row is the only remaining admin. Disables demote+delete. */
|
|
||||||
isLastAdmin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps) {
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [resetVisible, setResetVisible] = useState(false);
|
|
||||||
const [resetPw, setResetPw] = useState("");
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
|
|
||||||
function run<T extends { ok: boolean; error?: string }>(promise: Promise<T>) {
|
|
||||||
start(async () => {
|
|
||||||
setError(null);
|
|
||||||
const r = await promise;
|
|
||||||
if (!r.ok) setError(r.error ?? "Failed");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAdmin = user.role === "admin";
|
|
||||||
// The role-toggle button is disabled if:
|
|
||||||
// - flipping yourself (admin self-demotion is rejected server-side too)
|
|
||||||
// - this row is the last remaining admin and would become a user
|
|
||||||
const roleToggleDisabled = pending || isSelf || (isAdmin && isLastAdmin);
|
|
||||||
const deleteDisabled = pending || isSelf || isLastAdmin;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
|
||||||
{/* Row 1 — identity: username on the left, role badge + "you"
|
|
||||||
chip on the right, all on one line. */}
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{user.username}
|
|
||||||
</p>
|
|
||||||
{isSelf && (
|
|
||||||
<span className="text-xs text-muted-foreground shrink-0">you</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={
|
|
||||||
isAdmin
|
|
||||||
? "shrink-0 bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent"
|
|
||||||
: "shrink-0 bg-slate-200/60 text-slate-600 dark:bg-slate-700/40 dark:text-slate-300 border-transparent"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{user.role}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{/* Row 2 — actions: Promote/Demote, Reset, Delete, right-aligned. */}
|
|
||||||
<div className="flex flex-wrap justify-end gap-1.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={roleToggleDisabled}
|
|
||||||
onClick={() =>
|
|
||||||
run(
|
|
||||||
setUserRoleAction({
|
|
||||||
userId: user.id,
|
|
||||||
role: isAdmin ? "user" : "admin",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isAdmin ? (
|
|
||||||
<ArrowDownIcon className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpIcon className="size-3.5" />
|
|
||||||
)}
|
|
||||||
{isAdmin ? "Demote" : "Promote"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => setResetVisible((v) => !v)}
|
|
||||||
>
|
|
||||||
<KeyIcon className="size-3.5" />
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive"
|
|
||||||
disabled={deleteDisabled}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="size-3.5" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete user @{user.username}?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
This permanently removes the account. They will be
|
|
||||||
signed out on their next request and cannot sign in
|
|
||||||
again. This cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="ghost" size="sm">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteOpen(false);
|
|
||||||
run(deleteUserAction({ userId: user.id }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2Icon className="size-3.5" />
|
|
||||||
)}
|
|
||||||
Delete user
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
{resetVisible && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="New password (≥6 chars · letters + number/symbol)"
|
|
||||||
value={resetPw}
|
|
||||||
onChange={(e) => setResetPw(e.target.value)}
|
|
||||||
maxLength={256}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending || !validatePassword(resetPw).ok}
|
|
||||||
onClick={() => {
|
|
||||||
run(
|
|
||||||
resetUserPasswordAction({
|
|
||||||
userId: user.id,
|
|
||||||
newPassword: resetPw,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setResetPw("");
|
|
||||||
setResetVisible(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
|
|
||||||
vi.mock("@/actions/reminders", () => ({
|
|
||||||
resumeReminderRunAction: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { ResumeRunButton } from "./resume-run-button";
|
|
||||||
|
|
||||||
describe("ResumeRunButton", () => {
|
|
||||||
it("renders an icon button with aria-label='Resume run'", () => {
|
|
||||||
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" />);
|
|
||||||
expect(html).toMatch(/aria-label="Resume run"/);
|
|
||||||
expect(html).toMatch(/lucide-play/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses emerald accent so paused rows clearly offer 'go again'", () => {
|
|
||||||
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" />);
|
|
||||||
expect(html).toMatch(/text-emerald-700/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("compact variant uses size=icon-sm so it fits inline in the table", () => {
|
|
||||||
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" variant="compact" />);
|
|
||||||
// shadcn button forwards size into a data-size attr.
|
|
||||||
expect(html).toMatch(/data-size="icon-sm"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("default variant uses size=sm for a standalone surface", () => {
|
|
||||||
const html = renderToStaticMarkup(<ResumeRunButton runId="r-1" variant="default" />);
|
|
||||||
expect(html).toMatch(/data-size="sm"/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useTransition, useState } from "react";
|
|
||||||
import { Loader2Icon, PlayIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { resumeReminderRunAction } from "@/actions/reminders";
|
|
||||||
|
|
||||||
interface ResumeRunButtonProps {
|
|
||||||
runId: string;
|
|
||||||
/** Style hint — "compact" suits inline rows, "default" suits the
|
|
||||||
* paused-detail banner which renders its own size already. */
|
|
||||||
variant?: "compact" | "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small wrapper around resumeReminderRunAction so paused rows in the
|
|
||||||
* Activity tab can offer "Resume" without each row rolling its own
|
|
||||||
* useTransition / error handling. Cancel uses the detail banner —
|
|
||||||
* it's the rarer path.
|
|
||||||
*/
|
|
||||||
export function ResumeRunButton({ runId, variant = "compact" }: ResumeRunButtonProps) {
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const onClick = () =>
|
|
||||||
start(async () => {
|
|
||||||
setError(null);
|
|
||||||
const r = await resumeReminderRunAction({ runId });
|
|
||||||
if (!r.ok) setError(r.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex flex-col items-end gap-0.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size={variant === "compact" ? "icon-sm" : "sm"}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={pending}
|
|
||||||
aria-label="Resume run"
|
|
||||||
className="text-emerald-700 hover:text-emerald-800 dark:text-emerald-400 dark:hover:text-emerald-300"
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<PlayIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{error && (
|
|
||||||
<span className="text-[10px] text-destructive whitespace-nowrap">{error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -55,7 +55,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("renders a fixed top header that hides on sm+ breakpoints", () => {
|
it("renders a fixed top header that hides on sm+ breakpoints", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<main>page</main>
|
<main>page</main>
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -66,7 +66,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("brand mark on the left links to /", () => {
|
it("brand mark on the left links to /", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -90,7 +90,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
for (const c of cases) {
|
for (const c of cases) {
|
||||||
pathnameMock.mockReturnValue(c.path);
|
pathnameMock.mockReturnValue(c.path);
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -104,7 +104,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
|
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
|
||||||
pathnameMock.mockReturnValue("/unknown-route");
|
pathnameMock.mockReturnValue("/unknown-route");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -115,7 +115,7 @@ describe("AppShell — mobile header (SSR)", () => {
|
|||||||
|
|
||||||
it("menu button on the right uses aria-label='Open menu'", () => {
|
it("menu button on the right uses aria-label='Open menu'", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -134,7 +134,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("renders one nav link per NAV_ITEM, in order", () => {
|
it("renders one nav link per NAV_ITEM, in order", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -157,7 +157,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
it("marks the active route's link with aria-current='page'", () => {
|
it("marks the active route's link with aria-current='page'", () => {
|
||||||
pathnameMock.mockReturnValue("/reminders");
|
pathnameMock.mockReturnValue("/reminders");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -174,7 +174,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
// every page. The header uses an exact-match check for "/".
|
// every page. The header uses an exact-match check for "/".
|
||||||
pathnameMock.mockReturnValue("/accounts");
|
pathnameMock.mockReturnValue("/accounts");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -185,7 +185,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
|
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -195,7 +195,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
|
|||||||
|
|
||||||
it("drawer header carries the brand wording and a screen-reader description", () => {
|
it("drawer header carries the brand wording and a screen-reader description", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -221,7 +221,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
|
|
||||||
it("renders the sidebar nav with every NAV_ITEM", () => {
|
it("renders the sidebar nav with every NAV_ITEM", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -232,22 +232,21 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a Sign out button in the sidebar footer", () => {
|
it("keeps the theme toggle in the sidebar footer", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
// Theme toggle was dropped from the shell per request; the footer
|
// The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must
|
||||||
// now carries the Sign out affordance + the signed-in username.
|
// appear in the sidebar (we removed it from the mobile drawer).
|
||||||
expect(html).toContain('aria-label="Sign out"');
|
expect(html).toContain('data-testid="theme-toggle"');
|
||||||
expect(html).toContain("admin");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
|
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
|
||||||
pathnameMock.mockReturnValue("/accounts");
|
pathnameMock.mockReturnValue("/accounts");
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -265,7 +264,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
// reader users on a wide-window split-screen don't hear two
|
// reader users on a wide-window split-screen don't hear two
|
||||||
// identical announcements when both are visible.
|
// identical announcements when both are visible.
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AppShell role="admin" username="admin">
|
<AppShell>
|
||||||
<div />
|
<div />
|
||||||
</AppShell>,
|
</AppShell>,
|
||||||
);
|
);
|
||||||
@ -274,79 +273,6 @@ describe("AppShell — desktop sidebar (SSR)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Role-gated nav (admin panel)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe("AppShell — role-based nav filtering", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
pathnameMock.mockReset();
|
|
||||||
pathnameMock.mockReturnValue("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the Admin entry in BOTH the sidebar and drawer when role=admin", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<AppShell role="admin" username="admin">
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
expect(html).toContain('href="/settings/users"');
|
|
||||||
// A label appears in both the sidebar and the drawer; either way the
|
|
||||||
// count must be >=2 (sidebar copy + drawer copy).
|
|
||||||
const occurrences = (html.match(/href="\/settings\/users"/g) ?? []).length;
|
|
||||||
expect(occurrences).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the Admin entry from BOTH surfaces when role=user", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<AppShell role="user" username="alice">
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
expect(html).not.toContain('href="/settings/users"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the Admin entry when role=null (defense-in-depth: unauthenticated)", () => {
|
|
||||||
pathnameMock.mockReturnValue("/accounts");
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<AppShell role={null} username={null}>
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
expect(html).not.toContain('href="/settings/users"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT hide entries that have no visibleTo restriction for either role", () => {
|
|
||||||
const adminHtml = renderToStaticMarkup(
|
|
||||||
<AppShell role="admin" username="admin">
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
const userHtml = renderToStaticMarkup(
|
|
||||||
<AppShell role="user" username="alice">
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
for (const item of NAV_ITEMS) {
|
|
||||||
if (item.visibleTo) continue;
|
|
||||||
expect(adminHtml).toContain(`href="${item.href}"`);
|
|
||||||
expect(userHtml).toContain(`href="${item.href}"`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("/login route renders the bare header — no nav, no drawer, regardless of role", () => {
|
|
||||||
pathnameMock.mockReturnValue("/login");
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<AppShell role={null} username={null}>
|
|
||||||
<div />
|
|
||||||
</AppShell>,
|
|
||||||
);
|
|
||||||
expect(html).not.toContain("<aside");
|
|
||||||
expect(html).not.toContain('data-testid="sheet-content"');
|
|
||||||
expect(html).not.toContain('href="/settings/users"');
|
|
||||||
expect(html).toContain("WhatsApp Bot");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// helpers
|
// helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react";
|
import { MenuIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { logoutAction } from "@/actions/auth";
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@ -15,13 +14,8 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import {
|
import { NAV_ITEMS } from "@/components/nav-config";
|
||||||
NAV_ITEMS,
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
navItemsForRole,
|
|
||||||
pickActiveNavKey,
|
|
||||||
type NavItem,
|
|
||||||
type NavRole,
|
|
||||||
} from "@/components/nav-config";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mobile header (sm:hidden)
|
// Mobile header (sm:hidden)
|
||||||
@ -36,51 +30,8 @@ import {
|
|||||||
// waiting for the page content to render. The menu button on the right
|
// waiting for the page content to render. The menu button on the right
|
||||||
// opens a Sheet with the full nav list and the theme toggle.
|
// opens a Sheet with the full nav list and the theme toggle.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ---------------------------------------------------------------------------
|
function MobileHeader() {
|
||||||
// Sign-out button used by both the desktop sidebar footer and the mobile
|
|
||||||
// drawer footer. Server-action under the hood: clears the session
|
|
||||||
// cookie and redirects to /login. Disabled while in flight so a
|
|
||||||
// double-click doesn't fire two redirects.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function SignOutButton({ username }: { username: string | null }) {
|
|
||||||
const [pending, start] = useTransition();
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
{username && (
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
Signed in as <em className="italic font-medium text-foreground">{username}</em>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => start(() => logoutAction())}
|
|
||||||
aria-label="Sign out"
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<LogOutIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileHeader({
|
|
||||||
items,
|
|
||||||
username,
|
|
||||||
}: {
|
|
||||||
items: NavItem[];
|
|
||||||
username: string | null;
|
|
||||||
}) {
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const activeKey = pickActiveNavKey(items, pathname);
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Close the drawer when the route changes (i.e. the user picked a nav
|
// Close the drawer when the route changes (i.e. the user picked a nav
|
||||||
@ -90,10 +41,6 @@ function MobileHeader({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
// Use the full list (not the role-filtered one) for the title lookup
|
|
||||||
// so the page title still shows up correctly when a 'user' role hits
|
|
||||||
// a route they wouldn't normally see in the nav (e.g. arrives via a
|
|
||||||
// direct link), even though they can't navigate there from the menu.
|
|
||||||
const currentItem = NAV_ITEMS.find(({ href }) =>
|
const currentItem = NAV_ITEMS.find(({ href }) =>
|
||||||
href === "/" ? pathname === "/" : pathname.startsWith(href),
|
href === "/" ? pathname === "/" : pathname.startsWith(href),
|
||||||
);
|
);
|
||||||
@ -143,10 +90,10 @@ function MobileHeader({
|
|||||||
|
|
||||||
<nav
|
<nav
|
||||||
aria-label="Primary navigation"
|
aria-label="Primary navigation"
|
||||||
className="flex flex-col gap-0.5 p-2"
|
className="flex flex-col gap-0.5 p-2 flex-1"
|
||||||
>
|
>
|
||||||
{items.map(({ key, href, label, icon: Icon }) => {
|
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
||||||
const active = activeKey === key;
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={key}
|
key={key}
|
||||||
@ -170,10 +117,6 @@ function MobileHeader({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-auto border-t border-border p-3">
|
|
||||||
<SignOutButton username={username} />
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</header>
|
</header>
|
||||||
@ -183,15 +126,8 @@ function MobileHeader({
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar (desktop only — hidden below sm)
|
// Sidebar (desktop only — hidden below sm)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function Sidebar({
|
function Sidebar() {
|
||||||
items,
|
|
||||||
username,
|
|
||||||
}: {
|
|
||||||
items: NavItem[];
|
|
||||||
username: string | null;
|
|
||||||
}) {
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const activeKey = pickActiveNavKey(items, pathname);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
|
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
|
||||||
@ -214,7 +150,7 @@ function Sidebar({
|
|||||||
|
|
||||||
{/* Nav items */}
|
{/* Nav items */}
|
||||||
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
|
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
|
||||||
{items.map(({ key, href, label, icon: Icon }) => {
|
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@ -236,74 +172,29 @@ function Sidebar({
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer: signed-in user + sign-out */}
|
{/* Footer: theme toggle */}
|
||||||
<div className="border-t border-sidebar-border p-3">
|
<div className="border-t border-sidebar-border p-3">
|
||||||
<SignOutButton username={username} />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bare header for unauthenticated routes (/login). No sidebar, no mobile
|
|
||||||
// menu, no nav — just the centered brand mark + name. The user explicitly
|
|
||||||
// asked for nothing else here so the sign-in screen feels like a separate
|
|
||||||
// surface from the authenticated app.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function BareHeader() {
|
|
||||||
return (
|
|
||||||
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center justify-center border-b border-border bg-background/95 backdrop-blur-sm px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
|
|
||||||
>
|
|
||||||
cm
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold tracking-tight">
|
|
||||||
WhatsApp Bot
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AppShell — the outer container
|
// AppShell — the outer container
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Role of the signed-in user, or null when unauthenticated. */
|
|
||||||
role: NavRole | null;
|
|
||||||
/** Username of the signed-in user, surfaced in the footer + sign-out hint. */
|
|
||||||
username: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children, role, username }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
const pathname = usePathname();
|
|
||||||
const isAuthRoute = pathname === "/login";
|
|
||||||
|
|
||||||
if (isAuthRoute) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BareHeader />
|
|
||||||
<main className="min-h-dvh pt-14">{children}</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Treat unauthenticated render of a protected route (shouldn't happen
|
|
||||||
// because middleware redirects, but defense-in-depth) as 'user': hides
|
|
||||||
// the admin-only entries.
|
|
||||||
const items = navItemsForRole(role ?? "user");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<Sidebar items={items} username={username} />
|
<Sidebar />
|
||||||
|
|
||||||
{/* Mobile header (single row: brand · title · menu) */}
|
{/* Mobile header (single row: brand · title · menu) */}
|
||||||
<MobileHeader items={items} username={username} />
|
<MobileHeader />
|
||||||
|
|
||||||
{/* Main content
|
{/* Main content
|
||||||
Mobile: push down for the h-14 header (56px) plus a small gap
|
Mobile: push down for the h-14 header (56px) plus a small gap
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { NAV_ITEMS, navItemsForRole, pickActiveNavKey } from "./nav-config";
|
|
||||||
|
|
||||||
describe("navItemsForRole", () => {
|
|
||||||
it("includes every NAV_ITEM for an admin", () => {
|
|
||||||
const items = navItemsForRole("admin");
|
|
||||||
expect(items).toHaveLength(NAV_ITEMS.length);
|
|
||||||
for (const original of NAV_ITEMS) {
|
|
||||||
expect(items.find((i) => i.key === original.key)).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides admin-only entries for the 'user' role", () => {
|
|
||||||
const items = navItemsForRole("user");
|
|
||||||
const keys = items.map((i) => i.key);
|
|
||||||
expect(keys).not.toContain("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the un-restricted entries (no visibleTo) for the 'user' role", () => {
|
|
||||||
const items = navItemsForRole("user");
|
|
||||||
const keys = items.map((i) => i.key);
|
|
||||||
expect(keys).toEqual(
|
|
||||||
expect.arrayContaining(["dashboard", "accounts", "reminders", "activity", "settings"]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin nav entry routes to /settings/users", () => {
|
|
||||||
const admin = NAV_ITEMS.find((i) => i.key === "admin");
|
|
||||||
expect(admin).toBeDefined();
|
|
||||||
expect(admin!.href).toBe("/settings/users");
|
|
||||||
expect(admin!.visibleTo).toEqual(["admin"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pickActiveNavKey (longest-match active highlight)", () => {
|
|
||||||
// Use the real NAV_ITEMS so a future href change doesn't silently
|
|
||||||
// re-introduce the regression.
|
|
||||||
const adminItems = navItemsForRole("admin");
|
|
||||||
const userItems = navItemsForRole("user");
|
|
||||||
|
|
||||||
it("highlights ONLY the Admin entry on /settings/users (not Settings too)", () => {
|
|
||||||
// Repro of the user-reported regression. Naïve startsWith would
|
|
||||||
// light up both Settings (/settings) and Admin (/settings/users)
|
|
||||||
// because both prefixes match. The longest-match rule must pick
|
|
||||||
// the Admin entry alone.
|
|
||||||
const active = pickActiveNavKey(adminItems, "/settings/users");
|
|
||||||
expect(active).toBe("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("highlights Settings on /settings exact, with Admin NOT lit", () => {
|
|
||||||
const active = pickActiveNavKey(adminItems, "/settings");
|
|
||||||
expect(active).toBe("settings");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("highlights Settings on a subpath that is NOT /settings/users", () => {
|
|
||||||
// Admin nav is admin-only; this test is just to confirm the
|
|
||||||
// longest-match still picks Settings when no admin descendant
|
|
||||||
// claims the path.
|
|
||||||
const active = pickActiveNavKey(adminItems, "/settings/profile");
|
|
||||||
expect(active).toBe("settings");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("highlights Dashboard ONLY on '/' exact (not on every route)", () => {
|
|
||||||
expect(pickActiveNavKey(adminItems, "/")).toBe("dashboard");
|
|
||||||
expect(pickActiveNavKey(adminItems, "/accounts")).toBe("accounts");
|
|
||||||
expect(pickActiveNavKey(adminItems, "/reminders/abc-123")).toBe("reminders");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when nothing matches (e.g. a route hidden from this role)", () => {
|
|
||||||
// /settings/users isn't visible to a 'user' role, so the helper
|
|
||||||
// must NOT highlight it as Settings just because /settings is a
|
|
||||||
// prefix — we'd be claiming an item is active when the user can't
|
|
||||||
// navigate to it from this nav.
|
|
||||||
expect(pickActiveNavKey(userItems, "/settings/users")).toBe("settings");
|
|
||||||
// Neither item's href matches a totally foreign route.
|
|
||||||
expect(pickActiveNavKey(adminItems, "/elsewhere")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT match a sibling that shares a prefix string", () => {
|
|
||||||
// /settingsfoo is NOT a child of /settings — startsWith would
|
|
||||||
// mistakenly mark Settings active. The strict descendant check
|
|
||||||
// (`href + '/'`) prevents that.
|
|
||||||
expect(pickActiveNavKey(adminItems, "/settingsfoo")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("each pathname highlights AT MOST one nav key (defense check)", () => {
|
|
||||||
// Walk a small representative set of routes and confirm we never
|
|
||||||
// light up two items at once. This is the contract the JSX in
|
|
||||||
// app-shell.tsx relies on.
|
|
||||||
const probes = [
|
|
||||||
"/",
|
|
||||||
"/accounts",
|
|
||||||
"/accounts/abc",
|
|
||||||
"/reminders",
|
|
||||||
"/reminders/abc",
|
|
||||||
"/activity",
|
|
||||||
"/activity?filter=success",
|
|
||||||
"/settings",
|
|
||||||
"/settings/users",
|
|
||||||
"/settings/users/something",
|
|
||||||
"/login",
|
|
||||||
"/elsewhere",
|
|
||||||
];
|
|
||||||
for (const path of probes) {
|
|
||||||
const matchCount = adminItems.filter((item) => {
|
|
||||||
if (item.href === "/") return path === "/";
|
|
||||||
return path === item.href || path.startsWith(item.href + "/");
|
|
||||||
}).length;
|
|
||||||
// If two prefixes both match, pickActiveNavKey must collapse
|
|
||||||
// them to one — that's the whole point of the helper.
|
|
||||||
const active = pickActiveNavKey(adminItems, path);
|
|
||||||
if (matchCount === 0) {
|
|
||||||
expect(active).toBeNull();
|
|
||||||
} else {
|
|
||||||
expect(active).not.toBeNull();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,22 +1,11 @@
|
|||||||
import {
|
import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react";
|
||||||
Home,
|
|
||||||
Smartphone,
|
|
||||||
Calendar,
|
|
||||||
Activity,
|
|
||||||
Settings,
|
|
||||||
ShieldCheck,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
export type NavRole = "admin" | "user";
|
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
key: string;
|
key: string;
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
/** When set, only roles listed here will see this nav entry. */
|
|
||||||
visibleTo?: NavRole[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
@ -24,54 +13,5 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
|
||||||
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
|
||||||
{ key: "activity", href: "/activity", label: "Activity", icon: Activity },
|
{ key: "activity", href: "/activity", label: "Activity", icon: Activity },
|
||||||
{
|
|
||||||
key: "admin",
|
|
||||||
href: "/settings/users",
|
|
||||||
label: "Admin",
|
|
||||||
icon: ShieldCheck,
|
|
||||||
visibleTo: ["admin"],
|
|
||||||
},
|
|
||||||
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
|
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function navItemsForRole(role: NavRole): NavItem[] {
|
|
||||||
return NAV_ITEMS.filter(
|
|
||||||
(item) => item.visibleTo === undefined || item.visibleTo.includes(role),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick the SINGLE active nav item for a given pathname. Solves the
|
|
||||||
* "Admin and Settings both highlighted on /settings/users" bug:
|
|
||||||
* naïve `pathname.startsWith(href)` matches BOTH /settings/users (the
|
|
||||||
* Admin entry) AND /settings (its parent). Two items lit up at once
|
|
||||||
* looks broken.
|
|
||||||
*
|
|
||||||
* Rules:
|
|
||||||
* - The Dashboard ('/') item only matches an exact pathname match;
|
|
||||||
* otherwise it would shadow every other route.
|
|
||||||
* - All other items match either an exact pathname or a strict
|
|
||||||
* descendant path (`<href>/...`). `pathname.startsWith(href)` on
|
|
||||||
* its own would also match `/settingsfoo`, which is wrong.
|
|
||||||
* - When two non-root items both match (parent + child), pick the
|
|
||||||
* LONGEST href so the more specific entry wins.
|
|
||||||
*
|
|
||||||
* Returns the active item's `key`, or null if no item matches (e.g.
|
|
||||||
* the user navigated to a route that isn't in the visible nav).
|
|
||||||
*/
|
|
||||||
export function pickActiveNavKey(items: NavItem[], pathname: string): string | null {
|
|
||||||
let best: NavItem | null = null;
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.href === "/") {
|
|
||||||
if (pathname === "/") best = item;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const isMatch =
|
|
||||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
|
||||||
if (!isMatch) continue;
|
|
||||||
if (!best || item.href.length > best.href.length) {
|
|
||||||
best = item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best?.key ?? null;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -18,8 +18,7 @@ type PairingState =
|
|||||||
| { phase: "waiting" }
|
| { phase: "waiting" }
|
||||||
| { phase: "qr"; qrUrl: string }
|
| { phase: "qr"; qrUrl: string }
|
||||||
| { phase: "connected"; phoneNumber: string }
|
| { phase: "connected"; phoneNumber: string }
|
||||||
| { phase: "timeout" }
|
| { phase: "timeout" };
|
||||||
| { phase: "duplicate"; phoneNumber: string; existingLabel: string };
|
|
||||||
|
|
||||||
interface PairLiveProps {
|
interface PairLiveProps {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@ -113,15 +112,6 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
|||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
setPairingState({ phase: "timeout" });
|
setPairingState({ phase: "timeout" });
|
||||||
},
|
},
|
||||||
"session.duplicate": (data) => {
|
|
||||||
if (data.accountId !== accountId) return;
|
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
|
||||||
setPairingState({
|
|
||||||
phase: "duplicate",
|
|
||||||
phoneNumber: data.phoneNumber,
|
|
||||||
existingLabel: data.existingLabel,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-redirect on connected
|
// Auto-redirect on connected
|
||||||
@ -244,35 +234,6 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pairingState.phase === "duplicate" && (
|
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-amber-500/15">
|
|
||||||
<XCircleIcon className="size-8 text-amber-600 dark:text-amber-400" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-base font-semibold">Phone already linked</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
<span className="font-mono">
|
|
||||||
+{pairingState.phoneNumber.replace(/^\+/, "")}
|
|
||||||
</span>{" "}
|
|
||||||
is already paired to{" "}
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{pairingState.existingLabel}
|
|
||||||
</span>
|
|
||||||
. Each WhatsApp number can only be linked to one account here.
|
|
||||||
Unpair the existing account first, or scan with a different
|
|
||||||
phone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild size="sm">
|
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
||||||
<Link href={`/accounts/${accountId}` as any}>
|
|
||||||
Back to accounts
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
|
|
||||||
vi.mock("@/actions/reminders", () => ({
|
|
||||||
resumeReminderRunAction: vi.fn(),
|
|
||||||
cancelReminderRunAction: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { PausedRunBanner } from "./paused-run-banner";
|
|
||||||
|
|
||||||
describe("PausedRunBanner — SSR layout", () => {
|
|
||||||
it("renders Resume + Cancel buttons inside the banner", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<PausedRunBanner
|
|
||||||
runId="r-1"
|
|
||||||
sent={412}
|
|
||||||
total={1000}
|
|
||||||
windowEndHour={18}
|
|
||||||
timezone="Asia/Kuala_Lumpur"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(html).toContain('data-testid="paused-run-banner"');
|
|
||||||
expect(html).toContain('data-testid="paused-resume"');
|
|
||||||
expect(html).toContain('data-testid="paused-cancel"');
|
|
||||||
expect(html).toMatch(/Resume<\/button>/);
|
|
||||||
expect(html).toMatch(/Cancel run<\/button>/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows X of Y groups delivered when sent + total are present", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<PausedRunBanner
|
|
||||||
runId="r-1"
|
|
||||||
sent={412}
|
|
||||||
total={1000}
|
|
||||||
windowEndHour={18}
|
|
||||||
timezone="Asia/Kuala_Lumpur"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(html).toContain("412 of 1000 groups delivered");
|
|
||||||
// Surfaces the window-end deadline so the operator knows why.
|
|
||||||
expect(html).toContain("18:00 (Asia/Kuala_Lumpur)");
|
|
||||||
// And the remaining count drives the CTA copy.
|
|
||||||
expect(html).toContain("send the remaining 588");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to a generic body when sent / total aren't supplied", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<PausedRunBanner
|
|
||||||
runId="r-1"
|
|
||||||
windowEndHour={18}
|
|
||||||
timezone="Asia/Kuala_Lumpur"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(html).toMatch(/delivery window closed before/i);
|
|
||||||
expect(html).not.toContain("groups delivered");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses amber styling so the banner reads as 'attention, not error'", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<PausedRunBanner
|
|
||||||
runId="r-1"
|
|
||||||
sent={1}
|
|
||||||
total={2}
|
|
||||||
windowEndHour={18}
|
|
||||||
timezone="UTC"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(html).toMatch(/border-amber-500/);
|
|
||||||
expect(html).toMatch(/bg-amber-500/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
|
||||||
import {
|
|
||||||
AlertCircleIcon,
|
|
||||||
PlayIcon,
|
|
||||||
XIcon,
|
|
||||||
Loader2Icon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
resumeReminderRunAction,
|
|
||||||
cancelReminderRunAction,
|
|
||||||
} from "@/actions/reminders";
|
|
||||||
|
|
||||||
interface PausedRunBannerProps {
|
|
||||||
runId: string;
|
|
||||||
/** Best-effort sent count for the body copy. Falls back to a
|
|
||||||
* generic message when undefined. */
|
|
||||||
sent?: number;
|
|
||||||
/** Best-effort total target count. */
|
|
||||||
total?: number;
|
|
||||||
/** Deadline hour the bot stopped at. Shown in the body copy. */
|
|
||||||
windowEndHour: number;
|
|
||||||
/** Operator timezone (for the deadline label). */
|
|
||||||
timezone: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Amber callout shown above the reminder detail view when the most
|
|
||||||
* recent run is in 'paused' state. Two interactive choices:
|
|
||||||
* • Resume → re-enqueues the run via the bot.
|
|
||||||
* • Cancel run → stops the run cleanly (remaining pending → skipped).
|
|
||||||
*
|
|
||||||
* Pause notifications deep-link the operator into this surface.
|
|
||||||
*/
|
|
||||||
export function PausedRunBanner({
|
|
||||||
runId,
|
|
||||||
sent,
|
|
||||||
total,
|
|
||||||
windowEndHour,
|
|
||||||
timezone,
|
|
||||||
}: PausedRunBannerProps) {
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const onResume = () =>
|
|
||||||
startTransition(async () => {
|
|
||||||
setError(null);
|
|
||||||
const r = await resumeReminderRunAction({ runId });
|
|
||||||
if (!r.ok) setError(r.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onCancel = () =>
|
|
||||||
startTransition(async () => {
|
|
||||||
setError(null);
|
|
||||||
const r = await cancelReminderRunAction({ runId });
|
|
||||||
if (!r.ok) setError(r.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
const remaining =
|
|
||||||
typeof sent === "number" && typeof total === "number"
|
|
||||||
? Math.max(0, total - sent)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-4 space-y-3"
|
|
||||||
data-testid="paused-run-banner"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<p className="font-medium">Reminder paused</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{typeof sent === "number" && typeof total === "number"
|
|
||||||
? `${sent} of ${total} groups delivered.`
|
|
||||||
: "The delivery window closed before all groups got the message."}{" "}
|
|
||||||
The deadline was {windowEndHour}:00 ({timezone}).{" "}
|
|
||||||
{remaining !== null && remaining > 0
|
|
||||||
? `Resume to send the remaining ${remaining}, or cancel the run.`
|
|
||||||
: "Resume to keep going, or cancel the run."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-destructive">{error}</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={onResume}
|
|
||||||
disabled={pending}
|
|
||||||
className="gap-2"
|
|
||||||
data-testid="paused-resume"
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<Loader2Icon className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<PlayIcon className="size-3.5" />
|
|
||||||
)}
|
|
||||||
Resume
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={pending}
|
|
||||||
className="gap-2"
|
|
||||||
data-testid="paused-cancel"
|
|
||||||
>
|
|
||||||
<XIcon className="size-3.5" />
|
|
||||||
Cancel run
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -52,15 +52,7 @@ export function EditWhenForm({
|
|||||||
const [date, setDate] = useState(initial.date);
|
const [date, setDate] = useState(initial.date);
|
||||||
const [time, setTime] = useState(initial.time);
|
const [time, setTime] = useState(initial.time);
|
||||||
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec);
|
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec);
|
||||||
// Optional deadline: 24 (next-day midnight) is the off-sentinel —
|
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(initialDeliveryEndHour);
|
||||||
// hour=24 makes windowEndAt return tomorrow's start, effectively
|
|
||||||
// "no deadline today". Existing rows at 24 land with the toggle
|
|
||||||
// OFF; rows at any other value land toggled ON with that value.
|
|
||||||
const initialUseDeadline = initialDeliveryEndHour !== 24;
|
|
||||||
const [useDeadline, setUseDeadline] = useState<boolean>(initialUseDeadline);
|
|
||||||
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(
|
|
||||||
initialUseDeadline ? initialDeliveryEndHour : 18,
|
|
||||||
);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -116,7 +108,7 @@ export function EditWhenForm({
|
|||||||
scheduledAtIso,
|
scheduledAtIso,
|
||||||
rrule,
|
rrule,
|
||||||
timezone,
|
timezone,
|
||||||
deliveryWindowEndHour: useDeadline ? deliveryEndHour : 24,
|
deliveryWindowEndHour: deliveryEndHour,
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -170,29 +162,13 @@ export function EditWhenForm({
|
|||||||
|
|
||||||
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
|
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="flex items-center gap-3 rounded-lg border border-input bg-card px-3 py-2.5 cursor-pointer select-none transition-colors hover:bg-accent/40">
|
<Label className="flex items-center gap-1.5">
|
||||||
<input
|
|
||||||
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" />
|
<ClockIcon className="size-3.5" />
|
||||||
Pause sending by
|
Pause sending by
|
||||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||||
</span>
|
</Label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{useDeadline ? "Set" : "Off"}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{useDeadline && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pl-3">
|
|
||||||
<HourSelect
|
<HourSelect
|
||||||
ariaPrefix="Delivery deadline"
|
ariaPrefix="Delivery deadline"
|
||||||
value={deliveryEndHour}
|
value={deliveryEndHour}
|
||||||
@ -203,7 +179,6 @@ export function EditWhenForm({
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">({timezone})</span>
|
<span className="text-xs text-muted-foreground">({timezone})</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@ -99,7 +99,7 @@ export function ReminderFilterBar({ accounts }: FilterBarProps) {
|
|||||||
id="filter-account"
|
id="filter-account"
|
||||||
value={initial.accountId}
|
value={initial.accountId}
|
||||||
onChange={(e) => setParam("accountId", e.target.value)}
|
onChange={(e) => setParam("accountId", e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="h-8 w-full sm:max-w-xs rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">All accounts</option>
|
<option value="">All accounts</option>
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
|
|||||||
@ -82,12 +82,8 @@ export function ReviewSubmitClient({
|
|||||||
|
|
||||||
const groupCount = groupIds ? groupIds.split(",").filter(Boolean).length : 0;
|
const groupCount = groupIds ? groupIds.split(",").filter(Boolean).length : 0;
|
||||||
const fireAt = new Date(scheduledAt);
|
const fireAt = new Date(scheduledAt);
|
||||||
// Treat hour=24 (or unset) as "no deadline". The pill goes neutral.
|
const endHour = deliveryEndHour ?? 18;
|
||||||
const hasDeadline =
|
const wEnd = windowEndAt(timezone, endHour, fireAt);
|
||||||
deliveryEndHour !== undefined && deliveryEndHour !== 24;
|
|
||||||
const wEnd = hasDeadline
|
|
||||||
? windowEndAt(timezone, deliveryEndHour!, fireAt)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 pt-2">
|
<div className="space-y-3 pt-2">
|
||||||
|
|||||||
@ -3,20 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { RunEtaPill } from "./run-eta-pill";
|
import { RunEtaPill } from "./run-eta-pill";
|
||||||
|
|
||||||
describe("RunEtaPill", () => {
|
describe("RunEtaPill", () => {
|
||||||
it("renders a neutral ETA when windowEndAt is omitted (no deadline)", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<RunEtaPill
|
|
||||||
targetCount={500}
|
|
||||||
fireAt={new Date("2026-05-13T09:00:00.000+08:00")}
|
|
||||||
timezone="Asia/Kuala_Lumpur"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(html).toContain('data-testid="eta-pill-neutral"');
|
|
||||||
expect(html).toMatch(/min/);
|
|
||||||
expect(html).not.toMatch(/Fits before deadline/);
|
|
||||||
expect(html).not.toMatch(/Likely to pause/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders nothing for zero targets", () => {
|
it("renders nothing for zero targets", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<RunEtaPill
|
<RunEtaPill
|
||||||
|
|||||||
@ -4,10 +4,7 @@ import { estimateRunDuration } from "@/lib/run-eta";
|
|||||||
interface RunEtaPillProps {
|
interface RunEtaPillProps {
|
||||||
targetCount: number;
|
targetCount: number;
|
||||||
fireAt: Date;
|
fireAt: Date;
|
||||||
/** Optional. When omitted (or when the operator picked "no
|
windowEndAt: Date;
|
||||||
* deadline"), the pill renders a neutral ETA without the
|
|
||||||
* green/amber fit indicator. */
|
|
||||||
windowEndAt?: Date;
|
|
||||||
timezone: string;
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +27,8 @@ export function RunEtaPill({
|
|||||||
targetCount,
|
targetCount,
|
||||||
fireAt,
|
fireAt,
|
||||||
});
|
});
|
||||||
|
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
|
||||||
|
|
||||||
const finishLocal = new Intl.DateTimeFormat("en-GB", {
|
const finishLocal = new Intl.DateTimeFormat("en-GB", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@ -37,23 +36,6 @@ export function RunEtaPill({
|
|||||||
timeZone: timezone,
|
timeZone: timezone,
|
||||||
}).format(estimatedFinishAt);
|
}).format(estimatedFinishAt);
|
||||||
|
|
||||||
// No deadline → neutral ETA, no green/amber comparison.
|
|
||||||
if (!windowEndAt) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground"
|
|
||||||
data-testid="eta-pill-neutral"
|
|
||||||
>
|
|
||||||
<ClockIcon className="size-3.5" />
|
|
||||||
<span>
|
|
||||||
~{durationMinutes} min · finishes ~{finishLocal}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
|
|
||||||
|
|
||||||
if (fits) {
|
if (fits) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">
|
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">
|
||||||
|
|||||||
@ -45,16 +45,8 @@ export function WhenFormClient({
|
|||||||
const [date, setDate] = useState(initial.date);
|
const [date, setDate] = useState(initial.date);
|
||||||
const [time, setTime] = useState(initial.time);
|
const [time, setTime] = useState(initial.time);
|
||||||
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE);
|
const [spec, setSpec] = useState<RecurrenceSpec>(initialSpec ?? DEFAULT_RECURRENCE);
|
||||||
// Deadline is optional. We model it as two states: a checkbox that
|
|
||||||
// turns it on/off, and the picked hour (only meaningful when the
|
|
||||||
// checkbox is on). 24 (next-day midnight) is the off-sentinel sent
|
|
||||||
// to the server — windowEndAt treats it as "end of today" so the
|
|
||||||
// bot's window-end gate effectively never trips for short runs.
|
|
||||||
const initialUseDeadline =
|
|
||||||
initialDeliveryEndHour !== undefined && initialDeliveryEndHour !== 24;
|
|
||||||
const [useDeadline, setUseDeadline] = useState<boolean>(initialUseDeadline);
|
|
||||||
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(
|
const [deliveryEndHour, setDeliveryEndHour] = useState<number>(
|
||||||
initialUseDeadline ? (initialDeliveryEndHour ?? 18) : 18,
|
initialDeliveryEndHour ?? 18,
|
||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -85,8 +77,7 @@ export function WhenFormClient({
|
|||||||
if (passThroughParams.name) sp.set("name", passThroughParams.name);
|
if (passThroughParams.name) sp.set("name", passThroughParams.name);
|
||||||
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
|
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
|
||||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||||
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
|
sp.set("deliveryEndHour", String(deliveryEndHour));
|
||||||
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||||
return;
|
return;
|
||||||
@ -130,8 +121,7 @@ export function WhenFormClient({
|
|||||||
if (passThroughParams.name) sp.set("name", passThroughParams.name);
|
if (passThroughParams.name) sp.set("name", passThroughParams.name);
|
||||||
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
|
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
|
||||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||||
// 24 = "no deadline" sentinel (windowEndAt → next-day midnight).
|
sp.set("deliveryEndHour", String(deliveryEndHour));
|
||||||
sp.set("deliveryEndHour", String(useDeadline ? deliveryEndHour : 24));
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||||
}
|
}
|
||||||
@ -178,32 +168,14 @@ export function WhenFormClient({
|
|||||||
|
|
||||||
{/* Deadline — fire time is the implicit start; this only sets when
|
{/* Deadline — fire time is the implicit start; this only sets when
|
||||||
the bot must stop. Long fan-outs that don't finish before the
|
the bot must stop. Long fan-outs that don't finish before the
|
||||||
deadline are paused so the operator can resume them later.
|
deadline are paused so the operator can resume them later. */}
|
||||||
The whole control is opt-in: tick the box to surface the hour
|
<div className="space-y-1.5">
|
||||||
picker, untick to remove the deadline entirely. */}
|
<Label className="flex items-center gap-1.5">
|
||||||
<div className="space-y-2">
|
|
||||||
<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" />
|
<ClockIcon className="size-3.5" />
|
||||||
Pause sending by
|
Pause sending by
|
||||||
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||||
</span>
|
</Label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{useDeadline ? "Set" : "Off"}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{useDeadline && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 pl-3">
|
|
||||||
<HourSelect
|
<HourSelect
|
||||||
ariaPrefix="Delivery deadline"
|
ariaPrefix="Delivery deadline"
|
||||||
value={deliveryEndHour}
|
value={deliveryEndHour}
|
||||||
@ -214,7 +186,6 @@ export function WhenFormClient({
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">({timezone})</span>
|
<span className="text-xs text-muted-foreground">({timezone})</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
// next/navigation is touched by useRouter — stub it.
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: () => ({ push: vi.fn() }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// next/link → transparent <a> so the markup we assert on stays simple.
|
|
||||||
vi.mock("next/link", () => ({
|
|
||||||
default: ({
|
|
||||||
href,
|
|
||||||
children,
|
|
||||||
...rest
|
|
||||||
}: { href: string; children: ReactNode } & Record<string, unknown>) => (
|
|
||||||
<a href={href} {...rest}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { WhenFormClient } from "./when-form-client";
|
|
||||||
|
|
||||||
const baseProps = {
|
|
||||||
accountId: "acc-1",
|
|
||||||
groupIds: "g-1",
|
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
|
||||||
initialDefaultIso: "2026-05-13T09:00:00.000+08:00",
|
|
||||||
passThroughParams: { name: "test", messages: "x" },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "Pause sending by" deadline is opt-in. The checkbox controls
|
|
||||||
* whether the HourSelect is rendered at all; when off, the form
|
|
||||||
* sends 24 (next-day midnight) to the server, which makes the bot's
|
|
||||||
* window-end gate effectively never trip. These tests lock in the
|
|
||||||
* SSR markup for the three states (off by default, off when the
|
|
||||||
* stored value is 24, on when the stored value is something else).
|
|
||||||
*/
|
|
||||||
describe("WhenFormClient — deadline checkbox", () => {
|
|
||||||
it("defaults to UNCHECKED for a fresh reminder (no initialDeliveryEndHour)", () => {
|
|
||||||
const html = renderToStaticMarkup(<WhenFormClient {...baseProps} />);
|
|
||||||
// Checkbox is rendered but not checked.
|
|
||||||
expect(html).toMatch(
|
|
||||||
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*>/,
|
|
||||||
);
|
|
||||||
expect(html).not.toMatch(
|
|
||||||
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
|
|
||||||
);
|
|
||||||
// No HourSelect rendered while the box is unchecked.
|
|
||||||
expect(html).not.toMatch(/aria-label="Delivery deadline hour"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts UNCHECKED when initialDeliveryEndHour is 24 (the off-sentinel)", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<WhenFormClient {...baseProps} initialDeliveryEndHour={24} />,
|
|
||||||
);
|
|
||||||
expect(html).not.toMatch(
|
|
||||||
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
|
|
||||||
);
|
|
||||||
expect(html).not.toMatch(/aria-label="Delivery deadline hour"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts CHECKED + reveals the hour picker when initialDeliveryEndHour is set to a real hour", () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<WhenFormClient {...baseProps} initialDeliveryEndHour={18} />,
|
|
||||||
);
|
|
||||||
// Checkbox is checked.
|
|
||||||
expect(html).toMatch(
|
|
||||||
/<input[^>]*type="checkbox"[^>]*aria-label="Set a delivery deadline"[^>]*checked/,
|
|
||||||
);
|
|
||||||
// The hour + period selects render under the checkbox.
|
|
||||||
expect(html).toMatch(/aria-label="Delivery deadline hour"/);
|
|
||||||
expect(html).toMatch(/aria-label="Delivery deadline period"/);
|
|
||||||
// Pre-selected hour matches the initial value (18 → 6 PM).
|
|
||||||
expect(html).toMatch(/value="6"\s+selected/);
|
|
||||||
expect(html).toMatch(/value="PM"\s+selected/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers a clear (optional) hint next to the label", () => {
|
|
||||||
const html = renderToStaticMarkup(<WhenFormClient {...baseProps} />);
|
|
||||||
expect(html).toContain("Pause sending by");
|
|
||||||
expect(html).toContain("(optional)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -67,11 +67,6 @@ export function SwipeableRow({
|
|||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
|
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
|
||||||
// Tracks whether the pointer crossed the click-vs-drag threshold during
|
|
||||||
// the current gesture. If it did, we swallow the synthetic click that
|
|
||||||
// browsers fire on pointerup — otherwise a swipe on a Link-wrapped row
|
|
||||||
// both swipes the shelf open AND navigates to the link target.
|
|
||||||
const dragMoved = useRef(false);
|
|
||||||
|
|
||||||
// Close the shelf when the user taps anywhere outside an open row.
|
// Close the shelf when the user taps anywhere outside an open row.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -97,17 +92,12 @@ export function SwipeableRow({
|
|||||||
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||||
dragStart.current = { x: e.clientX, baseOffset: offset };
|
dragStart.current = { x: e.clientX, baseOffset: offset };
|
||||||
dragMoved.current = false;
|
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
if (!dragging || !dragStart.current) return;
|
if (!dragging || !dragStart.current) return;
|
||||||
const dx = e.clientX - dragStart.current.x;
|
const dx = e.clientX - dragStart.current.x;
|
||||||
// 6 px is the standard threshold below which a touch counts as a tap
|
|
||||||
// rather than a drag. Cross it once and the gesture commits to drag
|
|
||||||
// for the rest of the pointer's lifetime.
|
|
||||||
if (Math.abs(dx) > 6) dragMoved.current = true;
|
|
||||||
setOffset(clamp(dragStart.current.baseOffset + dx));
|
setOffset(clamp(dragStart.current.baseOffset + dx));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,28 +113,6 @@ export function SwipeableRow({
|
|||||||
rightWidth,
|
rightWidth,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (dragMoved.current) {
|
|
||||||
// The browser fires a synthetic `click` on the element under the
|
|
||||||
// pointer right after pointerup. If our row body wraps a <Link>,
|
|
||||||
// that click navigates away. Add a one-shot capture-phase handler
|
|
||||||
// that swallows the next click ANYWHERE in the row container
|
|
||||||
// before it can reach the anchor's onClick.
|
|
||||||
const swallow = (ev: Event) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
};
|
|
||||||
const node = containerRef.current;
|
|
||||||
if (node) {
|
|
||||||
node.addEventListener("click", swallow, { capture: true, once: true });
|
|
||||||
// Defensive: if for some reason no click fires (e.g. pointerup
|
|
||||||
// outside the element), strip the listener after a tick so it
|
|
||||||
// doesn't accidentally eat a future legitimate click.
|
|
||||||
window.setTimeout(() => {
|
|
||||||
node.removeEventListener("click", swallow, { capture: true });
|
|
||||||
}, 350);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragMoved.current = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -182,14 +150,6 @@ export function SwipeableRow({
|
|||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerCancel={handlePointerUp}
|
onPointerCancel={handlePointerUp}
|
||||||
// Anchors (and <img>) are natively draggable. When children
|
|
||||||
// contain a <Link> wrapping the card, the browser hijacks the
|
|
||||||
// pointer for a "drag link" operation as soon as the user
|
|
||||||
// moves horizontally, so the swipe gesture never reaches our
|
|
||||||
// pointer handlers. Suppress native drag here once and the
|
|
||||||
// whole row body is unblocked.
|
|
||||||
onDragStart={(e) => e.preventDefault()}
|
|
||||||
draggable={false}
|
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${offset}px)`,
|
transform: `translateX(${offset}px)`,
|
||||||
transition: dragging ? "none" : "transform 200ms ease-out",
|
transition: dragging ? "none" : "transform 200ms ease-out",
|
||||||
|
|||||||
@ -8,25 +8,4 @@ const envSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
export const env = envSchema.parse(process.env);
|
||||||
// Lazy parse via Proxy. Next.js's `next build` does a
|
|
||||||
// "Collecting page data" pass that imports every route module —
|
|
||||||
// including api/events/route.ts which depends on this env. With a
|
|
||||||
// top-level `envSchema.parse(process.env)` the parse ran during
|
|
||||||
// the build container, where DATABASE_URL isn't (and shouldn't be)
|
|
||||||
// set, and Zod aborted the build with:
|
|
||||||
// ZodError: DATABASE_URL: Required
|
|
||||||
// Deferring the parse until first property access lets the build
|
|
||||||
// finish (no consumer accesses env during page-data collection)
|
|
||||||
// while still failing loudly at runtime if the var is missing.
|
|
||||||
let cached: Env | null = null;
|
|
||||||
function read(): Env {
|
|
||||||
if (cached) return cached;
|
|
||||||
cached = envSchema.parse(process.env);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
export const env: Env = new Proxy({} as Env, {
|
|
||||||
get(_t, prop) {
|
|
||||||
return read()[prop as keyof Env];
|
|
||||||
},
|
|
||||||
}) as Env;
|
|
||||||
|
|||||||
@ -9,19 +9,8 @@ export type WebEventMap = {
|
|||||||
"session.connected": { accountId: string; phoneNumber: string | null };
|
"session.connected": { accountId: string; phoneNumber: string | null };
|
||||||
"session.disconnected": { accountId: string };
|
"session.disconnected": { accountId: string };
|
||||||
"session.timeout": { accountId: string };
|
"session.timeout": { accountId: string };
|
||||||
"session.duplicate": {
|
|
||||||
accountId: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
existingLabel: string;
|
|
||||||
};
|
|
||||||
"groups.synced": { accountId: string; count: number };
|
"groups.synced": { accountId: string; count: number };
|
||||||
"reminder.fired": {
|
"reminder.fired": { reminderId: string; runId: string; status: string };
|
||||||
reminderId: string;
|
|
||||||
runId: string;
|
|
||||||
status: string;
|
|
||||||
sent?: number;
|
|
||||||
total?: number;
|
|
||||||
};
|
|
||||||
"reminder.failed": { reminderId: string; error: string };
|
"reminder.failed": { reminderId: string; error: string };
|
||||||
"send_test.done": { groupId: string; ok: boolean; error: string | null };
|
"send_test.done": { groupId: string; ok: boolean; error: string | null };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,135 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll } from "vitest";
|
|
||||||
import {
|
|
||||||
signSession,
|
|
||||||
verifySession,
|
|
||||||
COOKIE_NAME,
|
|
||||||
DEFAULT_TTL_SECONDS,
|
|
||||||
type SessionPayload,
|
|
||||||
} from "./auth-cookie";
|
|
||||||
|
|
||||||
const SECRET = "test-secret-not-used-anywhere-real";
|
|
||||||
const NOW = 1_700_000_000; // 2023-11-14 — fixed clock for determinism
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.AUTH_SECRET = SECRET;
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
||||||
});
|
|
||||||
|
|
||||||
const validPayload = (): SessionPayload => ({
|
|
||||||
userId: "11111111-1111-1111-1111-111111111111",
|
|
||||||
role: "admin",
|
|
||||||
iat: NOW,
|
|
||||||
exp: NOW + DEFAULT_TTL_SECONDS,
|
|
||||||
v: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("auth-cookie (AES-256-GCM)", () => {
|
|
||||||
it("signSession + verifySession round-trips a valid payload", async () => {
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
const verified = await verifySession(cookie, SECRET, NOW);
|
|
||||||
expect(verified).toEqual(validPayload());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a fresh IV per call so two encryptions of the same payload differ", async () => {
|
|
||||||
// Repeating the IV under AES-GCM is catastrophic (it leaks the XOR
|
|
||||||
// of plaintexts and the auth key). Lock in that signSession draws
|
|
||||||
// a new nonce every time — the byte-for-byte cookies must not match
|
|
||||||
// even when the inputs are identical.
|
|
||||||
const a = await signSession(validPayload(), SECRET);
|
|
||||||
const b = await signSession(validPayload(), SECRET);
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
// Both still decrypt correctly with the same secret.
|
|
||||||
expect(await verifySession(a, SECRET, NOW)).toEqual(validPayload());
|
|
||||||
expect(await verifySession(b, SECRET, NOW)).toEqual(validPayload());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ciphertext does NOT leak the userId in plaintext (confidentiality)", async () => {
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
// The whole point of the GCM upgrade: someone with only the cookie
|
|
||||||
// value should not be able to read the userId / role straight off
|
|
||||||
// it the way they could with the old base64-encoded JSON.
|
|
||||||
expect(cookie).not.toContain(validPayload().userId);
|
|
||||||
expect(cookie).not.toContain("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when the ciphertext has been tampered with (auth tag mismatch)", async () => {
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
const [iv, ct] = cookie.split(".");
|
|
||||||
// Flip the last character of the ciphertext (still valid base64url).
|
|
||||||
const lastCh = ct!.slice(-1);
|
|
||||||
const replacement = lastCh === "A" ? "B" : "A";
|
|
||||||
const tampered = `${iv}.${ct!.slice(0, -1)}${replacement}`;
|
|
||||||
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when the IV has been swapped for another (auth tag mismatch)", async () => {
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
const otherIv = await signSession(validPayload(), SECRET);
|
|
||||||
const [, ct] = cookie.split(".");
|
|
||||||
const [otherIvB64] = otherIv.split(".");
|
|
||||||
const tampered = `${otherIvB64}.${ct}`;
|
|
||||||
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when verified with a different secret", async () => {
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
expect(await verifySession(cookie, "different-secret", NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an expired cookie (exp <= now)", async () => {
|
|
||||||
const expired = { ...validPayload(), exp: NOW - 1 };
|
|
||||||
const cookie = await signSession(expired, SECRET);
|
|
||||||
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cookie issued in the future beyond clock-skew tolerance", async () => {
|
|
||||||
const future = { ...validPayload(), iat: NOW + 120 };
|
|
||||||
const cookie = await signSession(future, SECRET);
|
|
||||||
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a cookie issued slightly in the future (within 60s skew)", async () => {
|
|
||||||
const future = { ...validPayload(), iat: NOW + 30 };
|
|
||||||
const cookie = await signSession(future, SECRET);
|
|
||||||
expect(await verifySession(cookie, SECRET, NOW)).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cookie whose v is stale (token-version bumped)", async () => {
|
|
||||||
const cookie = await signSession({ ...validPayload(), v: 1 }, SECRET);
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "2";
|
|
||||||
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cookie with an unknown role string", async () => {
|
|
||||||
const cookie = await signSession(
|
|
||||||
{ ...validPayload(), role: "superadmin" as never },
|
|
||||||
SECRET,
|
|
||||||
);
|
|
||||||
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cookie that doesn't have a '.' separator", async () => {
|
|
||||||
expect(await verifySession("not-a-cookie", SECRET, NOW)).toBeNull();
|
|
||||||
expect(await verifySession("", SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cookie whose IV decodes to the wrong byte length", async () => {
|
|
||||||
// GCM requires a 12-byte nonce. Swap the IV portion for something
|
|
||||||
// that decodes to a different length and confirm we bounce it
|
|
||||||
// before handing weird input to crypto.subtle.decrypt.
|
|
||||||
const cookie = await signSession(validPayload(), SECRET);
|
|
||||||
const [, ct] = cookie.split(".");
|
|
||||||
// 8 bytes encoded — too short.
|
|
||||||
const shortIv = "AAAAAAAAAAA";
|
|
||||||
expect(await verifySession(`${shortIv}.${ct}`, SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects malformed (non-base64url) input gracefully — no throw, just null", async () => {
|
|
||||||
expect(await verifySession("$$$.$$$", SECRET, NOW)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes COOKIE_NAME as 'session'", () => {
|
|
||||||
expect(COOKIE_NAME).toBe("session");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
/**
|
|
||||||
* Edge-runtime-safe AES-256-GCM session cookie. Runs in middleware
|
|
||||||
* and Server Actions. NO database, NO bcrypt, NO Node-only APIs —
|
|
||||||
* pure Web Crypto so it survives Edge runtime.
|
|
||||||
*
|
|
||||||
* Why GCM instead of HMAC-then-plaintext: GCM is authenticated
|
|
||||||
* encryption, so a leaked cookie no longer hands the userId/role to
|
|
||||||
* an attacker who only sees the bytes. Tampering with either the IV
|
|
||||||
* or the ciphertext invalidates the auth tag → decrypt throws → we
|
|
||||||
* return null. Replay protection comes from the per-payload `exp`
|
|
||||||
* field plus the global `OPERATOR_TOKEN_VERSION` kill switch.
|
|
||||||
*
|
|
||||||
* Cookie format: `<base64url(iv)>.<base64url(ciphertext+tag)>`
|
|
||||||
* - iv: 12 random bytes (GCM nonce)
|
|
||||||
* - ciphertext+tag: AES-GCM output (plaintext + 16-byte auth tag)
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const COOKIE_NAME = "session";
|
|
||||||
export const DEFAULT_TTL_SECONDS = 30 * 86400; // 30 days
|
|
||||||
export const CLOCK_SKEW_SECONDS = 60;
|
|
||||||
|
|
||||||
export type Role = "admin" | "user";
|
|
||||||
|
|
||||||
export interface SessionPayload {
|
|
||||||
userId: string;
|
|
||||||
role: Role;
|
|
||||||
iat: number;
|
|
||||||
exp: number;
|
|
||||||
v: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidPayload(x: unknown): x is SessionPayload {
|
|
||||||
if (typeof x !== "object" || x === null) return false;
|
|
||||||
const o = x as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
typeof o.userId === "string" &&
|
|
||||||
(o.role === "admin" || o.role === "user") &&
|
|
||||||
typeof o.iat === "number" &&
|
|
||||||
typeof o.exp === "number" &&
|
|
||||||
typeof o.v === "number"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function b64urlEncode(bytes: Uint8Array): string {
|
|
||||||
let s = "";
|
|
||||||
for (const b of bytes) s += String.fromCharCode(b);
|
|
||||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function b64urlDecode(str: string): Uint8Array {
|
|
||||||
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
|
|
||||||
const s = atob(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
||||||
const out = new Uint8Array(s.length);
|
|
||||||
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a 256-bit AES key from the operator-supplied AUTH_SECRET.
|
|
||||||
* SHA-256 hashes the secret to a fixed-length key so the secret can
|
|
||||||
* be any printable string in env (no min/max length policing here).
|
|
||||||
*/
|
|
||||||
async function deriveKey(secret: string): Promise<CryptoKey> {
|
|
||||||
const digest = await crypto.subtle.digest(
|
|
||||||
"SHA-256",
|
|
||||||
new TextEncoder().encode(secret),
|
|
||||||
);
|
|
||||||
return crypto.subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
digest,
|
|
||||||
{ name: "AES-GCM" },
|
|
||||||
false,
|
|
||||||
["encrypt", "decrypt"],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signSession(
|
|
||||||
payload: SessionPayload,
|
|
||||||
secret: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
const key = await deriveKey(secret);
|
|
||||||
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
||||||
const ct = new Uint8Array(
|
|
||||||
await crypto.subtle.encrypt(
|
|
||||||
{ name: "AES-GCM", iv: iv as BufferSource },
|
|
||||||
key,
|
|
||||||
plaintext as BufferSource,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return `${b64urlEncode(iv)}.${b64urlEncode(ct)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifySession(
|
|
||||||
cookie: string,
|
|
||||||
secret: string,
|
|
||||||
now: number = Math.floor(Date.now() / 1000),
|
|
||||||
): Promise<SessionPayload | null> {
|
|
||||||
if (!cookie || typeof cookie !== "string") return null;
|
|
||||||
const dot = cookie.indexOf(".");
|
|
||||||
if (dot <= 0 || dot === cookie.length - 1) return null;
|
|
||||||
let iv: Uint8Array;
|
|
||||||
let ct: Uint8Array;
|
|
||||||
try {
|
|
||||||
iv = b64urlDecode(cookie.slice(0, dot));
|
|
||||||
ct = b64urlDecode(cookie.slice(dot + 1));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// GCM nonces are exactly 12 bytes. A wrong-sized IV would still
|
|
||||||
// sometimes succeed at the WebCrypto layer on some platforms;
|
|
||||||
// guard explicitly so callers can't slip a non-standard nonce past us.
|
|
||||||
if (iv.length !== 12) return null;
|
|
||||||
let plain: string;
|
|
||||||
try {
|
|
||||||
const key = await deriveKey(secret);
|
|
||||||
// The IV in `AesGcmParams` must be backed by a non-shared
|
|
||||||
// ArrayBuffer; TS 5.7+ tightened Uint8Array's generic to reject
|
|
||||||
// `SharedArrayBuffer`-backed views. b64urlDecode produces a
|
|
||||||
// regular ArrayBuffer, but we cast to BufferSource explicitly so
|
|
||||||
// future allocator changes don't regress this site.
|
|
||||||
const buf = await crypto.subtle.decrypt(
|
|
||||||
{ name: "AES-GCM", iv: iv as BufferSource },
|
|
||||||
key,
|
|
||||||
ct as BufferSource,
|
|
||||||
);
|
|
||||||
plain = new TextDecoder().decode(buf);
|
|
||||||
} catch {
|
|
||||||
// Auth-tag mismatch (tampered cookie or wrong key) lands here.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(plain);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!isValidPayload(parsed)) return null;
|
|
||||||
|
|
||||||
if (parsed.exp <= now) return null;
|
|
||||||
if (parsed.iat > now + CLOCK_SKEW_SECONDS) return null;
|
|
||||||
|
|
||||||
const expectedV = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
|
|
||||||
if (parsed.v !== expectedV) return null;
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
const cookiesGetMock = vi.fn();
|
|
||||||
const findUserMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("next/headers", () => ({
|
|
||||||
cookies: async () => ({ get: cookiesGetMock }),
|
|
||||||
}));
|
|
||||||
vi.mock("./db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
operators: {
|
|
||||||
findFirst: (...a: unknown[]) => findUserMock(...a),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const SECRET = "test-secret";
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.AUTH_SECRET = SECRET;
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
||||||
cookiesGetMock.mockReset();
|
|
||||||
findUserMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
import { signSession } from "./auth-cookie";
|
|
||||||
import { getCurrentUser, requireUser, requireAdmin } from "./auth";
|
|
||||||
|
|
||||||
const NOW_S = Math.floor(Date.now() / 1000);
|
|
||||||
const ADMIN = {
|
|
||||||
id: "11111111-1111-1111-1111-111111111111",
|
|
||||||
username: "admin",
|
|
||||||
role: "admin" as const,
|
|
||||||
displayName: "Admin",
|
|
||||||
defaultTimezone: "UTC",
|
|
||||||
passwordHash: null,
|
|
||||||
};
|
|
||||||
const USER = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice", role: "user" as const };
|
|
||||||
|
|
||||||
async function makeCookie(role: "admin" | "user"): Promise<string> {
|
|
||||||
return signSession(
|
|
||||||
{
|
|
||||||
userId: role === "admin" ? ADMIN.id : USER.id,
|
|
||||||
role,
|
|
||||||
iat: NOW_S,
|
|
||||||
exp: NOW_S + 3600,
|
|
||||||
v: 1,
|
|
||||||
},
|
|
||||||
SECRET,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("auth helpers", () => {
|
|
||||||
it("getCurrentUser returns null when no cookie is set", async () => {
|
|
||||||
cookiesGetMock.mockReturnValue(undefined);
|
|
||||||
const u = await getCurrentUser();
|
|
||||||
expect(u).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getCurrentUser returns the user row for a valid admin cookie", async () => {
|
|
||||||
const cookie = await makeCookie("admin");
|
|
||||||
cookiesGetMock.mockReturnValue({ value: cookie });
|
|
||||||
findUserMock.mockResolvedValue(ADMIN);
|
|
||||||
const u = await getCurrentUser();
|
|
||||||
expect(u?.id).toBe(ADMIN.id);
|
|
||||||
expect(u?.role).toBe("admin");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requireUser throws when there is no session", async () => {
|
|
||||||
cookiesGetMock.mockReturnValue(undefined);
|
|
||||||
await expect(requireUser()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requireAdmin throws when role is 'user'", async () => {
|
|
||||||
const cookie = await makeCookie("user");
|
|
||||||
cookiesGetMock.mockReturnValue({ value: cookie });
|
|
||||||
findUserMock.mockResolvedValue(USER);
|
|
||||||
await expect(requireAdmin()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requireAdmin returns the user when role is 'admin'", async () => {
|
|
||||||
const cookie = await makeCookie("admin");
|
|
||||||
cookiesGetMock.mockReturnValue({ value: cookie });
|
|
||||||
findUserMock.mockResolvedValue(ADMIN);
|
|
||||||
const u = await requireAdmin();
|
|
||||||
expect(u.role).toBe("admin");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { db } from "./db";
|
|
||||||
import { COOKIE_NAME, verifySession } from "./auth-cookie";
|
|
||||||
|
|
||||||
export type AuthUser = {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
role: "admin" | "user";
|
|
||||||
displayName: string;
|
|
||||||
defaultTimezone: string;
|
|
||||||
passwordHash: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class UnauthenticatedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Unauthenticated");
|
|
||||||
this.name = "UnauthenticatedError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export class ForbiddenError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Forbidden");
|
|
||||||
this.name = "ForbiddenError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the operator row whose userId is encoded in the session
|
|
||||||
* cookie, or null if the cookie is missing / invalid / the row is
|
|
||||||
* gone. Never throws — call requireUser() if you want a throw.
|
|
||||||
*/
|
|
||||||
export async function getCurrentUser(): Promise<AuthUser | null> {
|
|
||||||
const jar = await cookies();
|
|
||||||
const cookie = jar.get(COOKIE_NAME)?.value;
|
|
||||||
if (!cookie) return null;
|
|
||||||
const secret = process.env.AUTH_SECRET;
|
|
||||||
if (!secret) return null;
|
|
||||||
const payload = await verifySession(cookie, secret);
|
|
||||||
if (!payload) return null;
|
|
||||||
const row = await db.query.operators.findFirst({
|
|
||||||
where: (o, { eq }) => eq(o.id, payload.userId),
|
|
||||||
});
|
|
||||||
if (!row) return null;
|
|
||||||
if (row.role !== "admin" && row.role !== "user") return null;
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
role: row.role,
|
|
||||||
displayName: row.displayName,
|
|
||||||
defaultTimezone: row.defaultTimezone,
|
|
||||||
passwordHash: row.passwordHash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireUser(): Promise<AuthUser> {
|
|
||||||
const u = await getCurrentUser();
|
|
||||||
if (!u) throw new UnauthenticatedError();
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireAdmin(): Promise<AuthUser> {
|
|
||||||
const u = await requireUser();
|
|
||||||
if (u.role !== "admin") throw new ForbiddenError();
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static guard: listAccounts() in apps/web/src/lib/queries.ts MUST
|
|
||||||
* order rows by createdAt ascending (with id as a deterministic
|
|
||||||
* tiebreaker) so the operator's earliest-added account stays on top.
|
|
||||||
*
|
|
||||||
* Earlier the orderBy used `asc(a.label)` which silently re-shuffled
|
|
||||||
* the list every time an account was renamed. This test pins the
|
|
||||||
* fix in source so a future refactor can't quietly bring the rename
|
|
||||||
* regression back.
|
|
||||||
*
|
|
||||||
* It's a static (regex) guard rather than an integration test
|
|
||||||
* because the live query needs Postgres + a seeded operator;
|
|
||||||
* pinning the source spelling keeps coverage cheap and CI-friendly.
|
|
||||||
*/
|
|
||||||
describe("listAccounts ordering (regression guard)", () => {
|
|
||||||
const src = readFileSync(
|
|
||||||
join(__dirname, "queries.ts"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
it("orders by created_at ASC", () => {
|
|
||||||
// Match across whitespace/comments inside listAccounts. Anchors:
|
|
||||||
// function header → orderBy → asc(a.createdAt).
|
|
||||||
const fnStart = src.indexOf("export async function listAccounts(");
|
|
||||||
expect(fnStart).toBeGreaterThan(-1);
|
|
||||||
const fnEnd = src.indexOf("export async function ", fnStart + 1);
|
|
||||||
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
|
|
||||||
expect(fnBody).toMatch(/orderBy:[\s\S]*asc\(a\.createdAt\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses id as a deterministic tiebreaker for ties on createdAt", () => {
|
|
||||||
const fnStart = src.indexOf("export async function listAccounts(");
|
|
||||||
const fnEnd = src.indexOf("export async function ", fnStart + 1);
|
|
||||||
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
|
|
||||||
expect(fnBody).toMatch(/asc\(a\.id\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT order by label (the regression we're guarding against)", () => {
|
|
||||||
const fnStart = src.indexOf("export async function listAccounts(");
|
|
||||||
const fnEnd = src.indexOf("export async function ", fnStart + 1);
|
|
||||||
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
|
|
||||||
expect(fnBody).not.toMatch(/asc\(a\.label\)/);
|
|
||||||
expect(fnBody).not.toMatch(/desc\(a\.label\)/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -240,44 +240,6 @@ describe("reminderFiredToNotification mapping", () => {
|
|||||||
expect(args).toBeNull();
|
expect(args).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders 'paused' with the resume/cancel call-to-action and sent/total", () => {
|
|
||||||
const args = reminderFiredToNotification({
|
|
||||||
type: "reminder.fired",
|
|
||||||
reminderId: "r-p",
|
|
||||||
runId: "run-p",
|
|
||||||
status: "paused",
|
|
||||||
sent: 412,
|
|
||||||
total: 1000,
|
|
||||||
});
|
|
||||||
expect(args?.title).toBe("Reminder paused");
|
|
||||||
expect(args?.body).toBe("412 of 1000 groups delivered. Tap to resume or cancel.");
|
|
||||||
expect(args?.tag).toBe("reminder:r-p");
|
|
||||||
expect(args?.href).toBe("/reminders/r-p");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders 'paused' without sent/total with a generic body", () => {
|
|
||||||
const args = reminderFiredToNotification({
|
|
||||||
type: "reminder.fired",
|
|
||||||
reminderId: "r-p",
|
|
||||||
runId: "run-p",
|
|
||||||
status: "paused",
|
|
||||||
});
|
|
||||||
expect(args?.title).toBe("Reminder paused");
|
|
||||||
expect(args?.body).toMatch(/Delivery window closed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders 'partial' with sent/total → 'X of Y groups delivered'", () => {
|
|
||||||
const args = reminderFiredToNotification({
|
|
||||||
type: "reminder.fired",
|
|
||||||
reminderId: "r-2",
|
|
||||||
runId: "run-2",
|
|
||||||
status: "partial",
|
|
||||||
sent: 87,
|
|
||||||
total: 100,
|
|
||||||
});
|
|
||||||
expect(args?.body).toBe("87 of 100 groups delivered. See activity for details.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the same tag for repeat fires of the same reminder so they coalesce", () => {
|
it("uses the same tag for repeat fires of the same reminder so they coalesce", () => {
|
||||||
const a = reminderFiredToNotification({
|
const a = reminderFiredToNotification({
|
||||||
type: "reminder.fired",
|
type: "reminder.fired",
|
||||||
|
|||||||
@ -138,35 +138,20 @@ export function reminderFiredToNotification(event: {
|
|||||||
reminderId: string;
|
reminderId: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
status: string;
|
status: string;
|
||||||
sent?: number;
|
|
||||||
total?: number;
|
|
||||||
}): ShowNotificationOptions | null {
|
}): ShowNotificationOptions | null {
|
||||||
if (event.status === "skipped") return null;
|
if (event.status === "skipped") return null;
|
||||||
const headline =
|
const headline =
|
||||||
event.status === "success"
|
event.status === "success"
|
||||||
? "Reminder sent"
|
? "Reminder sent"
|
||||||
: event.status === "paused"
|
|
||||||
? "Reminder paused"
|
|
||||||
: event.status === "partial"
|
: event.status === "partial"
|
||||||
? "Reminder partly sent"
|
? "Reminder partly sent"
|
||||||
: "Reminder failed";
|
: "Reminder failed";
|
||||||
let body =
|
const body =
|
||||||
event.status === "success"
|
event.status === "success"
|
||||||
? "All groups received the message."
|
? "All groups received the message."
|
||||||
: event.status === "paused"
|
|
||||||
? "Delivery window closed before all groups got the message."
|
|
||||||
: event.status === "partial"
|
: event.status === "partial"
|
||||||
? "Some groups received the message; others failed. See activity."
|
? "Some groups received the message; others failed. See activity."
|
||||||
: "No groups received the message. See activity.";
|
: "No groups received the message. See activity.";
|
||||||
if (event.status === "paused" && event.sent !== undefined && event.total !== undefined) {
|
|
||||||
body = `${event.sent} of ${event.total} groups delivered. Tap to resume or cancel.`;
|
|
||||||
} else if (
|
|
||||||
event.status === "partial" &&
|
|
||||||
event.sent !== undefined &&
|
|
||||||
event.total !== undefined
|
|
||||||
) {
|
|
||||||
body = `${event.sent} of ${event.total} groups delivered. See activity for details.`;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
title: headline,
|
title: headline,
|
||||||
body,
|
body,
|
||||||
|
|||||||
@ -5,14 +5,9 @@ import { db } from "./db";
|
|||||||
export type BotCommand =
|
export type BotCommand =
|
||||||
| { type: "account.start_pairing"; accountId: string }
|
| { type: "account.start_pairing"; accountId: string }
|
||||||
| { type: "account.unpair"; accountId: string }
|
| { type: "account.unpair"; accountId: string }
|
||||||
// Like account.unpair, but the bot also calls socket.logout() so
|
|
||||||
// WhatsApp drops this device from the operator's linked-devices
|
|
||||||
// list before the row is deleted.
|
|
||||||
| { type: "account.delete"; accountId: string }
|
|
||||||
| { type: "account.sync_groups"; accountId: string }
|
| { type: "account.sync_groups"; accountId: string }
|
||||||
| { type: "group.send_test"; groupId: string; text: string }
|
| { type: "group.send_test"; groupId: string; text: string }
|
||||||
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
|
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string };
|
||||||
| { type: "reminder.resume"; reminderId: string; runId: string };
|
|
||||||
|
|
||||||
export async function pgNotifyBot(cmd: BotCommand): Promise<void> {
|
export async function pgNotifyBot(cmd: BotCommand): Promise<void> {
|
||||||
const json = JSON.stringify(cmd);
|
const json = JSON.stringify(cmd);
|
||||||
|
|||||||
@ -1,21 +1,16 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { getCurrentUser } from "./auth";
|
import { db } from "./db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compatibility shim. The app used to seed a single operator and
|
* Returns the single seeded operator row. Since the app has no auth,
|
||||||
* attribute everything to it; now we have real auth + roles. Existing
|
* every action is attributed to this operator.
|
||||||
* call sites read `.id` and `.defaultTimezone` off the returned
|
|
||||||
* object — both are still present on the AuthUser shape, so the
|
|
||||||
* swap is mechanical and existing tests that mock @/lib/operator
|
|
||||||
* keep working unchanged.
|
|
||||||
*
|
|
||||||
* New code should call getCurrentUser / requireUser / requireAdmin
|
|
||||||
* from @/lib/auth directly.
|
|
||||||
*/
|
*/
|
||||||
export async function getSeededOperator() {
|
export async function getSeededOperator() {
|
||||||
const u = await getCurrentUser();
|
const op = await db.query.operators.findFirst({
|
||||||
if (!u) {
|
orderBy: (o, { asc }) => [asc(o.createdAt)],
|
||||||
throw new Error("Not authenticated");
|
});
|
||||||
|
if (!op) {
|
||||||
|
throw new Error("No operator row seeded. Run scripts/db.sh seed.");
|
||||||
}
|
}
|
||||||
return u;
|
return op;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
validatePassword,
|
|
||||||
MIN_PASSWORD_LEN,
|
|
||||||
MAX_PASSWORD_LEN,
|
|
||||||
} from "./password-policy";
|
|
||||||
|
|
||||||
describe("validatePassword", () => {
|
|
||||||
it("accepts the canonical mixed-case + digit example", () => {
|
|
||||||
expect(validatePassword("hengs3rver").ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts the bare minimum length with a number", () => {
|
|
||||||
// 6 chars, letter + digit. Boundary case for MIN_PASSWORD_LEN.
|
|
||||||
expect(validatePassword("abc12!").ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts symbols in place of digits", () => {
|
|
||||||
expect(validatePassword("abcde!").ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects passwords shorter than the minimum", () => {
|
|
||||||
const r = validatePassword("ab1!");
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/at least 6/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects letters-only passwords", () => {
|
|
||||||
const r = validatePassword("abcdefgh");
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/letters with numbers or symbols/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects digits-only passwords", () => {
|
|
||||||
const r = validatePassword("12345678");
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/letters/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects symbols-only passwords (no letters)", () => {
|
|
||||||
const r = validatePassword("!!!!!!!!");
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects passwords longer than MAX_PASSWORD_LEN", () => {
|
|
||||||
const tooLong = "a".repeat(MAX_PASSWORD_LEN) + "1";
|
|
||||||
const r = validatePassword(tooLong);
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) expect(r.error).toMatch(/too long/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects empty input", () => {
|
|
||||||
expect(validatePassword("").ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-string input defensively", () => {
|
|
||||||
// Server actions are typed but a malformed FormData payload could land
|
|
||||||
// here as null/undefined; the validator must not throw.
|
|
||||||
// @ts-expect-error - defensive runtime guard
|
|
||||||
expect(validatePassword(null).ok).toBe(false);
|
|
||||||
// @ts-expect-error - defensive runtime guard
|
|
||||||
expect(validatePassword(undefined).ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes the documented Facebook-aligned thresholds", () => {
|
|
||||||
expect(MIN_PASSWORD_LEN).toBe(6);
|
|
||||||
expect(MAX_PASSWORD_LEN).toBe(256);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Password policy modeled after Facebook's documented requirement
|
|
||||||
* (https://www.facebook.com/help/124904560921566): at least 6
|
|
||||||
* characters, with a recommended mix of letters and numbers/punctuation.
|
|
||||||
*
|
|
||||||
* We enforce the hard minimum (6) and the recommended-mix rule on
|
|
||||||
* password creation/reset (admin-only flows). Sign-in itself stays
|
|
||||||
* permissive — old short passwords keep working until they're reset —
|
|
||||||
* since rejecting them at login would lock people out without a recovery
|
|
||||||
* path.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const MIN_PASSWORD_LEN = 6;
|
|
||||||
export const MAX_PASSWORD_LEN = 256;
|
|
||||||
|
|
||||||
export type PasswordCheck = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
export function validatePassword(pw: string): PasswordCheck {
|
|
||||||
if (typeof pw !== "string" || pw.length < MIN_PASSWORD_LEN) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: `Password must be at least ${MIN_PASSWORD_LEN} characters.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (pw.length > MAX_PASSWORD_LEN) {
|
|
||||||
return { ok: false, error: "Password is too long." };
|
|
||||||
}
|
|
||||||
const hasLetter = /[A-Za-z]/.test(pw);
|
|
||||||
const hasNonLetter = /[^A-Za-z]/.test(pw);
|
|
||||||
if (!hasLetter || !hasNonLetter) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: "Password must mix letters with numbers or symbols.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
@ -6,18 +6,9 @@ export async function getDashboardStats(operatorId: string) {
|
|||||||
const accounts = await db.query.whatsappAccounts.findMany({
|
const accounts = await db.query.whatsappAccounts.findMany({
|
||||||
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||||
});
|
});
|
||||||
// Reminders scoped to this operator's accounts. The previous
|
// All reminder rows so the dashboard can show active/total in one query.
|
||||||
// findMany() with no filter leaked global counts across users — a
|
// Status enum today is active / ended (paused will join in a later phase).
|
||||||
// brand-new user would see another operator's totals on the
|
const allReminders = await db.query.reminders.findMany();
|
||||||
// dashboard. INNER JOIN on whatsapp_accounts.operator_id keeps each
|
|
||||||
// user's view isolated.
|
|
||||||
const reminderRows = await db.execute(sql`
|
|
||||||
SELECT r.id, r.status
|
|
||||||
FROM reminders r
|
|
||||||
INNER JOIN whatsapp_accounts wa ON wa.id = r.account_id
|
|
||||||
WHERE wa.operator_id = ${operatorId}
|
|
||||||
`);
|
|
||||||
const allReminders = reminderRows.rows as Array<{ id: string; status: string }>;
|
|
||||||
// LEFT JOIN so runs whose reminder has been deleted still appear. The
|
// LEFT JOIN so runs whose reminder has been deleted still appear. The
|
||||||
// ownership filter widens to: either the reminder still exists and the
|
// ownership filter widens to: either the reminder still exists and the
|
||||||
// operator owns its account, OR the reminder is gone but the run row
|
// operator owns its account, OR the reminder is gone but the run row
|
||||||
@ -43,7 +34,7 @@ export async function getDashboardStats(operatorId: string) {
|
|||||||
totalAccounts: accounts.length,
|
totalAccounts: accounts.length,
|
||||||
activeReminders: allReminders.filter((r) => r.status === "active").length,
|
activeReminders: allReminders.filter((r) => r.status === "active").length,
|
||||||
pausedReminders: allReminders.filter((r) => r.status === "paused").length,
|
pausedReminders: allReminders.filter((r) => r.status === "paused").length,
|
||||||
inactiveReminders: allReminders.filter((r) => r.status === "inactive").length,
|
endedReminders: allReminders.filter((r) => r.status === "ended").length,
|
||||||
totalReminders: allReminders.length,
|
totalReminders: allReminders.length,
|
||||||
recentRuns: recentRuns.rows as Array<{
|
recentRuns: recentRuns.rows as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@ -63,12 +54,9 @@ export async function listAccounts(operatorId: string) {
|
|||||||
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
|
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
|
||||||
// by status produced phantom "I created an account but it's gone"
|
// by status produced phantom "I created an account but it's gone"
|
||||||
// bug reports.
|
// bug reports.
|
||||||
// Earliest-added on top, newest at the bottom. Stable across renames
|
|
||||||
// (a label edit shouldn't reorder the list and confuse muscle memory)
|
|
||||||
// and matches how other admin tools order accounts that grow over time.
|
|
||||||
return db.query.whatsappAccounts.findMany({
|
return db.query.whatsappAccounts.findMany({
|
||||||
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||||
orderBy: (a, { asc }) => [asc(a.createdAt), asc(a.id)],
|
orderBy: (a, { asc }) => [asc(a.label)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,19 +70,11 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
|
|||||||
const account = await getAccount(operatorId, accountId);
|
const account = await getAccount(operatorId, accountId);
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
const trimmed = (q ?? "").trim();
|
const trimmed = (q ?? "").trim();
|
||||||
// Hide archived groups from the picker by default. They're rows
|
|
||||||
// that disappeared from the live participant list (group deleted,
|
|
||||||
// bot kicked, etc.) but still have reminder_targets pointing at
|
|
||||||
// them — see the soft-archive flow in apps/bot/src/whatsapp/
|
|
||||||
// group-sync.ts. Surfacing archived rows here would let an
|
|
||||||
// operator pick a group the bot can't actually reach.
|
|
||||||
const rows = trimmed
|
const rows = trimmed
|
||||||
? await db.execute(sql`
|
? await db.execute(sql`
|
||||||
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
||||||
FROM whatsapp_groups
|
FROM whatsapp_groups
|
||||||
WHERE account_id = ${accountId}
|
WHERE account_id = ${accountId} AND name % ${trimmed}
|
||||||
AND is_archived = false
|
|
||||||
AND name % ${trimmed}
|
|
||||||
ORDER BY similarity(name, ${trimmed}) DESC
|
ORDER BY similarity(name, ${trimmed}) DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
`)
|
`)
|
||||||
@ -102,7 +82,6 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
|
|||||||
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
|
||||||
FROM whatsapp_groups
|
FROM whatsapp_groups
|
||||||
WHERE account_id = ${accountId}
|
WHERE account_id = ${accountId}
|
||||||
AND is_archived = false
|
|
||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`);
|
`);
|
||||||
@ -208,13 +187,11 @@ export async function listActivityRuns(
|
|||||||
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
|
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
|
||||||
// has been deleted but history was preserved) in the list.
|
// has been deleted but history was preserved) in the list.
|
||||||
// The `archived` flag flips the visibility filter:
|
// The `archived` flag flips the visibility filter:
|
||||||
// false (default) — non-archived, non-skipped rows (skipped runs
|
// false (default) — only non-archived rows
|
||||||
// belong to the Archived tab now)
|
// true — only archived rows (for the Archived tab)
|
||||||
// true — archived rows OR skipped rows (they're treated
|
|
||||||
// as "history" rather than active outcomes)
|
|
||||||
const archivedClause = opts.archived
|
const archivedClause = opts.archived
|
||||||
? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')`
|
? sql`rr.archived_at IS NOT NULL`
|
||||||
: sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`;
|
: sql`rr.archived_at IS NULL`;
|
||||||
const rows = await db.execute(sql`
|
const rows = await db.execute(sql`
|
||||||
SELECT
|
SELECT
|
||||||
rr.id,
|
rr.id,
|
||||||
@ -264,23 +241,11 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
|
|||||||
where: (m, { eq }) => eq(m.reminderId, reminderId),
|
where: (m, { eq }) => eq(m.reminderId, reminderId),
|
||||||
orderBy: (m, { asc }) => [asc(m.position)],
|
orderBy: (m, { asc }) => [asc(m.position)],
|
||||||
});
|
});
|
||||||
// LEFT-JOIN aggregate counts in one round-trip so the detail page
|
|
||||||
// can render the paused banner with "X of Y groups delivered"
|
|
||||||
// without a per-run fan-out query. Counts are bigint in PG → cast
|
|
||||||
// to int so JSON marshalling stays lossless.
|
|
||||||
const runs = await db.execute(sql`
|
const runs = await db.execute(sql`
|
||||||
SELECT
|
SELECT id, fired_at, status, error_summary
|
||||||
rr.id,
|
FROM reminder_runs
|
||||||
rr.fired_at,
|
WHERE reminder_id = ${reminderId}
|
||||||
rr.status,
|
ORDER BY fired_at DESC
|
||||||
rr.error_summary,
|
|
||||||
COALESCE(SUM(CASE WHEN rt.status = 'sent' THEN 1 ELSE 0 END)::int, 0) AS sent,
|
|
||||||
COALESCE(COUNT(rt.id)::int, 0) AS total
|
|
||||||
FROM reminder_runs rr
|
|
||||||
LEFT JOIN reminder_run_targets rt ON rt.run_id = rr.id
|
|
||||||
WHERE rr.reminder_id = ${reminderId}
|
|
||||||
GROUP BY rr.id, rr.fired_at, rr.status, rr.error_summary
|
|
||||||
ORDER BY rr.fired_at DESC
|
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
`);
|
`);
|
||||||
return {
|
return {
|
||||||
@ -296,8 +261,6 @@ export async function getReminderWithRuns(operatorId: string, reminderId: string
|
|||||||
firedAt: r.fired_at as Date,
|
firedAt: r.fired_at as Date,
|
||||||
status: r.status as string,
|
status: r.status as string,
|
||||||
errorSummary: r.error_summary as string | null,
|
errorSummary: r.error_summary as string | null,
|
||||||
sent: r.sent as number,
|
|
||||||
total: r.total as number,
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,7 +73,7 @@ describe("applyReminderFilter — account / group filters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("status='all' or unset includes every status", () => {
|
it("status='all' or unset includes every status", () => {
|
||||||
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "inactive" })];
|
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "ended" })];
|
||||||
expect(applyReminderFilter(rows, { status: "all" }).map((r) => r.id)).toEqual(["a", "b"]);
|
expect(applyReminderFilter(rows, { status: "all" }).map((r) => r.id)).toEqual(["a", "b"]);
|
||||||
expect(applyReminderFilter(rows, {}).map((r) => r.id)).toEqual(["a", "b"]);
|
expect(applyReminderFilter(rows, {}).map((r) => r.id)).toEqual(["a", "b"]);
|
||||||
});
|
});
|
||||||
@ -81,7 +81,7 @@ describe("applyReminderFilter — account / group filters", () => {
|
|||||||
it("status filters to the matching value", () => {
|
it("status filters to the matching value", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
mk({ id: "a", status: "active" }),
|
mk({ id: "a", status: "active" }),
|
||||||
mk({ id: "b", status: "inactive" }),
|
mk({ id: "b", status: "ended" }),
|
||||||
mk({ id: "c", status: "paused" }),
|
mk({ id: "c", status: "paused" }),
|
||||||
];
|
];
|
||||||
expect(applyReminderFilter(rows, { status: "paused" }).map((r) => r.id)).toEqual(["c"]);
|
expect(applyReminderFilter(rows, { status: "paused" }).map((r) => r.id)).toEqual(["c"]);
|
||||||
@ -152,7 +152,7 @@ describe("applyReminderFilter — combined", () => {
|
|||||||
mk({ id: "match", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
|
mk({ id: "match", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
|
||||||
mk({ id: "wrong-acc", name: "Daily ping", accountId: "acc-2", groupIds: ["g-1"], status: "active", ...base }),
|
mk({ id: "wrong-acc", name: "Daily ping", accountId: "acc-2", groupIds: ["g-1"], status: "active", ...base }),
|
||||||
mk({ id: "wrong-group", name: "Daily ping", accountId: "acc-1", groupIds: ["g-9"], status: "active", ...base }),
|
mk({ id: "wrong-group", name: "Daily ping", accountId: "acc-1", groupIds: ["g-9"], status: "active", ...base }),
|
||||||
mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "inactive", ...base }),
|
mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "ended", ...base }),
|
||||||
mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
|
mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
|
||||||
];
|
];
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export interface ReminderFilter {
|
|||||||
q?: string;
|
q?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
status?: string; // "all" | "active" | "inactive" | "paused"
|
status?: string; // "all" | "active" | "ended" | "paused"
|
||||||
sort?: SortKey;
|
sort?: SortKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -59,11 +59,11 @@ describe("validateUpdateScheduledAt", () => {
|
|||||||
if (r.ok) expect(r.scheduledAt.getTime()).toBe(PAST.getTime());
|
if (r.ok) expect(r.scheduledAt.getTime()).toBe(PAST.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inactive one-off, past timestamp matching existing → ALLOWED", () => {
|
it("ended one-off, past timestamp matching existing → ALLOWED", () => {
|
||||||
const r = validateUpdateScheduledAt({
|
const r = validateUpdateScheduledAt({
|
||||||
iso: isoOf(PAST),
|
iso: isoOf(PAST),
|
||||||
timezone: TZ,
|
timezone: TZ,
|
||||||
existingStatus: "inactive",
|
existingStatus: "ended",
|
||||||
existingScheduledAt: PAST,
|
existingScheduledAt: PAST,
|
||||||
now: NOW,
|
now: NOW,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export function validateUpdateScheduledAt(args: {
|
|||||||
if (Number.isNaN(dt.getTime())) {
|
if (Number.isNaN(dt.getTime())) {
|
||||||
return { ok: false, error: "Invalid date" };
|
return { ok: false, error: "Invalid date" };
|
||||||
}
|
}
|
||||||
const isPaused = args.existingStatus === "paused" || args.existingStatus === "inactive";
|
const isPaused = args.existingStatus === "paused" || args.existingStatus === "ended";
|
||||||
const sameAsExisting =
|
const sameAsExisting =
|
||||||
args.existingScheduledAt !== null &&
|
args.existingScheduledAt !== null &&
|
||||||
Math.abs(args.existingScheduledAt.getTime() - dt.getTime()) < 1000;
|
Math.abs(args.existingScheduledAt.getTime() - dt.getTime()) < 1000;
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { safeRedirect } from "./safe-redirect";
|
|
||||||
|
|
||||||
describe("safeRedirect", () => {
|
|
||||||
it("preserves a relative path that starts with a single slash", () => {
|
|
||||||
expect(safeRedirect("/dashboard")).toBe("/dashboard");
|
|
||||||
expect(safeRedirect("/reminders/new")).toBe("/reminders/new");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves query string and fragment", () => {
|
|
||||||
expect(safeRedirect("/legit?with=params&extra=fine#hash")).toBe(
|
|
||||||
"/legit?with=params&extra=fine#hash",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects protocol-relative URLs (//evil.com)", () => {
|
|
||||||
expect(safeRedirect("//evil.com")).toBe("/");
|
|
||||||
expect(safeRedirect("//evil.com/dashboard")).toBe("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects absolute URLs", () => {
|
|
||||||
expect(safeRedirect("https://evil.com")).toBe("/");
|
|
||||||
expect(safeRedirect("http://evil.com/dashboard")).toBe("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects javascript: and data: schemes", () => {
|
|
||||||
expect(safeRedirect("javascript:alert(1)")).toBe("/");
|
|
||||||
expect(safeRedirect("data:text/html,<script>x</script>")).toBe("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to / for empty / null / undefined / whitespace input", () => {
|
|
||||||
expect(safeRedirect("")).toBe("/");
|
|
||||||
expect(safeRedirect(null)).toBe("/");
|
|
||||||
expect(safeRedirect(undefined)).toBe("/");
|
|
||||||
expect(safeRedirect(" ")).toBe("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects paths that don't start with / (relative-relative)", () => {
|
|
||||||
expect(safeRedirect("dashboard")).toBe("/");
|
|
||||||
expect(safeRedirect("./dashboard")).toBe("/");
|
|
||||||
expect(safeRedirect("../dashboard")).toBe("/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Returns `next` if it is a safe relative path, otherwise "/".
|
|
||||||
*
|
|
||||||
* Safe means: starts with a single forward slash AND not "//" (which
|
|
||||||
* is a protocol-relative URL, e.g. //evil.com). Anything else falls
|
|
||||||
* back to the root — including empty input, absolute URLs, javascript:
|
|
||||||
* URIs, and relative-relative paths like "dashboard" or "../foo".
|
|
||||||
*/
|
|
||||||
export function safeRedirect(next: string | null | undefined): string {
|
|
||||||
if (typeof next !== "string") return "/";
|
|
||||||
const s = next.trim();
|
|
||||||
if (s.length < 2) return "/";
|
|
||||||
if (!s.startsWith("/")) return "/";
|
|
||||||
if (s.startsWith("//")) return "/";
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll } from "vitest";
|
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
const SECRET = "test-secret";
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.AUTH_SECRET = SECRET;
|
|
||||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
|
||||||
});
|
|
||||||
|
|
||||||
import { signSession } from "./lib/auth-cookie";
|
|
||||||
import { middleware } from "./middleware";
|
|
||||||
|
|
||||||
async function makeReq(path: string, cookie?: string): Promise<NextRequest> {
|
|
||||||
const url = new URL(`https://wabot.04080616.xyz${path}`);
|
|
||||||
const headers = new Headers();
|
|
||||||
if (cookie) headers.set("cookie", `session=${cookie}`);
|
|
||||||
return new NextRequest(url, { headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validCookie(): Promise<string> {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
return signSession(
|
|
||||||
{
|
|
||||||
userId: "00000000-0000-0000-0000-000000000000",
|
|
||||||
role: "admin",
|
|
||||||
iat: now,
|
|
||||||
exp: now + 3600,
|
|
||||||
v: 1,
|
|
||||||
},
|
|
||||||
SECRET,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("middleware", () => {
|
|
||||||
it("page request without a cookie redirects to /login?next=…", async () => {
|
|
||||||
const r = await middleware(await makeReq("/dashboard"));
|
|
||||||
expect(r.status).toBe(307);
|
|
||||||
expect(r.headers.get("location")).toContain("/login");
|
|
||||||
expect(r.headers.get("location")).toContain("next=%2Fdashboard");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("/api/* request without a cookie returns 401 with no body", async () => {
|
|
||||||
const r = await middleware(await makeReq("/api/events"));
|
|
||||||
expect(r.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("page request with a valid cookie passes through", async () => {
|
|
||||||
const r = await middleware(await makeReq("/dashboard", await validCookie()));
|
|
||||||
// NextResponse.next() returns a 200 with the x-middleware-next header.
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("page request with a tampered cookie redirects to /login", async () => {
|
|
||||||
const cookie = (await validCookie()).slice(0, -4) + "AAAA";
|
|
||||||
const r = await middleware(await makeReq("/dashboard", cookie));
|
|
||||||
expect(r.status).toBe(307);
|
|
||||||
expect(r.headers.get("location")).toContain("/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allowlisted paths bypass auth (login, logout, health, manifest, icons)", async () => {
|
|
||||||
for (const path of [
|
|
||||||
"/login",
|
|
||||||
"/logout",
|
|
||||||
"/api/health",
|
|
||||||
"/manifest.webmanifest",
|
|
||||||
"/icon-192.png",
|
|
||||||
"/favicon.ico",
|
|
||||||
]) {
|
|
||||||
const r = await middleware(await makeReq(path));
|
|
||||||
expect(r.status).toBe(200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("/api/events and /api/qr/<id> are NOT in the allowlist (regression)", async () => {
|
|
||||||
expect((await middleware(await makeReq("/api/events"))).status).toBe(401);
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await middleware(
|
|
||||||
await makeReq("/api/qr/11111111-1111-1111-1111-111111111111"),
|
|
||||||
)
|
|
||||||
).status,
|
|
||||||
).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,41 +1,21 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { COOKIE_NAME, verifySession } from "./lib/auth-cookie";
|
|
||||||
|
|
||||||
const PUBLIC_PATHS = new Set<string>([
|
export function middleware(req: NextRequest) {
|
||||||
"/login",
|
|
||||||
"/logout",
|
|
||||||
"/api/health",
|
|
||||||
"/manifest.webmanifest",
|
|
||||||
"/favicon.ico",
|
|
||||||
"/robots.txt",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function isPublic(path: string): boolean {
|
|
||||||
if (PUBLIC_PATHS.has(path)) return true;
|
|
||||||
if (path.startsWith("/icon-")) return true;
|
|
||||||
if (path.startsWith("/_next/")) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
if (isPublic(path)) return NextResponse.next();
|
|
||||||
|
|
||||||
const cookie = req.cookies.get(COOKIE_NAME)?.value;
|
// Block all /api/* except a small set of read-only endpoints.
|
||||||
const secret = process.env.AUTH_SECRET;
|
// Mutations happen via Server Actions which post to page URLs, not /api/*.
|
||||||
const ok =
|
const allowed =
|
||||||
!!cookie && !!secret && (await verifySession(cookie, secret)) !== null;
|
path === "/api/events" ||
|
||||||
if (ok) return NextResponse.next();
|
path === "/api/health" ||
|
||||||
|
path.startsWith("/api/qr/");
|
||||||
if (path.startsWith("/api/")) {
|
if (path.startsWith("/api/") && !allowed) {
|
||||||
return new NextResponse("Unauthorized", { status: 401 });
|
return new NextResponse("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
const url = req.nextUrl.clone();
|
|
||||||
url.pathname = "/login";
|
return NextResponse.next();
|
||||||
url.searchParams.set("next", path + (req.nextUrl.search || ""));
|
|
||||||
return NextResponse.redirect(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next/static|_next/image).*)"],
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import {
|
|
||||||
assertJournalMonotonic,
|
|
||||||
formatJournalViolations,
|
|
||||||
type JournalEntry,
|
|
||||||
} from "@cmbot/db/journal-check";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CI guard against the recurring drizzle journal-skip bug.
|
|
||||||
*
|
|
||||||
* Drizzle's migrator orders entries by `when` (not `idx`) and only
|
|
||||||
* applies entries whose `when` is greater than the latest applied
|
|
||||||
* row's recorded `created_at`. We've shipped two breaking deploys
|
|
||||||
* (0010/0011 and 0012/0013) where freshly-generated migrations had
|
|
||||||
* `when` values older than a prior manually-bumped entry — `pnpm
|
|
||||||
* migrate` printed "Migrations applied." while silently skipping
|
|
||||||
* the new SQL, and production 500'd until we hand-fixed the journal.
|
|
||||||
*
|
|
||||||
* This test reads the committed _journal.json and fails if the
|
|
||||||
* entries aren't strictly monotonically increasing by `when` in the
|
|
||||||
* same order as `idx`. Catches a bad commit at PR time instead of
|
|
||||||
* at the next deploy.
|
|
||||||
*/
|
|
||||||
describe("drizzle journal monotonicity (regression guard)", () => {
|
|
||||||
const journalPath = join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"..",
|
|
||||||
"packages",
|
|
||||||
"db",
|
|
||||||
"migrations",
|
|
||||||
"meta",
|
|
||||||
"_journal.json",
|
|
||||||
);
|
|
||||||
|
|
||||||
const raw = JSON.parse(readFileSync(journalPath, "utf8")) as {
|
|
||||||
entries: JournalEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
it("loads at least one journal entry (sanity)", () => {
|
|
||||||
expect(raw.entries.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("`when` timestamps are strictly increasing in `idx` order", () => {
|
|
||||||
const result = assertJournalMonotonic(raw.entries);
|
|
||||||
if (!result.ok) {
|
|
||||||
// Print the same actionable message migrate.ts prints, so a
|
|
||||||
// failed CI run reads exactly like a failed local migrate.
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(formatJournalViolations(result));
|
|
||||||
}
|
|
||||||
expect(result.violations).toEqual([]);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
||||||
import { join, relative } from "node:path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static guard: no production `.tsx` file may pass `showCloseButton`
|
|
||||||
* to `<DialogFooter>`.
|
|
||||||
*
|
|
||||||
* Why: the shared DialogFooter renders an EXTRA outline-styled
|
|
||||||
* <Button>Close</Button> when `showCloseButton` is set. Every dialog
|
|
||||||
* we have that already provides its own primary action also includes
|
|
||||||
* a Cancel/dismiss button (either via DialogClose or by closing the
|
|
||||||
* Dialog state on submit) — and Radix's auto-rendered corner X
|
|
||||||
* already gives users a third way out. The redundant Close button
|
|
||||||
* cluttered the footer and shipped to production multiple times
|
|
||||||
* before this guard existed; this test stops it from regressing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SRC_ROOT = join(__dirname, "..");
|
|
||||||
|
|
||||||
function listTsxFiles(dir: string): string[] {
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const entry of readdirSync(dir)) {
|
|
||||||
const full = join(dir, entry);
|
|
||||||
const st = statSync(full);
|
|
||||||
if (st.isDirectory()) {
|
|
||||||
out.push(...listTsxFiles(full));
|
|
||||||
} else if (entry.endsWith(".tsx")) {
|
|
||||||
out.push(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Hit {
|
|
||||||
file: string;
|
|
||||||
line: number;
|
|
||||||
excerpt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findHits(content: string): Array<{ line: number; excerpt: string }> {
|
|
||||||
const hits: Array<{ line: number; excerpt: string }> = [];
|
|
||||||
// Match `<DialogFooter` with `showCloseButton` somewhere in the
|
|
||||||
// opening tag. Stops at `>` so we don't accidentally cross into the
|
|
||||||
// children. Multi-line opening tags are handled by `[\s\S]`.
|
|
||||||
const matches = content.matchAll(
|
|
||||||
/<DialogFooter\b[\s\S]*?showCloseButton\b[\s\S]*?>/g,
|
|
||||||
);
|
|
||||||
for (const m of matches) {
|
|
||||||
const idx = m.index ?? 0;
|
|
||||||
const line = content.slice(0, idx).split("\n").length;
|
|
||||||
hits.push({ line, excerpt: m[0].slice(0, 120).replace(/\s+/g, " ") });
|
|
||||||
}
|
|
||||||
return hits;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("static guard: no <DialogFooter showCloseButton>", () => {
|
|
||||||
// Skip this test file (it intentionally contains the pattern strings)
|
|
||||||
// and all other .test.tsx files (they're examples, not production UI).
|
|
||||||
const files = listTsxFiles(SRC_ROOT).filter(
|
|
||||||
(f) => !/\.test\.tsx?$/.test(f),
|
|
||||||
);
|
|
||||||
|
|
||||||
it("scans at least one source file (sanity)", () => {
|
|
||||||
expect(files.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds no <DialogFooter showCloseButton ...> in any production .tsx file", () => {
|
|
||||||
const allHits: Hit[] = [];
|
|
||||||
for (const file of files) {
|
|
||||||
const content = readFileSync(file, "utf8");
|
|
||||||
for (const h of findHits(content)) {
|
|
||||||
allHits.push({ file: relative(SRC_ROOT, file), ...h });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (allHits.length > 0) {
|
|
||||||
const message = allHits
|
|
||||||
.map((h) => ` ${h.file}:${h.line} → ${h.excerpt}`)
|
|
||||||
.join("\n");
|
|
||||||
throw new Error(
|
|
||||||
`Redundant Close button detected — <DialogFooter showCloseButton ...>:\n${message}\n` +
|
|
||||||
`The DialogFooter component injects an extra "Close" button when this prop\n` +
|
|
||||||
`is set. Every existing caller already has its own Cancel/Close action plus\n` +
|
|
||||||
`Radix's corner X — a third Close is just visual noise. Remove the prop.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
expect(allHits).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findHits parser", () => {
|
|
||||||
it("matches a single-line <DialogFooter showCloseButton>", () => {
|
|
||||||
expect(
|
|
||||||
findHits("<DialogFooter showCloseButton>foo</DialogFooter>"),
|
|
||||||
).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it("matches when other props are present alongside showCloseButton", () => {
|
|
||||||
expect(
|
|
||||||
findHits('<DialogFooter className="x" showCloseButton>'),
|
|
||||||
).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it("matches across multiple lines", () => {
|
|
||||||
const src = `<DialogFooter\n className="x"\n showCloseButton\n>`;
|
|
||||||
expect(findHits(src)).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it("does NOT match a clean <DialogFooter>", () => {
|
|
||||||
expect(findHits("<DialogFooter>x</DialogFooter>")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
it("does NOT match a similarly-named prop on an unrelated component", () => {
|
|
||||||
expect(findHits("<SheetFooter showCloseButton>")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -19,7 +19,7 @@ services:
|
|||||||
MEDIA_DIR: ${MEDIA_DIR:-/data/media}
|
MEDIA_DIR: ${MEDIA_DIR:-/data/media}
|
||||||
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
|
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
|
||||||
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
|
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
|
||||||
SEED_OPERATOR_USERNAME: ${SEED_OPERATOR_USERNAME:-admin}
|
SEED_OPERATOR_TELEGRAM_ID: ${SEED_OPERATOR_TELEGRAM_ID:-0}
|
||||||
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
|
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
|
||||||
networks:
|
networks:
|
||||||
- cmbot
|
- cmbot
|
||||||
@ -36,8 +36,6 @@ services:
|
|||||||
DATA_DIR: ${DATA_DIR}
|
DATA_DIR: ${DATA_DIR}
|
||||||
MEDIA_DIR: ${MEDIA_DIR}
|
MEDIA_DIR: ${MEDIA_DIR}
|
||||||
WEB_PORT: ${WEB_PORT}
|
WEB_PORT: ${WEB_PORT}
|
||||||
AUTH_SECRET: ${AUTH_SECRET}
|
|
||||||
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
|
||||||
networks:
|
networks:
|
||||||
- cmbot
|
- cmbot
|
||||||
|
|
||||||
|
|||||||
@ -59,7 +59,5 @@ services:
|
|||||||
DATA_DIR: ${DATA_DIR}
|
DATA_DIR: ${DATA_DIR}
|
||||||
MEDIA_DIR: ${MEDIA_DIR}
|
MEDIA_DIR: ${MEDIA_DIR}
|
||||||
WEB_PORT: ${WEB_PORT}
|
WEB_PORT: ${WEB_PORT}
|
||||||
AUTH_SECRET: ${AUTH_SECRET}
|
|
||||||
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- tools
|
- tools
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
# Portainer-ready stack. Pulls cm-whatsapp-{web,bot} from
|
|
||||||
# gitea.04080616.xyz/yiekheng instead of building from source — drop
|
|
||||||
# this file into a Portainer "Stack" (Repository or Web editor) and
|
|
||||||
# fill the env vars in the Portainer UI.
|
|
||||||
#
|
|
||||||
# Differences vs docker-compose.base.yml:
|
|
||||||
# - No `build:` blocks (Portainer pulls only).
|
|
||||||
# - Named volumes (cmbot-data, cmbot-sessions, cmbot-media) instead
|
|
||||||
# of host bind-mounts so the operator doesn't need shell access
|
|
||||||
# to manage persistent state.
|
|
||||||
# - Ports section on `web` so the operator can route a reverse
|
|
||||||
# proxy / Cloudflare Tunnel directly at the container.
|
|
||||||
# - `restart: unless-stopped` on both services.
|
|
||||||
#
|
|
||||||
# Required env vars (set in Portainer → Stack → Environment variables):
|
|
||||||
# DATABASE_URL postgres://USER:PASS@HOST:5432/wabot
|
|
||||||
# AUTH_SECRET 32-byte random hex (use scripts/gen_auth_secret.sh
|
|
||||||
# on any machine and copy the output)
|
|
||||||
# WEB_PORT host port for the web container (default 9000)
|
|
||||||
#
|
|
||||||
# Optional:
|
|
||||||
# DOCKER_IMAGE_TAG registry tag to deploy (default: latest)
|
|
||||||
# OPERATOR_TOKEN_VERSION session-cookie kill switch (default: 1)
|
|
||||||
# BOT_FIRE_CONCURRENCY pg-boss workers (default: 8)
|
|
||||||
# BOT_GROUP_CONCURRENCY per-account parallel sends (default: 3)
|
|
||||||
# BOT_MAX_SEND_PER_MINUTE per-account token-bucket rate (default: 40)
|
|
||||||
# BOT_LOG_LEVEL pino log level (default: info)
|
|
||||||
#
|
|
||||||
# Registry auth: Portainer needs a pull credential for
|
|
||||||
# gitea.04080616.xyz before you start the stack:
|
|
||||||
# Portainer → Registries → Add registry
|
|
||||||
# Name: gitea.04080616.xyz
|
|
||||||
# URL: gitea.04080616.xyz
|
|
||||||
# Username: <gitea user>
|
|
||||||
# Token: <gitea personal access token, read:packages>
|
|
||||||
# After adding, edit each service in the stack and set "Registry" to
|
|
||||||
# the one you just added so the pull resolves.
|
|
||||||
|
|
||||||
services:
|
|
||||||
bot:
|
|
||||||
image: gitea.04080616.xyz/yiekheng/cm-whatsapp-bot:${DOCKER_IMAGE_TAG:-latest}
|
|
||||||
container_name: cmbot-bot
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
|
||||||
DATA_DIR: /data
|
|
||||||
SESSIONS_DIR: /data/sessions
|
|
||||||
MEDIA_DIR: /data/media
|
|
||||||
BOT_HEALTH_PORT: 8081
|
|
||||||
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
|
|
||||||
BOT_FIRE_CONCURRENCY: ${BOT_FIRE_CONCURRENCY:-8}
|
|
||||||
BOT_GROUP_CONCURRENCY: ${BOT_GROUP_CONCURRENCY:-3}
|
|
||||||
BOT_MAX_SEND_PER_MINUTE: ${BOT_MAX_SEND_PER_MINUTE:-40}
|
|
||||||
volumes:
|
|
||||||
- cmbot-sessions:/data/sessions
|
|
||||||
- cmbot-media:/data/media
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- "CMD-SHELL"
|
|
||||||
- "wget -qO- --timeout=2 http://127.0.0.1:8081/health >/dev/null || exit 1"
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 20s
|
|
||||||
networks:
|
|
||||||
- cmbot
|
|
||||||
|
|
||||||
web:
|
|
||||||
image: gitea.04080616.xyz/yiekheng/cm-whatsapp-web:${DOCKER_IMAGE_TAG:-latest}
|
|
||||||
container_name: cmbot-web
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- bot
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
|
||||||
DATA_DIR: /data
|
|
||||||
MEDIA_DIR: /data/media
|
|
||||||
WEB_PORT: 3000
|
|
||||||
AUTH_SECRET: ${AUTH_SECRET}
|
|
||||||
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
|
|
||||||
volumes:
|
|
||||||
# Web reads media from the same persistent volume the bot wrote.
|
|
||||||
- cmbot-media:/data/media:ro
|
|
||||||
ports:
|
|
||||||
# Maps the Next.js port (3000 inside the container) to whatever
|
|
||||||
# WEB_PORT the operator set. The reverse proxy / Cloudflare Tunnel
|
|
||||||
# in front of this host points at <host>:${WEB_PORT}.
|
|
||||||
- "${WEB_PORT:-9000}:3000"
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- "CMD-SHELL"
|
|
||||||
- "wget -qO- --timeout=2 http://127.0.0.1:3000/api/health >/dev/null || exit 1"
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 30s
|
|
||||||
networks:
|
|
||||||
- cmbot
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
cmbot-sessions:
|
|
||||||
name: cmbot-sessions
|
|
||||||
cmbot-media:
|
|
||||||
name: cmbot-media
|
|
||||||
|
|
||||||
networks:
|
|
||||||
cmbot:
|
|
||||||
driver: bridge
|
|
||||||
name: cmbot
|
|
||||||
@ -26,13 +26,5 @@ COPY --from=build /app/node_modules /app/node_modules
|
|||||||
COPY --from=build /app/apps/bot /app/apps/bot
|
COPY --from=build /app/apps/bot /app/apps/bot
|
||||||
COPY --from=build /app/packages/db /app/packages/db
|
COPY --from=build /app/packages/db /app/packages/db
|
||||||
COPY --from=build /app/packages/shared /app/packages/shared
|
COPY --from=build /app/packages/shared /app/packages/shared
|
||||||
# Reuse the `node` user (UID/GID 1000) that node:alpine ships with —
|
|
||||||
# `addgroup -g 1000 app` failed in CI because gid 1000 was already
|
|
||||||
# taken by the node group. Same hardening posture (non-root, no
|
|
||||||
# shell login), one less moving part.
|
|
||||||
RUN mkdir -p /data/sessions /data/media /app && \
|
|
||||||
chown -R node:node /app /data && \
|
|
||||||
chmod 700 /data/sessions
|
|
||||||
USER node
|
|
||||||
EXPOSE 8081
|
EXPOSE 8081
|
||||||
CMD ["node", "apps/bot/dist/index.js"]
|
CMD ["node", "apps/bot/dist/index.js"]
|
||||||
|
|||||||
@ -18,20 +18,7 @@ COPY tsconfig.base.json turbo.json ./
|
|||||||
COPY apps/web apps/web
|
COPY apps/web apps/web
|
||||||
COPY packages/db packages/db
|
COPY packages/db packages/db
|
||||||
COPY packages/shared packages/shared
|
COPY packages/shared packages/shared
|
||||||
# Placeholder env values during `next build`'s "Collecting page data"
|
RUN pnpm --filter @cmbot/shared build && \
|
||||||
# pass. Next bundles `lib/db.ts` (`createClient(env.DATABASE_URL)`) and
|
|
||||||
# `actions/auth.ts` (uses AUTH_SECRET) into route bundles; their
|
|
||||||
# top-level env access fires when Next imports the route to inspect
|
|
||||||
# its config (the route's own `export const dynamic = "force-dynamic"`
|
|
||||||
# stops handler execution, NOT module evaluation).
|
|
||||||
#
|
|
||||||
# pg.Pool is lazy — it stores the URL and only connects on the first
|
|
||||||
# query — so a build-time placeholder never opens a socket. The
|
|
||||||
# placeholders are scoped to this RUN layer (each Dockerfile RUN is
|
|
||||||
# its own shell); nothing leaks into the runtime image.
|
|
||||||
RUN export DATABASE_URL=postgres://build:build@localhost:5432/build && \
|
|
||||||
export AUTH_SECRET=build-time-placeholder-not-used-at-runtime && \
|
|
||||||
pnpm --filter @cmbot/shared build && \
|
|
||||||
pnpm --filter @cmbot/db build && \
|
pnpm --filter @cmbot/db build && \
|
||||||
pnpm --filter @cmbot/web build
|
pnpm --filter @cmbot/web build
|
||||||
|
|
||||||
@ -42,21 +29,5 @@ ENV HOSTNAME=0.0.0.0
|
|||||||
COPY --from=build /app/apps/web/.next/standalone ./
|
COPY --from=build /app/apps/web/.next/standalone ./
|
||||||
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
|
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
COPY --from=build /app/apps/web/public ./apps/web/public
|
COPY --from=build /app/apps/web/public ./apps/web/public
|
||||||
# pnpm's workspace layout: each packages/<pkg>/node_modules/<dep> is a
|
|
||||||
# symlink into /app/node_modules/.pnpm/<dep>@<ver>/node_modules/<dep>
|
|
||||||
# where the real files live. Copying just packages/<pkg>/node_modules
|
|
||||||
# ships dangling symlinks. Bring the .pnpm content store across too so
|
|
||||||
# every symlink resolves at runtime; this is what unblocks the
|
|
||||||
# `Cannot find module 'rrule'` error from
|
|
||||||
# packages/shared/dist/rrule.js. Use --link to deduplicate the layer
|
|
||||||
# blobs inside docker so the runtime image stays slim despite the
|
|
||||||
# dot-pnpm tree being large.
|
|
||||||
COPY --link --from=build /app/node_modules/.pnpm ./node_modules/.pnpm
|
|
||||||
COPY --link --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
|
|
||||||
COPY --link --from=build /app/packages/db/node_modules ./packages/db/node_modules
|
|
||||||
# Reuse the `node` user (UID/GID 1000) that node:alpine ships with —
|
|
||||||
# `addgroup -g 1000 app` collided with the pre-existing node group.
|
|
||||||
RUN chown -R node:node /app
|
|
||||||
USER node
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "apps/web/server.js"]
|
CMD ["node", "apps/web/server.js"]
|
||||||
|
|||||||
@ -1,172 +0,0 @@
|
|||||||
# Deploying via Portainer
|
|
||||||
|
|
||||||
End-to-end deploy steps for a fresh Portainer-managed host. Targets
|
|
||||||
the standard cm-whatsapp-bot pair of images published by
|
|
||||||
`scripts/publish.sh`.
|
|
||||||
|
|
||||||
## 0. Prerequisites
|
|
||||||
|
|
||||||
- Portainer 2.x running on the target host (CE or EE both fine).
|
|
||||||
- A Postgres reachable from that host (the `wabot` database with the
|
|
||||||
pgcrypto / pg_trgm extensions enabled — run migrations from any
|
|
||||||
machine that can reach the DB before the stack is brought up).
|
|
||||||
- A pull credential for `gitea.04080616.xyz` — a Gitea personal
|
|
||||||
access token with the `read:packages` scope. Generate one in
|
|
||||||
Gitea → User Settings → Applications.
|
|
||||||
- A reverse proxy / Cloudflare Tunnel pointing at
|
|
||||||
`http://<portainer-host>:<WEB_PORT>` if the deploy needs to be
|
|
||||||
reachable on the public domain (e.g. `wabot.04080616.xyz`).
|
|
||||||
|
|
||||||
## 1. Add the registry to Portainer
|
|
||||||
|
|
||||||
Portainer → **Registries** → **+ Add registry** → Custom registry.
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---------------|-----------------------------|
|
|
||||||
| Name | `gitea.04080616.xyz` |
|
|
||||||
| Registry URL | `gitea.04080616.xyz` |
|
|
||||||
| Authentication | enabled |
|
|
||||||
| Username | your Gitea username |
|
|
||||||
| Password | the read:packages PAT |
|
|
||||||
|
|
||||||
Save. The registry must show as connected before continuing — if the
|
|
||||||
test pull fails, the stack will hang on `pull` later.
|
|
||||||
|
|
||||||
## 2. Push the images (on your dev machine)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Login once (sudo path matches scripts/dev.sh by default)
|
|
||||||
sudo docker login gitea.04080616.xyz
|
|
||||||
|
|
||||||
# Push :latest. Tag explicitly with DOCKER_IMAGE_TAG=v1.x.y if you
|
|
||||||
# want pinned-tag deploys (recommended for prod — never deploy
|
|
||||||
# `latest` if you can avoid it; tag versions per release).
|
|
||||||
NO_SUDO=1 ./scripts/publish.sh latest
|
|
||||||
```
|
|
||||||
|
|
||||||
`publish.sh` builds + pushes both images:
|
|
||||||
- `gitea.04080616.xyz/yiekheng/cm-whatsapp-bot:<tag>`
|
|
||||||
- `gitea.04080616.xyz/yiekheng/cm-whatsapp-web:<tag>`
|
|
||||||
|
|
||||||
## 3. Create the Portainer stack
|
|
||||||
|
|
||||||
Portainer → **Stacks** → **+ Add stack**.
|
|
||||||
|
|
||||||
**Name:** `cm-whatsapp-bot`
|
|
||||||
|
|
||||||
**Build method:** "Web editor" or "Repository". Either is fine —
|
|
||||||
"Repository" pointing at this repo's `master` and the file
|
|
||||||
`docker-compose.portainer.yml` is the cleanest path because future
|
|
||||||
deploys are just "Pull and redeploy" inside Portainer.
|
|
||||||
|
|
||||||
**Web editor path:** copy the contents of
|
|
||||||
[`docker-compose.portainer.yml`](../docker-compose.portainer.yml)
|
|
||||||
into the editor verbatim.
|
|
||||||
|
|
||||||
**Repository path:**
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|------------------|-------------------------------------------------------------|
|
|
||||||
| Repository URL | http://192.168.0.215:3000/yiekheng/cm_whatsapp_bot_v1.git |
|
|
||||||
| Reference | refs/heads/master |
|
|
||||||
| Compose path | docker-compose.portainer.yml |
|
|
||||||
| Authentication | enabled (same Gitea PAT as step 1) |
|
|
||||||
| Auto-update | optional — enabled lets Portainer redeploy on every push |
|
|
||||||
|
|
||||||
## 4. Set environment variables
|
|
||||||
|
|
||||||
In the same stack form, scroll to **Environment variables** and add:
|
|
||||||
|
|
||||||
| Key | Value |
|
|
||||||
|---------------------------|------------------------------------------------|
|
|
||||||
| `DATABASE_URL` | `postgres://wabot:PASS@192.168.0.210:5432/wabot` |
|
|
||||||
| `AUTH_SECRET` | output of `scripts/gen_auth_secret.sh` |
|
|
||||||
| `WEB_PORT` | host port (e.g. `9000`) |
|
|
||||||
| `DOCKER_IMAGE_TAG` | `latest` (or a pinned `v1.x.y`) |
|
|
||||||
| `OPERATOR_TOKEN_VERSION` | `1` (bump only when you want to invalidate every existing session) |
|
|
||||||
| `BOT_LOG_LEVEL` | `info` |
|
|
||||||
|
|
||||||
Optional tuning (defaults are fine for most installs):
|
|
||||||
|
|
||||||
| Key | Default | When to bump |
|
|
||||||
|---------------------------|---------|--------------|
|
|
||||||
| `BOT_FIRE_CONCURRENCY` | `8` | More accounts firing in parallel |
|
|
||||||
| `BOT_GROUP_CONCURRENCY` | `3` | More groups per fire — but careful with WhatsApp rate caps |
|
|
||||||
| `BOT_MAX_SEND_PER_MINUTE` | `40` | Aged accounts can push toward 60 |
|
|
||||||
|
|
||||||
## 5. Run database migrations
|
|
||||||
|
|
||||||
The stack does NOT auto-migrate on boot. Apply migrations from any
|
|
||||||
machine that can reach the same Postgres:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DATABASE_URL='postgres://...' \
|
|
||||||
./scripts/db.sh migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
If the journal is non-monotonic, the migrate runner refuses with a
|
|
||||||
clear error and prints which `_journal.json` entry to bump (the
|
|
||||||
guard added in commit 47d7c53 + the CI test in
|
|
||||||
`apps/web/src/test/drizzle-journal-monotonic.test.ts`).
|
|
||||||
|
|
||||||
Then seed the bootstrap operator + set its password:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DATABASE_URL='postgres://...' SEED_OPERATOR_USERNAME=admin \
|
|
||||||
./scripts/db.sh seed
|
|
||||||
DATABASE_URL='postgres://...' \
|
|
||||||
./scripts/set-password.sh admin # reads the password from stdin
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Deploy the stack
|
|
||||||
|
|
||||||
In Portainer → click **Deploy the stack**. Watch the container list
|
|
||||||
in **Containers**:
|
|
||||||
|
|
||||||
- `cmbot-bot` should show *running, healthy* within ~20 s.
|
|
||||||
- `cmbot-web` should show *running, healthy* within ~30 s (Next.js
|
|
||||||
cold boot is the bottleneck).
|
|
||||||
|
|
||||||
If a container shows *unhealthy*, check **Logs**:
|
|
||||||
|
|
||||||
| Symptom | Likely cause |
|
|
||||||
|----------------------------------------------|--------------|
|
|
||||||
| `column "email" does not exist` | Migrations weren't applied. Run step 5. |
|
|
||||||
| `Server is not configured for sign-in` | `AUTH_SECRET` blank or missing. Set it in stack env. |
|
|
||||||
| `pg-boss: queue policy ...standard` | Harmless first-boot log; the bot force-flips it. |
|
|
||||||
| `Stream Errored (restart required)` (Baileys) | Upstream noise; ignore unless pairing fails. |
|
|
||||||
|
|
||||||
## 7. First sign-in
|
|
||||||
|
|
||||||
Visit `https://<your-domain>/login`, sign in as `admin` with the
|
|
||||||
password set in step 5, and walk the
|
|
||||||
[`docs/runbook.md`](runbook.md) smoke checklist before declaring
|
|
||||||
the deploy good.
|
|
||||||
|
|
||||||
## 8. Future redeploys
|
|
||||||
|
|
||||||
Two paths depending on how you set up step 3:
|
|
||||||
|
|
||||||
**Web editor flow:**
|
|
||||||
1. Run `scripts/publish.sh <tag>` on your dev machine.
|
|
||||||
2. In Portainer → Stack → "Update the stack" → "Re-pull image and
|
|
||||||
redeploy".
|
|
||||||
|
|
||||||
**Repository flow:**
|
|
||||||
1. Run `scripts/publish.sh <tag>`.
|
|
||||||
2. Commit any compose / env changes to master.
|
|
||||||
3. Portainer → Stack → "Pull and redeploy". (If auto-update is on,
|
|
||||||
skip this — Portainer redeploys on every push.)
|
|
||||||
|
|
||||||
Always pin a tag (`v1.4.2`) instead of `latest` for production —
|
|
||||||
makes rollback a one-field stack edit instead of a republish.
|
|
||||||
|
|
||||||
## Rolling back
|
|
||||||
|
|
||||||
In Portainer → Stack → set `DOCKER_IMAGE_TAG=v1.4.1` (or whatever
|
|
||||||
the previous good tag was) → Re-pull and redeploy. The cmbot-* data
|
|
||||||
volumes (sessions, media) are preserved across image swaps, so a
|
|
||||||
rollback doesn't lose pairings or uploaded media.
|
|
||||||
|
|
||||||
If the schema also rolled back, run the corresponding `down` SQL by
|
|
||||||
hand — drizzle's migrator only goes forward, by design.
|
|
||||||
200
docs/runbook.md
200
docs/runbook.md
@ -1,200 +0,0 @@
|
|||||||
# Manual end-to-end runbook (v1)
|
|
||||||
|
|
||||||
Smoke checklist for verifying a fresh deploy. Unit tests don't catch
|
|
||||||
the live-Baileys / live-Postgres / browser-gesture path; this is what
|
|
||||||
you run before declaring a release good.
|
|
||||||
|
|
||||||
Time budget: ~10 minutes if everything works, ~30 if a step fails.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pre-flight
|
|
||||||
|
|
||||||
- [ ] **Stack up.**
|
|
||||||
`docker ps | grep cmbot` → expect `cmbot-tools`, `cmbot-bot`,
|
|
||||||
`cmbot-web` all `Up`.
|
|
||||||
- [ ] **Migrations clean.**
|
|
||||||
`NO_SUDO=1 scripts/db.sh migrate` → "Migrations applied." (and
|
|
||||||
*not* "Refusing to run drizzle migrate" — that's the journal
|
|
||||||
monotonicity guard tripping).
|
|
||||||
- [ ] **Web reachable.**
|
|
||||||
`curl -sf http://localhost:9000/api/health` → 200.
|
|
||||||
- [ ] **Bot reachable.**
|
|
||||||
`curl -sf http://localhost:8081/health` → 200.
|
|
||||||
|
|
||||||
If any pre-flight fails, fix before continuing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Auth bootstrap
|
|
||||||
|
|
||||||
- [ ] `scripts/db.sh seed` (idempotent — only inserts the `admin`
|
|
||||||
operator if missing).
|
|
||||||
- [ ] `echo 'change-me-now' | scripts/set-password.sh admin` → "Password
|
|
||||||
updated."
|
|
||||||
- [ ] Open `http://localhost:9000/login` → enter `admin` / the password
|
|
||||||
→ redirected to `/`.
|
|
||||||
- [ ] **Wrong password three times in a row** still rate-limits but
|
|
||||||
with the generic "Too many attempts" message — no leak about
|
|
||||||
which limit (IP / username / global) tripped.
|
|
||||||
- [ ] Hit `/admin` URL while signed out → redirected to `/login` with
|
|
||||||
`?next=/admin`. After a successful login, lands back on `/admin`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. User management (admin-only)
|
|
||||||
|
|
||||||
- [ ] **Sidebar / drawer**: only one nav entry highlights at a time.
|
|
||||||
On `/settings/users`, only `Admin` lights up; `Settings` does
|
|
||||||
not.
|
|
||||||
- [ ] `/settings/users` → Add user → username `alice`, password
|
|
||||||
`alpha7!`, role `user` → "User created."
|
|
||||||
- [ ] `alice` row shows: username + `you` chip if applicable, role
|
|
||||||
pill, Promote / Reset / Delete buttons on row 2.
|
|
||||||
- [ ] Promote `alice` to admin → page revalidates, badge flips to
|
|
||||||
`admin`.
|
|
||||||
- [ ] Demote back to `user`.
|
|
||||||
- [ ] **Last-admin guard**: Demote / Delete on the only remaining
|
|
||||||
admin row are both disabled.
|
|
||||||
- [ ] Delete `alice` via the confirm dialog (Cancel + Delete user
|
|
||||||
buttons; **no third "Close" button** — the static guard test
|
|
||||||
catches that regression but eyeball it anyway).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Account pairing
|
|
||||||
|
|
||||||
- [ ] `/accounts` → New Account → label `WaBot Test` → Pair WhatsApp.
|
|
||||||
Land on the live QR page within ~2 s.
|
|
||||||
- [ ] Login screen header is JUST the centered brand mark — no nav,
|
|
||||||
no menu drawer.
|
|
||||||
- [ ] Scan with WhatsApp → "Linked Devices" → "Link a device".
|
|
||||||
- [ ] **Connection success.** Page transitions through `qr` → (brief
|
|
||||||
`restart-required` close handled silently) → `connected` with
|
|
||||||
a green check and `+60xxx` phone number → auto-redirect to
|
|
||||||
`/accounts/<id>` after 3 s.
|
|
||||||
- [ ] **Refresh Groups** button on `/accounts/<id>/groups` → spinner
|
|
||||||
during the sync, page auto-refreshes when the bot pushes
|
|
||||||
`groups.synced` over SSE. No manual reload needed.
|
|
||||||
|
|
||||||
### Pair regression checks (these caught real bugs)
|
|
||||||
|
|
||||||
- [ ] **Back → Re-pair**: from a live QR, click ← Back → Pair again
|
|
||||||
from the account detail page. Should NOT instantly flash
|
|
||||||
"Pairing timed out". A new QR appears and the countdown
|
|
||||||
restarts at 5:00.
|
|
||||||
- [ ] **Duplicate phone**: with one phone already paired, scan its QR
|
|
||||||
from a *second* account row → see the amber "Phone already
|
|
||||||
linked" panel naming the existing account. The original
|
|
||||||
account's session stays intact.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Reminder lifecycle
|
|
||||||
|
|
||||||
- [ ] `/reminders` → New Reminder → walk the wizard:
|
|
||||||
- Step 1: pick `WaBot Test`.
|
|
||||||
- Step 2: enter a short text message ("smoke test <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.
|
|
||||||
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