235 Commits

Author SHA1 Message Date
050292a282 feat(web): bare login header — only centred brand mark
The login page lived inside the authenticated AppShell, so the desktop
sidebar (with all nav items) and the mobile menu drawer were rendering
on the sign-in screen. AppShell now branches on pathname=/login and
renders a single centred header (cm + WhatsApp Bot) with no nav, plus
the form. Drops the redundant in-card title since the header carries
the brand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:14:03 +08:00
1b7f553e24 fix(db): bump 0010/0011 journal timestamps so drizzle applies them
drizzle's migrator skips entries whose 'when' is older than the latest
applied migration's recorded created_at. 0010 (1778405570914) and 0011
(1778405817706) were generated before 0009's manually-set when of
1778464000000, so 'pnpm migrate' reported success but never ran the
auth + telegram-drop migrations against any DB whose 0009 had landed.

Bumping 0010/0011 to 0009.when + 1s/+2s makes the timestamps strictly
monotonic so future drizzle migrate runs apply them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:13:55 +08:00
b29d137c84 feat: production hardening — robots, allowedOrigins, container non-root, rate limits, CLI bootstrap
robots.ts + metadata.robots blocks indexing.
serverActions.allowedOrigins gates cross-origin Server Action posts.
Bot + web Dockerfiles add a non-root 'app' user (uid 1000) with
chmod 700 on /data/sessions.
sendTestAction grows a per-group rate limit (3/60s).
resumeReminderRunAction + cancelReminderRunAction get a per-IP
rate limit (30/10s).
.env.example documents every required key.
packages/db/src/scripts/{set-password,create-user}.ts + thin shell
wrappers in scripts/ — first admin sets their password via
./scripts/set-password.sh admin before signing in.
2026-05-10 18:05:34 +08:00
67091c294a feat(web): user-management surface (admin only)
createUserAction, setUserRoleAction, resetUserPasswordAction,
deleteUserAction — all gated by requireAdmin(). Self-demote and
last-admin guards prevent the operator from accidentally locking
themselves out. /settings/users page lists every operator with
inline Demote/Promote, Reset password, and Delete buttons. 10 unit
tests.
2026-05-10 18:01:09 +08:00
b77a9d106d feat(web): middleware gates non-allowlisted paths on session cookie
Edge-runtime check via auth-cookie.verifySession. /api/* paths get a
401 (no body) when unauthenticated; pages get a 307 to /login with
the original path encoded into ?next=. Allowlist explicitly excludes
/api/events and /api/qr — both were unauthenticated in v1.1.0 and
let an unauthenticated client snoop the entire SSE event stream and
enumerate paired account QR codes.
2026-05-10 17:57:07 +08:00
5b4787d10e fix(web): typed-routes + redirect-mock signatures in auth.ts
Next.js 16 typed-routes (experimental.typedRoutes in next.config.ts)
narrows redirect()'s parameter to RouteImpl<T>, which a runtime
string from the form can't satisfy. Cast to any with a comment for
the two redirect call sites in auth.ts.

The auth.test.ts redirectMock used `() =>` zero-arg signature, which
typescript rejected once the action started passing the path through.
Change to `(_path: string) =>` so the signature matches and the test
still passes (vitest's esbuild-transpiled run was fine; tsc caught it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:54:59 +08:00
4f1056cdcd feat(web): /login page with username + password form
Server-rendered card-style login. Form posts to loginAction; on
failure the client renders the generic 'Invalid username or
password' error. Centred, mobile-first, autocomplete-friendly so
phone PWAs autofill from the keychain on subsequent logins.
2026-05-10 17:52:35 +08:00
cedd623466 feat(web): loginAction + logoutAction (with TDD)
Username + password verified against the operators row, bcrypt
compare regardless of user-found state for timing equivalence,
DUMMY_HASH precomputed and committed. 10/5min IP rate limit, no
password ever logged. Issues a 30-day HttpOnly+Secure+SameSite=Lax
cookie on success, redirects via safeRedirect(next). 12 unit tests
covering correct creds, wrong username, wrong password, missing
password_hash, empty/long inputs, case-insensitive match, rate-limit
trigger, no-password-leak, safe redirect, unsafe redirect, logout.
2026-05-10 17:50:41 +08:00
d236196476 feat(web): getCurrentUser / requireUser / requireAdmin helpers
Reads the session cookie from next/headers, verifies via auth-cookie,
loads the operators row, returns the shape every existing call site
expects (.id, .defaultTimezone, etc) plus the new .role and
.username. getSeededOperator stays as a thin compat shim that
delegates to getCurrentUser, so the ~12 tests that mock
@/lib/operator keep working without churn.
2026-05-10 17:46:16 +08:00
e1ba1da2de feat(web): safeRedirect helper for the login \?next= param
Falls back to / for anything that isn't a single-slash-prefixed
relative path. Locks out protocol-relative (//evil.com), absolute
(https://evil.com), and javascript: redirects. 7 tests cover the
full attacker matrix.
2026-05-10 17:44:10 +08:00
27b7a3df1f feat(web): edge-safe HMAC-signed session cookie
signSession + verifySession run on Edge runtime (Web Crypto only).
Verifier checks signature (constant-time compare), expiry, clock-skew
on iat (60s tolerance), token version vs OPERATOR_TOKEN_VERSION env,
and role-shape sanity. 11 unit tests cover round-trip plus every
rejection path attackers could probe.
2026-05-10 17:43:01 +08:00
838e129f37 chore: add bcryptjs to web + db packages
Pure-JS bcrypt for password hashing. Avoids the native-build pain
of node-bcrypt in our Alpine Docker images. Login is a rare event
so the perf gap is irrelevant for our scale.
2026-05-10 17:41:06 +08:00
46c0315559 refactor(db): drop operators.telegram_user_id (not used since v1.0)
The Telegram bot phase ended in Plan 3 — the operator now signs in
via username + password. Migration 0011 drops the legacy column +
its unique index. seed.ts no longer reads SEED_OPERATOR_TELEGRAM_ID;
docker-compose.base.yml swaps the env to SEED_OPERATOR_USERNAME
(default 'admin'); .env.development follows. Settings page shows
'Username' instead of 'Operator ID'. Auth-and-prod-hardening plan
doc updated to drop the synthetic telegram_user_id from the
create-user CLI script and createUserAction insert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:39:46 +08:00
a37b36196d feat(db): add username + password_hash to operators
Migration 0010 widens the existing operators table for username +
password auth. Backfills 'admin' on the seed row so the NOT NULL
constraint succeeds; password_hash stays nullable so the operator is
forced to set one via scripts/set-password.sh before they can sign in.
Adds a unique index on lower(username).

seed.ts also picks up the new username field (defaults to 'admin' so
re-running scripts/db.sh seed stays idempotent against the backfilled row).
2026-05-10 17:35:01 +08:00
477e09f645 docs: implementation plan — auth + production hardening
10 tasks, TDD-shaped, executable by superpowers:subagent-driven-development.
~50 unit tests across auth-cookie / safe-redirect / auth helpers /
loginAction / middleware / user-management actions, covering brute-
force, cookie tampering, replay, expiry, fixation, open redirect,
timing-equivalence on user-not-found, rate-limit trigger, no-
password-leak in logs, role gates, last-admin / self-demote guards,
and the unauth-API regression for /api/events + /api/qr.

Plan honours the project's .gitignore policy of keeping
.env.development committed; ships .env.example for documentation
instead of forcing repo-level removal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:19:20 +08:00
feffe419db docs: design spec — auth + production hardening for v1.1.x → v1.2.0
Drives the work that closes the v1.1.0 production-readiness audit
findings: username + password + role auth on the web app, gated
SSE / QR endpoints, robots/noindex, env hygiene, container non-
root, and rate limits on the four currently-naked Server Actions.

Auth design highlights:
* Roll-our-own session cookie (no NextAuth) — bcrypt password +
  HMAC-SHA256 signed cookie; edge-runtime middleware verifies on
  every request; defense-in-depth requireUser / requireAdmin in
  every Server Action.
* Username + password + 2-role model (admin / user). Schema
  migration adds username + password_hash to existing operators
  table.
* CLI bootstrap (scripts/set-password.sh) sets the first admin's
  password before going live; user management UI gates everything
  else.
* OPERATOR_TOKEN_VERSION env var as a global session-invalidation
  lever.
* 38 unit tests covering brute-force / cookie tampering / replay /
  expiry / fixation / open redirect / timing leak / rate limit /
  origin-allowlist / unauth API regression / role gates / self-
  demote and last-admin guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:09:46 +08:00
4cb4015666 fix(bot): dedupe duplicate reminder.fire jobs (msg sent twice)
Observed: reminder fired twice within ~2s. The bot logs showed two
distinct pg-boss jobIds for the same reminder enqueued at the same
scheduledAt — both ran fire-reminder, both sent the message.

Root cause: pg-boss's `singletonKey` only deduplicates on queues with
a 'singleton' / 'stately' / 'short' policy. Our queue was created
without specifying a policy, defaulting to 'standard', which IGNORES
the singletonKey. Two sends with the same key produced two jobs.

Fix lives at two layers:

* Layer 1 — queue policy. createQueue(REMINDER_FIRE_QUEUE) now
  passes `{ policy: 'stately' }`. With this, future fresh deploys
  fold a duplicate send (same singletonKey) into the existing
  'created' job rather than producing a second one. This doesn't
  retroactively change an existing queue's policy (pg-boss doesn't
  support that), but new queues are correct from creation.

* Layer 2 — defense-in-depth check inside fireReminder. Before
  acquiring the per-account mutex, query reminderRuns for any row
  with the same reminderId fired in the last 30s. If found, log
  + bail. This guards against:
    - Existing queues stuck on policy='standard'.
    - Race windows even within 'stately' policy.
    - The operator double-clicking Save in the wizard.
    - A jittery pg_notify('bot.command') replay.
  Resume jobs (payload.runId set) skip this check — they're meant
  to attach to an existing run.

Tests:
* New "BAILS OUT when a fresh fire collides with a recent run" case
  in fire-reminder.test.ts.
* beforeEach now resets findExistingRunMock too, since both the
  resume and dedupe paths share that mock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.1.0
2026-05-10 16:41:11 +08:00
be3f28a1e6 refactor(web,bot,db): rename reminder status 'ended' → 'inactive'
The 'ended' label read like a terminal failure state ("the reminder
gave up") when in practice it just means "this reminder isn't going
to fire on its own — restart it if you want it back". 'inactive' is
the more accurate read.

* SQL migration 0009 backfills existing rows.
* Bot fire-reminder writes 'inactive' on one-off completion / no
  further occurrences.
* Web actions, queries, filters, and reminder lifecycle gates updated.
* Dashboard counter card label "Active / Paused / Ended / Total"
  becomes "Active / Paused / Inactive / Total".
* Reminders list filter tab "Ended" becomes "Inactive".
* Status pill style key renamed to match.
* Tests updated alongside the runtime changes.

Also: the "Pause sending by" deadline opt-in now renders as a
visible card-shaped row with hover state + Set/Off label on the
right, so the toggle is discoverable instead of a tiny native
checkbox tucked next to the label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:32:53 +08:00
2e1defaef6 feat(web): "Pause sending by" deadline is opt-in via a checkbox
Wizard When-step and the per-section Edit-when page now gate the
HourSelect behind a checkbox. The control reads "[ ] Pause sending
by (optional)" by default — checking it reveals the hour picker;
unchecking hides it again.

The off-state is encoded as deliveryWindowEndHour=24 (next-day
midnight) so the bot's existing windowEndAt helper produces an end
that's always in the future for any reminder fired the same day,
making the gate effectively never trip. This avoids a NULL-allowing
schema migration while still giving the operator a clean "no
deadline" mode.

Existing reminders:
  • Stored 24 → checkbox starts UNCHECKED, picker hidden.
  • Stored anything else → checkbox starts CHECKED, picker shows
    the saved value.
  • Unsupplied (legacy rows) → checkbox starts UNCHECKED.

RunEtaPill picks up an optional `windowEndAt` prop. When omitted —
the no-deadline path — it renders a neutral grey pill with just the
ETA, skipping the green "Fits before deadline" / amber "Likely to
pause" comparison that wouldn't be meaningful without a deadline.

Tests:
* when-form-deadline.test.tsx (4) — fresh / 24 / real-hour /
  optional-hint paths.
* run-eta-pill.test.tsx (+1) — neutral pill when windowEndAt is
  undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:25:44 +08:00
309020fa5d feat(bot): sweep stale 'pending' runs on startup
Corner case observed: fire-reminder writes the run row with
status='pending' UP FRONT (so the Activity tab shows progress
mid-run), then flips to a terminal status once it's done. If the
bot is killed between those two writes — e.g. a redeploy or crash —
the row sits at 'pending' forever. pg-boss already marked the job
'completed', so it won't retry. Activity surfaces and the dashboard
counters then show a "stuck" run that never moves.

sweepStalePendingRuns runs at bot startup, finds any 'pending' run
older than 5 minutes, and:
  • Flips the run to 'failed' with a clear error_summary so the UI
    stops treating it as in-flight.
  • Flips its still-'pending' run_target rows to 'skipped' with the
    same reason so per-group counts remain coherent.

The 5-minute floor is generous enough that an actual mid-run worker
rebalance isn't accidentally killed.

Tests:
* 4 sweep tests covering: no-stale path skips the second UPDATE;
  with-stale path fires both UPDATEs; counts are forwarded; the
  edge case where a stale run has zero pending targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:05:18 +08:00
bb8d28a594 feat(web): paused-run banner + Activity Paused filter / Resume button
Reminder detail page:

* Surfaces a PausedRunBanner above the rest of the surface when the
  most recent run is in 'paused' state. The banner shows the
  delivered/total counts, the deadline that closed the window, and
  Resume / Cancel run buttons that call the matching server actions.
* getReminderWithRuns now LEFT JOIN-aggregates run_target counts so
  the banner has sent/total per run without an N+1 fan-out.

Activity tab:

* New Paused filter tab between Success and Partial.
* Paused rows in the desktop table get an inline ResumeRunButton
  (emerald play icon, useTransition + error surfacing).
* RunStatusBadge picks up a Paused entry — amber, PauseCircle icon.

Tests:
* PausedRunBanner — 4 SSR cases (resume/cancel CTA rendered, X-of-Y
  copy, generic fallback, amber styling).
* ResumeRunButton — 4 SSR cases (aria, emerald accent, compact /
  default size variants).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:58:06 +08:00
376bbe595b feat(web,bot): resumeReminderRunAction + cancelReminderRunAction
Web actions:

* resumeReminderRunAction({ runId }) → validates ownership and that
  the run is in 'paused' state, then publishes a reminder.resume
  command via pg_notify('bot.command'). The bot's command-consumer
  picks it up and enqueues a fresh pg-boss job at REMINDER_FIRE_QUEUE
  carrying { reminderId, runId }; fire-reminder's existing resume
  branch attaches to the row.
* cancelReminderRunAction({ runId }) → flips remaining 'pending'
  targets to 'skipped' with error="canceled by operator", marks the
  run 'partial' with a clear errorSummary, and lifts the parent
  reminder out of 'paused' (recurring → active so the next
  occurrence fires; one-off → ended).

Bot:

* New BotCommand variant { type: "reminder.resume"; reminderId; runId }
* command-consumer registers handleResumeReminder which calls
  enqueueReminderResume(boss, reminderId, runId) — a sibling of
  scheduleReminderFire that posts the job at REMINDER_FIRE_QUEUE
  with { reminderId, runId } and singletonKey "reminder:resume:<runId>"
  so the resume doesn't conflict with a future-occurrence schedule.

Tests:
* reminders.run-actions.test.ts (11 tests) — every guard rail
  (invalid uuid, missing run, missing reminder, foreign operator,
  wrong status) and the recurring/one-off lifecycle branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:54:21 +08:00
57786f9d09 feat(bot,web): window-end gate + paused/resume run lifecycle
fire-reminder.ts now:

* Computes windowEnd via @cmbot/shared/windowEndAt(timezone, endHour,
  now). Per-target loop trips the gate before sending; pending rows
  are LEFT pending (not flipped to skipped) so the run is resumable.
* Accepts an optional runId on the FireReminderPayload. When set,
  the handler ATTACHES to that existing run instead of creating a
  new one and only re-tries pending targets. Resume is allowed even
  when the reminder.status is 'paused' (otherwise we couldn't drag
  it back into delivery).
* Final-status logic adds a 'paused' branch (window closed mid-run
  with at least one row still pending AND something delivered);
  failed when window closed before any send; partial / success
  otherwise.
* Lifecycle: a paused run flips the reminder row to status='paused'
  and skips the recurring re-arm. Resuming or completing later
  flips it back to 'active'.
* SSE event payload gains optional sent/total counts.

reminderFiredToNotification picks up:
* New 'paused' headline + 'X of Y groups delivered. Tap to resume
  or cancel.' body.
* 'partial' body uses sent/total when present.

WebEventMap and the bot's WebEvent union match the new shape.

Tests:
* fire-reminder.test.ts gains a "resume against paused reminder
  acquires mutex" case.
* notifications.test.ts gains 3 paused/partial-sent body cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:48:52 +08:00
670eaf493c feat(web): swipeable account rows, editable label, disabled-account guard
Accounts list (mobile):
* Each row is a SwipeableRow. Swipe right reveals Pair (or Unpair if
  the account is connected). Swipe left reveals Groups + Delete.
* The right shelf widens to 176px so two buttons fit comfortably; a
  new \`leftShelfWidth\`/\`rightShelfWidth\` prop on SwipeableRow drives
  the override (default 88 stays for single-button shelves).

Accounts list (desktop): unchanged grid of clickable cards.

Account detail:
* New "Name" card at the top opens /accounts/[id]/edit/label, the
  dedicated rename surface (mirrors the reminder edit-name pattern).
* Paired-at row now shows the full timestamp ("10 May 2026, 3:33:04 pm")
  via toLocaleString instead of toLocaleDateString.

Reminder wizard:
* Disconnected accounts on the "Account" step are no longer
  clickable. They render as a non-link with aria-disabled, dimmed
  to opacity-50 with cursor-not-allowed and a "Pair this account
  before scheduling a reminder from it" tooltip. The bot has no
  live session for those accounts, so this prevents broken submits.

renameAccountAction validates the label, rejects duplicates within
the same operator, and revalidates /accounts and the detail page.

Tests added:
* AccountSwipeableRow — 6 SSR tests (shelves rendered, conditional
  Pair/Unpair, Groups + Delete shelf, 176px shelf width, hidden
  accountId field).
* EditAccountLabelForm — 5 SSR + payload tests (prefill, Save
  button, required + maxLength=60, action call shape).
* StepAccount — 4 SSR tests (connected → Link, disconnected → no
  Link + aria-disabled, opacity/cursor styles, "Not connected"
  copy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.0.0
2026-05-10 15:42:10 +08:00
c166a09fdb fix(web): client bundle no longer pulls in node-only rrule code
The wizard's RunEtaPill imported windowEndAt from the @cmbot/shared
barrel, which transitively re-exports rrule.ts. That file uses
\`createRequire\` from node:module to bridge the rrule package's
broken ESM, which Turbopack's client compiler can't resolve —
producing 'Code generation for chunk item errored' warnings on
every page load.

* Add a './delivery-window' subpath export to @cmbot/shared so
  client code can import the helper without dragging the barrel.
* Switch review-submit-client.tsx to that subpath.
* Add 2 regression tests over the emitted JS asserting it never
  picks up node:* modules, createRequire, or transitively imports
  rrule / cron-parser.

ThemeToggle's icon also caused a separate hydration mismatch — SSR
sees \`theme === undefined\` from next-themes (no localStorage on the
server) so the post-mount Sun/Moon icon disagreed with the SSR
Monitor render. Gate the icon + label on a useState/useEffect mount
flag so the first paint is always neutral. Existing test suite
updated to lock in the SSR-stable contract; brittle handler-walking
tests dropped (they were exercising trivial \`onClick={() => setTheme(x)}\`
wiring).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:33:47 +08:00
1d0d06d648 feat(web): floating CTA on mobile so Add/New buttons aren't a wasted row
Adds a `floatingAction` slot to PageShell. Desktop renders it inline
next to the H1 (same as before); mobile drops the entire header row
and floats the action as a fixed pill in the bottom-right corner —
the page now starts straight at content with no wasted vertical
space at the top when only an action exists.

Add Account / New Reminder buttons grow to size-12 circles on mobile
(easy thumb target) and keep the compact h-7 inline pill on desktop.
The action node is rendered twice in the tree — once inline, once
fixed — and switched via responsive utilities.

Bumps mobile bottom padding to pb-20 when a FAB is present so the
last card doesn't sit under the floating button.

Activity's "Clear history" still uses the regular `action` slot — it
keeps the inline header row on mobile because it isn't the page's
primary CTA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:27:32 +08:00
9b13223966 fix(web): tighten the page-header row — smaller buttons, less padding
Action buttons drop from size=lg with px-6 + font-semibold to a
compact size=sm pill with a subtle shadow. PageShell trims mobile
top padding from 24px to 16px and the inter-section gap from 24px
to 16px on small screens (desktop unchanged) so the header row
doesn't dominate the top of the page when the H1 is hidden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:25:08 +08:00
7697ea5fcb feat(web): PageShell narrow variant for dense single-column tabs
Adds a 'narrow' prop that wraps the body in 'max-w-2xl mx-auto'
while keeping the header chrome at the standard 5xl. Settings is
the first consumer — its rows are dense text and look adrift at
full width. The header still aligns with the other tabs so the
title position stays consistent.

Covered by 2 SSR tests (narrow path adds the inner wrapper, default
path doesn't).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:23:06 +08:00
3c3a3f57d3 refactor(web): extract EmptyState and reuse on every empty surface
One component now owns the icon / heading / helper / action stack
that the dashboard, accounts list, reminders list, and activity tab
were each rendering inline. The four duplicated 'flex-col items-center
py-12 text-center' Card blocks collapse to one shared surface so the
empty experience reads the same wherever the user lands.

Covered by 4 SSR tests (icon + title + description, omitted helper,
action slot pass-through, centring).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:21:51 +08:00
a5cb8cea46 refactor(web): extract PageShell and apply to every tab
Single component now owns the page chrome — wrapper width, padding,
vertical rhythm, and the page-header row (hidden-on-mobile H1 + an
optional right-aligned action slot). Dashboard, Accounts, Reminders,
Activity, and Settings all use it, replacing five copies of the same
\`<div className=\"max-w-5xl mx-auto px-4 ...\">\` markup.

Settings was previously \`max-w-2xl\` and \`container mx-auto\`; it
now matches the other tabs at 5xl so the chrome stays consistent.

Covered by 5 SSR tests (header order, responsive justify utilities,
wrapper class, action-optional path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:19:17 +08:00
e38b9ac7b6 feat(web): RunEtaPill at the wizard review step
Renders an advisory ETA badge above the Schedule button:
* Green "Fits before deadline" when the projected finish lands
  before the chosen deadline hour.
* Amber "Likely to pause" with a "Push the deadline later or split
  into smaller runs" hint when it doesn't.

Pill is purely informational — the operator can still schedule a
run that's likely to pause; the pause/resume flow (Phase 3) covers
that case. The pill just removes the surprise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:11:53 +08:00
e6521bd151 feat(web): run-eta helper + bigger pill-shaped add buttons
estimateRunDuration() computes a per-run ETA from a target count, a
fire time, and an assumed per-account send rate (40/min, mirroring
the bot env). Adds a 15% buffer with a 1-minute floor. Pure helper,
covered by 6 round-trip tests including the rate-defaults path.

Header CTA buttons on /accounts and /reminders are now size="lg"
rounded-full pills with a shadow that lifts on hover. Mobile shows
just the plus icon (label collapses) so the button doesn't dominate
narrow screens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:10:09 +08:00
bf49b80431 feat(web): pause-by-hour deadline + AM/PM dropdowns + dashboard tweaks
Wizard When-step and the per-section Edit When page now expose an
optional "Pause sending by" hour. Fire time IS the implicit start, so
the deadline is the only thing the operator sets. When the bot's
fan-out hasn't finished by that hour (in the reminder's timezone) the
run pauses for resume — that runtime gating lands in a later phase;
this commit just persists the hour and threads it through the wizard.

HourSelect splits hour and AM/PM into two side-by-side <select>s and
emits a single 0..23 value. to12Hour / from12Hour are pure helpers
covered by 11 round-trip tests.

Dashboard adjustments:
* "WhatsApp accounts" card now reads Connected / Unpaired / Total.
* "Reminders" card reads Active / Paused / Ended / Total.
* "Recent runs" stat card removed (the Recent activity section below
  shows the same info).
* Activity rows show absolute timestamp with AM/PM and relative time
  in tandem.

Accounts list:
* The page-level <h1>Accounts</h1> is hidden on mobile (the top bar
  already shows it), matching the Dashboard pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:07:25 +08:00
f96eea8e93 fix(web): dashboard reminders card uses X/Y; hide /accounts duplicate H1
Dashboard:
* "Active reminders" card retitled to "Reminders" and now shows
  active / total in the same X / Y format as the Accounts card
  (mirroring 2 / 3 connected / total).

Accounts list:
* The page-level <h1>Accounts</h1> is now hidden on mobile (the top
  bar already shows it), matching the Dashboard pattern. The
  "Add Account" button still shows on every breakpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:53:47 +08:00
f50a1fc0a7 feat(web): create/update actions accept delivery window hours
createReminderAction and updateReminderAction now read
deliveryWindowStartHour / deliveryWindowEndHour off the input and
persist them on the reminders row. Both fields are optional in the
input shape (default 6/18) so existing callers don't break, and a
refine validates start < end when provided.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:52:05 +08:00
7039d57a41 feat(db,shared): delivery window columns + windowEndAt helper
Adds two integer columns to the reminders table:
* delivery_window_start_hour (default 6)
* delivery_window_end_hour   (default 18)

Both are documented in the operator's timezone. End hour will gate
the runtime fire-reminder loop in a later phase; this commit just
lands the data model and the pure window-end calculator.

windowEndAt(timezone, endHour, fireAt) lives in @cmbot/shared so
both bot (window enforcement) and web (ETA preview) can import it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:48:36 +08:00
c9a7e6f089 feat(bot): cross-account parallel + same-account serial fan-out
Replaces the single-threaded, 1.5s-sleep-per-part loop with a
concurrency model that:

* Wraps inner work in PerKeyMutex(accountId) so two reminders on the
  SAME account take turns (running them concurrently would double the
  effective send rate and risk a WhatsApp ban). Different accounts run
  in parallel.
* Bumps pg-boss localConcurrency to BOT_FIRE_CONCURRENCY (default 8),
  so up to 8 different-account reminders can fire simultaneously.
* Bulk-loads groups + media in 2 queries (drops ~3000 round-trips to
  ~3 for a 1000-group run) and pre-creates run_target rows so the
  Activity tab shows progress mid-run.
* Pre-uploads each unique media via MediaUploadCache (one
  generateWAMessageContent call per mediaId, then relayMessage to
  every group). For 1000 groups × 5 MB image, this turns 5 GB of
  upload into 5 MB.
* Runs BOT_GROUP_CONCURRENCY (default 3) groups in parallel within
  one account; parts within a group stay serial so chat order is
  preserved.
* Gates every send on a per-account TokenBucket
  (BOT_MAX_SEND_PER_MINUTE, default 40).
* Replaces the rigid 1.5s inter-part sleep with 200..499 ms jitter.

Adds a unit test verifying accountMutex.run is called keyed by
accountId for active reminders, and skipped for inactive / missing.

Window enforcement, paused/resume, and ETA preview are deferred to
later phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:44:23 +08:00
7da872eb5f feat(bot): MediaUploadCache for once-per-run media prepare
One cache instance per fire-reminder run. Each unique mediaId gets
prepared (uploaded to WA CDN) exactly once, and subsequent group
sends within the run reuse the prepared message via relayMessage.
Concurrent gets coalesce into a single prepare. Failed prepares
don't poison the cache — next caller retries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:38:10 +08:00
bb58f5acf2 feat(bot): per-account token-bucket rate limiter
TokenBucket gates each socket.sendMessage call. Tokens regenerate at
ratePerMinute/60 per second, capped at one minute's worth so quiet
accounts can't burst. FIFO drain across concurrent waiters.

accountRateLimiter (singleton) hands out one bucket per accountId, so
account A's drain never throttles account B. Default rate is
BOT_MAX_SEND_PER_MINUTE (40) — the safe band for an established
WhatsApp account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:37:12 +08:00
5913706ab9 feat(bot): PerKeyMutex for accountId-keyed serialisation
Same key serialises, different keys run in parallel. Used by
fire-reminder to prevent two same-account fan-outs from doubling
the effective send rate (which would risk a WhatsApp ban). Chains
auto-clean empty entries so the Map doesn't leak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:36:22 +08:00
c5339abe1a feat(bot): fan-out tuning env vars
BOT_FIRE_CONCURRENCY (8) — pg-boss worker pool size, gates max
accounts firing fan-outs in parallel.
BOT_GROUP_CONCURRENCY (3) — per-account parallel group sends; parts
within a group stay serial so chat order is preserved.
BOT_MAX_SEND_PER_MINUTE (40) — per-account token-bucket rate.

Defaults are tuned for an established WhatsApp account
(~30-60 msg/min safe band).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:35:26 +08:00
082a70db06 docs: consolidate windowed-fanout spec/plan with ETA + paused/resume
Folds in three rounds of requirement evolution:

* Pause/resume on window close (was stop-and-report-partial).
* ETA preview pill at compose / edit time so the operator sees
  whether their chosen window will fit before scheduling.
* Interactive paused-run banner with Resume / Cancel buttons on the
  detail page; pause notification deep-links to it.

Helper relocations:

* windowEndAt() moves to packages/shared so both bot fire-reminder
  and the web ETA pill can import the same calculator.

Plan grows from 8 to 10 tasks: adds Task 9 (run-eta + RunEtaPill,
TDD) and Task 10 (resume/cancel actions + PausedRunBanner).
Acceptance gains two paused-flow smoke tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:33:51 +08:00
c4d4f1dda7 feat(web): extract reminder name to its own edit section
The name input previously lived inside the message edit page. Now that
it's a required field — and one users may want to revise without
touching the message stack — it gets a dedicated card on the reminder
detail page and its own edit route at /reminders/[id]/edit/name.

EditMessageForm receives the name as a pass-through prop so saving
messages doesn't drop the existing name from the action payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:23:23 +08:00
d5b8c0beeb feat(reminders): name is required (was optional with auto-derive)
Previously the name field auto-derived from the first text part when
the operator left it blank. That's brittle once reminders carry
multiple parts of varying provenance, and confusing in lists where
"Reminder" or partial sentences crowd in.

Now: every reminder must carry a non-empty name, capped at 60 chars.

  - Zod schema on createReminder/updateReminder: name moves from
    `z.string().nullable().optional()` to
    `z.string().trim().min(1, "Give the reminder a name").max(60)`.
    Stale-URL legacy callers that omit it now get a clear server error.
  - Wizard compose step: input has `required` + `aria-required`,
    placeholder + label simplified ("(optional)" tag and the helper
    paragraph removed), Continue blocks on empty.
  - Edit-message form: same — required, aria-required, save blocked
    on empty, the "leave blank and we'll auto-derive" hint dropped.
  - Review-submit client: defensive fail-fast for stale-bookmark URLs
    that arrive at step 5 without a name — bounces back with
    "Give the reminder a name (back on the Message step)" instead of
    letting the server reject.

The resolveReminderName helper stays put — duplicateReminderAction
and any future caller still benefit from the trim+clamp+fallback
chain. Helper unit tests unaffected (they test the resolver in
isolation, the policy-tightening lives at the schema layer above).

298 web tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:15:16 +08:00
50187a86e1 docs: design spec — windowed, pacing-safe reminder fan-out
Records the design decisions for the next planned work:

  - Per-reminder delivery window (default 6am–6pm, operator timezone).
    Window-close hard-stops the run; remaining targets become
    skipped; status reports as partial with a clear "this account is
    at capacity, consider another paired account" message.
  - Per-account isolation via pg-boss teamSize ≥ N + an in-process
    PerKeyMutex keyed by accountId. Different accounts run in
    parallel; the same account serialises (no double-rate sends
    that would risk a ban).
  - Per-account token-bucket rate limiter (default 40 msg/min,
    BOT_MAX_SEND_PER_MINUTE).
  - Up-front media-upload cache via prepareWAMessageMedia: 1000
    groups × 5 MB upload turns into 5 MB. Biggest single win for
    text+picture reminders.
  - Bounded group concurrency (default 3 in-flight per account);
    parts-within-a-group stay serial for visible message order.
  - Pre-fetched DB Maps (groups / messages / media), no inner-loop
    round-trips.
  - Replaces the rigid 1.5 s inter-part sleep with 200–500 ms
    jitter; the per-account rate-limiter is the real gate.

Out of scope for v1 (documented under "v2 candidates"): cross-day
window resume, mid-restart resumability, multi-account auto-split,
adaptive rate-limit back-off, pause/resume mid-run.

Acceptance: 1000-group reminder + one image, established account
finishes in ~30–50 minutes, well inside a 6am–6pm window. Two
reminders on different accounts at the same wall-clock minute
both progress in parallel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:01:24 +08:00
5d91b904f2 refactor(ui): hide page-title H1 on mobile (header already shows it)
Mobile header strip carries the current section title in its centre
slot ("Dashboard" / "Reminders" / etc.). The top-level pages were
ALSO rendering the same string in an H1 right below — duplicate
labelling, wasted vertical space, and the H1 was the first thing
that overlapped the header on tight viewports.

Switched the four duplicates to `hidden sm:block`:
  - / (Dashboard)
  - /reminders
  - /activity
  - /settings

Desktop sidebar has no per-page title chip, so the H1 stays visible
sm: and up. Sub-pages (account detail, group detail, reminder
detail, "New Reminder", "Add Account") have dynamic H1s that don't
duplicate the header — those keep their visibility unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:45:01 +08:00
68d3de5ee2 feat(reminders): user-supplied name; auto-derived as fallback
Reminders pick up a real, user-controlled name instead of being
auto-named from the first message body. Auto-derive stays as the
fallback so empty inputs still produce something useful.

Resolution policy (single source of truth in lib/reminder-name.ts)
------------------------------------------------------------------
1. User-supplied name, trimmed, clamped to 60 chars.
2. First text-bearing message part — text body or media caption,
   trimmed, clamped to 60.
3. Literal "Reminder" (only if every part is media-without-caption
   and no name was given).

Wizard
------
- New "Name" input above the message stack on step 2 (Compose).
  Optional (label says so), maxLength 60, placeholder gives an
  example. Blank flows through the URL as an absent param.
- The name parameter passes through every subsequent step
  (when, groups, review) via the existing URL-state pattern.
- Review step gains a "Name" row at the very top showing what the
  resolver will produce. If the user left it blank, the row shows
  the auto-derived value plus a muted "(auto from message)" tag so
  they know what's happening.

Edit forms
----------
- `EditMessageForm` gains the same Name input at the top —
  consistent with the wizard's compose step.
- `EditAccountForm` / `EditWhenForm` / `EditGroupsForm` accept the
  current `name` and forward it unchanged on save. Otherwise saving
  any of those sections would re-auto-derive the name from the
  message body, silently overriding what the operator typed.

Server action
-------------
- Both `createReminderAction` and `updateReminderAction` accept an
  optional `name` field on the schema. The body collapses through
  the new `resolveReminderName` helper, replacing the inline
  `firstLabel ?? "Reminder"` slice.

Tests (+17 new in lib/reminder-name.test.ts)
--------------------------------------------
- User priority: user name wins over message body even when both
  are present; trimming.
- Auto-derive: first text part, first non-empty after skipping
  empties, media caption when present, trims around the value.
- Fallback: null/undefined/empty stack, every-part-empty, every
  part media-without-caption.
- Clamping: user-supplied long names truncate at 60; auto-derived
  long names truncate at 60; short names pass through.
- The 60-char ceiling matches what the wizard's <Input maxLength>
  enforces and what the DB column allows.

Existing tests updated to pass the new required prop (`initialName`
on EditMessageForm, `name` on EditAccountForm/EditGroupsForm SSR
fixtures, plus a couple in no-render-warnings.test.tsx).

Total: 298 web + 31 shared + 26 bot = 355 passing (was 338).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:43:22 +08:00
8f2ee5df9e feat(web): browser notifications for reminder + send-test events
In-tab notification bridge so the operator gets a system notification
when a reminder fires successfully (or partly / fails) and when a
send-test message lands. Foundation for true background push later
(VAPID + service-worker subscription); this lands the wiring so
behaviour is testable today.

Pieces
------
- `lib/notifications.ts` — pure helper module:
    * notificationSupport / getPermission — feature detection that
      treats the SSR / unsupported-browser case as "denied" so callers
      don't have to handle a third state.
    * isOptedIn / setOptedIn — localStorage-backed opt-in flag
      (key `cmbot.notifications.optedIn`). Survives gracefully when
      window is missing or storage throws (private mode / quota).
    * showNotification(opts) — gated dispatch returning a discriminated
      result ({ ok: true, tag } | { ok: false, reason }) so callers
      can fall back to a UI toast on opt-out / unsupported / error.
    * reminderFiredToNotification + sendTestDoneToNotification —
      pure mappers from the bot's SSE events into notification args.
      Skips bookkeeping noise (status === "skipped") and failures
      that the in-page toast already shows verbatim.

- `components/notification-manager.tsx` — client component mounted
  once at the app shell. Subscribes to `reminder.fired` and
  `send_test.done` via useEvents and forwards each through the pure
  mappers. Renders no DOM.

- `components/notifications-toggle.tsx` — settings-page card with
  three states (unsupported / not-granted / granted+opted-in).
  "Send test" button fires a sample notification so the operator
  can verify the wiring without waiting for a real reminder. The
  blocked-by-browser path points them at site settings instead of
  silently doing nothing.

- `app/settings/page.tsx` — new "Notifications" card sits above
  the Appearance card.

- `app/layout.tsx` — `<NotificationManager />` rendered alongside
  `<Toaster />` inside ThemeProvider so the SSE subscription is
  active across all routes.

Bot side
--------
- `apps/bot/src/scheduler/fire-reminder.ts` — emits
  `pgNotifyWeb({ type: "reminder.fired", reminderId, runId, status })`
  after every run regardless of success/partial/failed. The web
  side decides whether to surface it as a notification (skipped is
  filtered out client-side).

- send_test.done was already emitted by `ipc/send-test-handler.ts`.

PWA service-worker tests (the original ask before this thread)
--------------------------------------------------------------
- Extracted the Serwist config into `pwa/config.ts` so the choices
  (skipWaiting, clientsClaim, navigationPreload, runtimeCaching,
  precacheEntries) are pinnable without booting a worker scope.
- 6 tests in `pwa/config.test.ts` lock the surface (no extra keys
  appear silently, the manifest passes through unchanged, the
  pinned booleans stay where production expects them).
- 6 tests in `app/manifest.webmanifest/route.test.ts` cover the
  manifest contract (display=standalone, start_url=/, dark theme
  colors match the OS, both icons are PNG + maskable, paths
  match committed PNGs in public/).

Test counts
-----------
281 web + 31 shared + 26 bot = 338 total (was 306).

  - +6 pwa/config (service-worker config pinning)
  - +6 app/manifest.webmanifest (PWA manifest contract)
  - +20 lib/notifications (full coverage of mappers + dispatch
        gates + SSR / unsupported / blocked / opted-out paths)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:30:40 +08:00
29535d6bbc docs: refresh README for web-first state of the world (P3/T24)
The status section reflected the post-Plan-1 world where pairing
went through Telegram. Reality has long since moved on:

  - Plans 1, 2, and 3 are all complete.
  - The Telegram bot is gone (removed in P3/T4).
  - The web app at wabot.04080616.xyz is the primary surface.
  - The recurrence picker, multi-message stack, swipe rows, archive
    flow, HEIC fallback, and PWA install all shipped.

Other updates:
  - Quick-start no longer mentions Telegram tokens — replaced with
    the URL the operator opens to use the app.
  - Architecture paragraph swapped grammy mention for the LISTEN/
    NOTIFY consumer + SSE shape.
  - Pointed at the new design spec
    (2026-05-09-web-app-design.md) and manual test runbook.
  - Layout / scripts tables trimmed: dropped the publish.sh and
    link-account.sh stubs that were never implemented.
  - "Deferred" section lists honest gaps (media library, E2E tests,
    auth, multi-operator).

Test counts called out (249 web + 31 shared + 26 bot = 306) so the
status claim is verifiable in a single grep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:23:04 +08:00
d407fc585e docs: manual web app end-to-end test runbook (P3/T23)
13 sections covering every flow that unit tests can't:

  1. Smoke (mobile header, sidebar, page titles, tab strips)
  2. Account pairing (live QR, sync groups, send-test)
  3. Re-pair / unpair (status flips, no spurious reconnect)
  4. Reminders — single-message one-off (5-step wizard)
  5. Reminders — multi-message stack with media swap
  6. Reminders — recurrence picker (Daily / Weekly / Monthly /
     Yearly + multi-rule, descriptions are sentences not raw cron)
  7. Reminders — edit each section preserves the message stack
     (regression guard for the parts-2..N drop bug)
  8. Reminders list — swipe + lifecycle (no reshuffle on Pause /
     Restart, shelf collapses after action)
  9. Activity — swipe archive / delete + Archived tab + restore
 10. Send-test feedback round trip (Sending… → Sent ✓)
 11. Media upload limits (size caps, HEIC/MOV → document fallback)
 12. PWA install (standalone display, offline shell)
 13. Theme toggle (desktop only)

Captures the policy decisions that aren't immediately obvious from
the code:
  - browser-extension hydration warnings are expected, app code is
    not at fault — Incognito clears them
  - HEIC/MOV uploads are NOT rejected; they fall back to document
    delivery
  - cron rules render as descriptive sentences in the UI
  - status changes don't reorder the reminders list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:22:04 +08:00