Repro: scan QR window once → click Back → click Pair again → instantly
see 'Pairing timed out' (sometimes for several attempts in a row).
Root cause: when handleStartPairing hits a still-running session it
calls await sessionManager.stop(accountId) and immediately attaches a
fresh listener. session.close() resolves before sessionManager broadcasts
the close event to listeners (handleEvent has several awaits between
close arriving and the listener fan-out). The new listener was already
attached by then and saw the OLD session's close as if it were the new
session timing out — flipped the row to unpaired and pushed
session.timeout to the UI.
Fix: track a per-account 'pairingWarmingUp' Set. The new attempt enters
warming-up the moment its listener attaches; clears on the first qr or
open (those events can only come from the freshly-started session). A
close that arrives while still warming is logged and ignored. abandonPair
also clears the flag for safety.
Also drop the redundant Admin card from /settings — the Admin nav entry
on the sidebar/drawer already routes admins to /settings/users, the
extra card was duplicate UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DialogFooter showCloseButton was rendering a third button (Close) next
to the Cancel + 'Yes, delete' / 'Yes, unpair' pair. The corner X icon
already closes the dialog, so the extra button was just visual noise.
Drop the prop on both account-card dialogs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DialogTrigger asChild + transparent button overlay pattern wasn't
emitting a clickable button in the rendered DOM under radix-ui 1.4 +
Next 16 (server component context), so Delete and Unpair both became
no-ops. Replace each with a small client component that:
- holds open-state for the confirm Dialog
- drives the Card itself as the click target via role='button',
tabIndex, onClick, and Enter/Space keydown handlers
- calls the server action through useTransition
The Card stays a div (no <button> wrapping a Card → satisfies the
existing static-guard test). Removed the unused inline Dialog imports
and unpair/delete icons from the page.
Also trim the forgot-password dialog body to one sentence per request
('don't write too detail').
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Login page: replace static 'Forget Password? Contact IT' line with a
proper dialog button. Clicking opens an explanatory dialog (self-
service reset is intentionally disabled; admins can reset from
/settings/users or run scripts/set-password.sh).
- /settings: drop the 'cm WhatsApp Bot · self-hosted' tagline.
- /accounts/[id]: Unpair + Delete cards weren't responding to clicks.
Restructure so the transparent <button> overlay is a sibling of
<Card> inside a <div className='relative'> wrapper (mirrors the
working Pair/Re-pair pattern). The previous layout placed the
DialogTrigger inside the Card, which produced no clickable button
in the rendered DOM under radix-ui 1.4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-fix batch from a rapid feedback round:
- Password policy mirrors Facebook's documented rule (≥6 chars + mix of
letters with numbers/symbols). Centralised in
apps/web/src/lib/password-policy.ts; createUserAction,
resetUserPasswordAction, the AddUser form, and the row Reset-password
flow all use it. CLI scripts/set-password.ts inlines the same check
so the bootstrap path stays consistent.
- App shell adds a Sign-out button in both the desktop sidebar footer
and the mobile drawer footer, with the signed-in username next to it.
Layout passes username down alongside role. Theme toggle was removed
from the shell per request — operators don't need it in the chrome.
- Dashboard stats: getDashboardStats was running findMany on reminders
with NO operator filter, so a brand-new user saw global counts from
every tenant. Switched to an INNER JOIN on whatsapp_accounts so the
card on / only counts this user's reminders. (Counts had been showing
'1 / 1 / 3 / 5' to a fresh user — the cross-tenant leak the user
flagged.)
- /activity drops the All tab and the Clear-history button. Default
filter is now Success when no ?filter= is set; Partial keeps fanning
into Paused + Failed; Skipped still merges into Archived.
- /settings drops the Display name row entirely and only shows the Role
row to admins. Layout receives username so the shell can also surface
it next to the Sign-out button.
- Tests: password-policy.test.ts (11 cases), updated users.test.ts to
use policy-compliant passwords + cover letters-only / digits-only
rejection, sidebar-footer assertion swapped from theme-toggle to the
new Sign-out + username markup. 453 tests green; typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New AddUserFormClient on /settings/users (admin-only): username +
password + role select. Wraps createUserAction.
- UserRowClient gains an isLastAdmin prop and a confirm-dialog before
delete. Demote and Delete are both disabled on the last remaining
admin so an admin can't lock everyone out via the UI (server-side
guards in users.ts already cover the API).
- Page passes isLastAdmin per row and computes adminCount once.
- Role badge uses emerald for admin / slate for user; explicit Promote
/ Demote arrows replace the bidirectional icon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add an Admin nav item (key 'admin', href /settings/users) with
visibleTo=['admin'] so signed-in users with role='user' don't see it.
- nav-config exposes navItemsForRole(role) helper that filters NAV_ITEMS
by visibleTo.
- Root layout fetches getCurrentUser() and forwards role into AppShell.
AppShell narrows the role gate to the rendered nav (sidebar + drawer);
/login still short-circuits to the bare header. Unknown role falls
back to 'user' visibility (defense-in-depth).
- Settings page renders an admin-only card linking to Users so admins
have a discoverable in-app entry point too.
Tests:
- nav-config: navItemsForRole admin/user matrix + admin entry shape.
- app-shell: admin link visible for admin, hidden for user, hidden for
null/unauthenticated, /login bare header strips nav entirely.
- actions/auth: cookie payload encodes role=user, unknown role rejected,
AUTH_SECRET-unset path, whitespace-only username rejected, rate-limit
key contains client IP, unknown-user path still hits DB+bcrypt.
440 tests now (was 423).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Activity filter tabs drop Partial and Skipped; Partial runs now appear
under both Paused and Failed (anything that didn't fully succeed),
Skipped runs surface under Archived (history the operator chose not
to send). Five tabs left: All / Success / Paused / Failed / Archived.
- listActivityRuns flips skipped runs out of the default list and into
the archived view at the SQL layer so pagination stays correct.
- Tabs row spans the full width and wraps onto a second row when the
viewport can't fit them. Account-filter select also span full width
on every breakpoint instead of capping at sm:max-w-xs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Setting Secure on http://localhost cookies works in Chrome (localhost
exception) but Firefox/Safari silently drop them, so dev users hit
'redirect to /login on every click' after a 'successful' login. Switch
to secure: NODE_ENV === 'production'. Public deploy still gets
Secure-only.
Also swap the login footer copy from a CLI hint to 'Forget Password?
Contact IT' — operator-friendly, doesn't leak the bootstrap
mechanism on the public sign-in screen.
Test updated to assert secure=true under prod NODE_ENV and a new test
locks in secure=false in dev.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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.
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.
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.
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>
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.
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.
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The web app is now installable on a phone home screen with offline
fallback for static assets and the navigation shell.
Pieces
------
- `src/app/manifest.webmanifest/route.ts` — dynamic manifest route.
Standalone display mode, portrait orientation, dark theme matching
the app, "any maskable" icons so the same PNG works for both
regular launchers and Android adaptive icons.
- `src/pwa/sw.ts` — service worker entry. Uses serwist's stock
recipe: skipWaiting + clientsClaim so a new worker takes over on
the next navigation, navigationPreload to race the network with
the worker boot, and `defaultCache` for HTML-network-first /
static-cache-first / image+font cache TTLs.
- `next.config.ts` — wraps the existing config with `withSerwistInit`.
Disabled in development (`NODE_ENV !== "production"`) because a
service worker on every dev reload makes hot-reload extremely
flaky.
- `package.json` build script switched to `next build --webpack`.
`@serwist/next` doesn't yet support Turbopack (it logs a warning
and silently skips emitting `sw.js`), and Next 16 defaults the
build to Turbopack. The dev server still uses Turbopack — only
production builds switch to webpack.
- `src/app/layout.tsx` metadata gains `manifest`, `icons.icon` (192
+ 512 PNG), and `icons.apple` (180 PNG). The existing
`appleWebApp.capable` already opts iOS into standalone mode.
Icons
-----
Generated by a tiny one-shot script (`scripts/gen-pwa-icons.ts`)
that uses the workspace's already-installed sharp to render an SVG
wordmark at 512 / 192 / 180 px. Placeholder branding (dark square
with "cm" wordmark) — swap in real artwork later by editing the SVG
in the script and re-running `pnpm --filter @cmbot/web run gen:icons`.
Build artefacts
---------------
- `apps/web/public/icon-512.png`, `icon-192.png`,
`apple-touch-icon.png` ARE committed (stable input).
- `apps/web/public/sw.js` and `swe-worker-*.js` are NOT — they're
regenerated on every production build. Added to `.gitignore`.
Verification
------------
- Production build emits `[serwist] Bundling the service worker
script with the URL '/sw.js' and the scope '/'...` and `sw.js`
shows up in `public/`.
- `/manifest.webmanifest` is in the build's static-route table.
- 249 web tests still passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old behaviour: HEIC/AVIF photos, .mov / .webm / .mkv videos, and niche
audio (FLAC, etc.) got rejected outright at upload with "Images are
not supported" / "Videos are not supported" errors. Strict but
unfriendly — recipients could still receive these as a downloadable
file via WhatsApp's document path; we just weren't using it.
New behaviour: anything not playable inline gets routed through the
document path automatically. The recipient downloads the file and
opens it in their default app. The 100 MB document cap applies
instead of the inline 5 / 16 / 16 MB caps. Only oversized uploads
get rejected.
Where the policy lives
----------------------
The classifier moved into a new `@cmbot/shared/whatsapp-media`
module so the web upload validator AND the bot's fire-reminder send
path share one source of truth:
- resolveDeliveryKind(mime, bytes?) → "image" | "video" | "audio"
| "document". Native types stay as-is; HEIF / AVIF / QuickTime /
WebM / Matroska / non-MP3-or-M4A audio all collapse to "document".
- Bytes argument is optional but recommended — sniffing the first
12 bytes of the file catches iOS Safari's habit of labelling
a HEIC as image/jpeg or a .mov as video/mp4. Bytes win when they
disagree with the mime.
Web side
--------
- `lib/whatsapp-media.ts` re-exports the shared helpers and keeps
only the validator + byte-formatter. `validateForWhatsApp` calls
resolveDeliveryKind internally; the size cap it returns is for the
RESOLVED kind (so a HEIC routes to document and gets the 100 MB
cap). The "Images are not supported" / "Videos are not supported"
rejection messages are gone — there's no format rejection anymore.
- `actions/media.ts` collapses the previous explicit-mime + byte-sniff
pair into a single `validateForWhatsApp(mime, size, bytes)` call.
- Compose-step upload-zone hint updated to spell out the per-kind
caps: "JPEG/PNG up to 5 MB · MP4/3GP up to 16 MB · MP3/M4A/OGG
up to 16 MB · documents up to 100 MB".
Bot side
--------
- `fire-reminder.ts` reads the first 12 bytes of the file before
dispatching and calls `resolveDeliveryKind(mimeType, head)` to
pick the senderKind. So a HEIC on disk (whose mime claims
image/jpeg) gets sent via Baileys' document path — no failed
thumbnail extraction, message arrives as a downloadable .heic.
- New `readHeadBytes(filePath, n)` helper opens, reads N bytes,
closes — no full-file slurp.
Tests
-----
249 web + 31 shared + 26 bot = 306 passing total.
Web (`lib/whatsapp-media.test.ts`):
- "HEIC at 30 MB allowed: routes to document (100 MB cap)"
- "HEIC at 110 MB rejects: exceeds the document cap"
- "MOV at 50 MB allowed (would be 16 MB cap as video, 100 MB as
document)"
- "MOV pretending to be mp4 demotes to document (50 MB allowed)"
- "FLAC audio routes to document path"
- "genuine MP4 byte-sniff path keeps it as video"
Shared (`packages/shared/src/whatsapp-media.test.ts`, new):
- The cross-package contract: 11 tests covering size limits,
classifyMediaKind, resolveDeliveryKind for native + demoted +
byte-sniff cases, plus the underlying helpers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three threads from the recent UX iteration:
1. Reminder list / detail no longer shows raw "Cron: 32 11 * * *"
----------------------------------------------------------------
`describeRecurrence` for a kind=cron spec used to emit
"Cron: <expr>" verbatim, which is unreadable on the list row's
recurrence line.
New pure helper `describeCronRule(rule)` parses the cron shapes
the recurrence picker produces and renders them as natural
sentences:
"0 9 * * *" → "Every day at 09:00"
"0 9 * * 1-5" → "Every week on Mon, Tue, Wed, Thu, Fri at 09:00"
"0 9 * * 1,3,5" → "Every week on Mon, Wed, Fri at 09:00"
"0 9 1,15 * *" → "Every month on days 1, 15 at 09:00"
"0 9 13 5 *" → "Every year in May on day 13 at 09:00"
"30 17 1,15 1,4,7,10 *" → "Every year in Jan, Apr, Jul, Oct on days 1, 15 at 17:30"
Multi-line rules ("0 9 * * 1\n0 17 * * 5") join the per-line
descriptions with " · " for compactness in the list density.
Long DOM lists (>6 days) collapse with a "+N more" tail to keep
the line short; same convention the picker's per-row preview uses.
Unrecognised shapes (e.g. "*/5 * * * *") fall back to the raw
expression — better than swallowing entirely.
2. HEIC/AVIF magic-byte sniffing at upload
----------------------------------------------------------------
The mime-only check we shipped earlier missed iOS Safari's
habit of uploading HEIC photos with Content-Type: image/jpeg.
The file then made it to the bot, where Sharp's HEIF decoder
plugin is missing, the thumbnail extraction failed, and the
message went out without a working preview — read by the user
as "image still not send".
New helper `sniffUnsupportedImage(bytes)` reads bytes 4..11 of
the upload and looks for the ISOBMFF "ftyp" marker followed by
one of the brands Sharp can't decode (HEIF: heic / heix / hevc
/ heim / heis / mif1 / msf1; AVIF: avif / avis). Brand match is
case-insensitive. Plain JPEG / PNG / unrelated ftyp brands like
mp4 are not flagged.
`uploadMediaAction` now runs the sniff against the buffered
bytes before persisting, returning the same "Images are not
supported, please re-upload images" error as the mime path.
3. Sidebar brand link → dashboard tests
----------------------------------------------------------------
Asserts the desktop <aside> contains an <a href="/" aria-label=
"Go to dashboard"> at the top, scoped via a new extractSidebar
helper so it can't accidentally match the mobile-header brand
link (which uses aria-label="Go home"). A second test confirms
the two aria-labels stay distinct.
22 web test files / 232 passing (was 212):
- +12 cron-description cases in lib/recurrence.test.ts
- +6 magic-byte sniff cases in lib/whatsapp-media.test.ts
- +2 sidebar-brand-link cases in app-shell.test.tsx
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new SSR tests in `app-shell.test.tsx`:
- Desktop sidebar's brand header is now a link, not a static <div>.
Asserts the <aside> contains an <a href="/" aria-label="Go to
dashboard"> at the top. The slice is scoped to the <aside> via a
new `extractSidebar` helper so it can't accidentally match the
mobile-header brand link.
- Mobile and desktop brand links carry distinct aria-labels
("Go home" vs "Go to dashboard"). On a wide window where the
desktop sidebar is visible alongside the (sm:hidden) mobile
header — which technically can't happen at any one breakpoint
but is a useful invariant for screen readers in split-screen /
zoom contexts — the two announcements stay distinguishable.
Total: 212 web tests passing (was 210).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small follow-ups:
1. HEIC/HEIF/AVIF uploads now rejected at the door
----------------------------------------------------
Symptom: an iPhone-shot image uploaded fine but came through on
WhatsApp without a thumbnail. Bot logs:
failed to obtain extra info
heif: Error while loading plugin: Support for this compression
format has not been built in
Cause: the bot container's Sharp ships without a HEIF/AVIF
decoder, so the thumbnail-extraction step Baileys runs throws and
the message is sent without a preview.
Fix: the upload validator (`validateForWhatsApp`) now rejects the
HEIF family before the file ever reaches the action body. Error
message: "Images are not supported, please re-upload images".
New tests in `lib/whatsapp-media.test.ts`:
- `isUnsupportedImageMime` recognises image/heic, image/heif,
image/heic-sequence, image/avif (case-insensitive).
- `isUnsupportedImageMime` does NOT flag jpeg/png/webp/gif.
- `validateForWhatsApp` rejects a HEIC upload regardless of size,
even below the 5 MB image cap.
2. Desktop sidebar brand is now a link to /
----------------------------------------------------
The mobile header brand pill was already a link to /; the desktop
sidebar version was a static <div>, so clicking the "cm WhatsApp
Bot" header in the sidebar did nothing. Wrapped in <Link href="/">
with `aria-label="Go to dashboard"` and a hover background to
make the affordance obvious.
3. Activity tab strip switched from full-width to scrollable
----------------------------------------------------
The activity page has six tabs (All / Success / Partial / Failed
/ Skipped / Archived) — packing them into a `w-full` row at h-8
left every label squeezed to ~50px on mobile. Wrapped the
<TabsList> in an `overflow-x-auto` scroller (with negative
horizontal margins so the strip extends to the page edges and the
first/last tabs aren't clipped) so each tab keeps a comfortable
touch target on phones; on desktop the row fits naturally and no
scroll bar appears.
Reminders page kept its full-width layout — only 4 tabs there,
they don't crowd.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 new SSR tests in app-shell.test.tsx:
Mobile header
- Fixed top header is rendered with `sm:hidden` so it disappears on
the desktop breakpoint.
- Brand mark on the left links home and carries `aria-label="Go home"`.
- Page title in the centre is derived from usePathname:
* "/" → "Dashboard"
* "/accounts/123" → "Accounts" (sub-route falls back to parent label)
* unknown route → generic "WhatsApp Bot"
- Menu button on the right is labelled `aria-label="Open menu"`.
Menu drawer (Sheet primitives mocked transparent so SSR shows content)
- Renders one nav link per NAV_ITEM, in declared order.
- The active route's link gets `aria-current="page"`; others don't.
- Dashboard ("/") matches by exact equality, not by `startsWith`, so
every page doesn't get marked Dashboard.
- The drawer does NOT include the theme toggle — it lives only in
the desktop sidebar footer per the recent product call.
- Drawer header carries the brand wording and the SR-only nav-menu
description.
Desktop sidebar
- Renders with `hidden sm:flex` (mobile-hidden, desktop-visible).
- All NAV_ITEMS appear.
- Theme toggle is present in the sidebar footer.
Plus the small follow-up the user pointed at:
UI: status tabs span the full row
- The shadcn `<TabsList>` defaults to `inline-flex w-fit`, which
packed Active/Ended/Paused into a tight cluster on the left of
the reminders + activity pages. Added `w-full` to both
`<TabsList>` invocations so the tabs distribute evenly across
the available row width (`flex-1` on each `<TabsTrigger>` already
handles even widths once the parent stretches).
Total: 206 web tests passing (was 194; +12 from app-shell.test.tsx).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Several user-reported bugs and UX nits fixed in one cut:
1. Editing account / when / groups silently dropped messages 2..N
--------------------------------------------------------------
Symptom: a reminder with 3 message parts came back with 1 after
the user edited any section other than the message itself.
Cause: the three section forms were still on the legacy
{text, mediaId, caption} prop shape. The parent pages pulled only
messages[0] from the DB, reduced it to those three fields, and
the form posted them through to updateReminderAction. The action
then folded the legacy fields into a single MessagePart and
replaced the whole reminder_messages row set — wiping parts 2..N
even though the user only meant to change the schedule.
Fix: each form (edit-account / edit-when / edit-groups) now takes
the full `messages: MessagePart[]` and forwards it unchanged. The
three parent pages load the full stack (sorted by position) and
pass it through.
Test: new edit-section-forms.test.tsx asserts a 3-part stack
reaches updateReminderAction intact for both the account-form and
groups-form code paths, plus a sanity test that the legacy
single-message payload shape (without `messages`) is what a
future regression would look like.
2. Reminders list: removed the Group filter
--------------------------------------------------------------
Per request — Account + Search already cover the use cases the
Group filter was supposed to. Search even matches group names
directly, so the dropdown was redundant. Page no longer fetches
the groups table for its filter bar at all.
3. Mobile chrome: bottom nav → top header w/ menu drawer
--------------------------------------------------------------
Removed the bottom tab bar. Mobile now has a single-row top
header:
┌──┐ ┌────┐
│cm│ <current page title> │menu│
└──┘ └────┘
- Brand mark on the left links home.
- Current page title sits in the middle so the user always knows
where they are.
- Menu icon on the right opens a right-side Sheet (radix Dialog)
containing the full nav list. Active item highlighted; the
drawer auto-closes when a nav item is clicked (effect on the
pathname change).
- Theme toggle stays only in the desktop sidebar footer per the
follow-up ask.
Main content padding adjusted: pt-16 (mobile) for the h-14
header, no bottom padding now.
4. Cleaned up the now-unused legacy props
--------------------------------------------------------------
`text` / `mediaId` / `caption` removed from the three section
form prop types. The wizard's URL-state pass-through still
accepts the legacy fields and folds them into the new
`messages` shape on entry, so old bookmarked /reminders/new
URLs still work.
194 passing web tests (was 194; net 0 — the new edit-section-forms
tests replaced coverage we lost when the legacy props went away).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom
-------
The upload action rejected anything over 50 MB with a flat
"File too large (>50MB)" — a number that was both too generous for
images (WA caps at 5 MB) and too restrictive for documents (WA
allows 100 MB). And anything over 1 MB was being rejected even
earlier by Next's default Server Action body limit, with a much
less actionable error.
Fix
---
1. New `lib/whatsapp-media.ts` resolves an uploaded file's MIME type
to a WhatsApp delivery kind and validates it against the
per-kind cap that WA actually enforces:
image → 5 MB image/* except sticker-mode
video → 16 MB video/*
audio → 16 MB audio/*
document → 100 MB anything else (PDFs, office docs, …)
Anything not recognised as image/video/audio falls through to
"document", which is also the Baileys sender path the bot uses
to deliver it. So a .zip or .csv ends up correctly classified
AND correctly limited to the document cap.
Error messages now name the kind and show both the actual size
and the cap: "Image too large (5.2 MB > 5.0 MB limit on
WhatsApp)".
2. `next.config.ts` lifts the Server Action body limit from the 1 MB
default to 100 MB, so document uploads actually reach the action
instead of getting bounced at the framework boundary. The WA
per-kind validator inside the action enforces the real limit
from there.
3. The compose-step upload zone hint now reflects the per-kind caps
("Image up to 5 MB · video / audio up to 16 MB · document up to
100 MB") instead of the wrong flat "up to 50 MB" value.
Tests (17 new cases, total 189)
-------------------------------
- classifyMediaKind: image/video/audio prefix routing, fall-through
to document for unknown / empty / octet-stream / text/plain.
- validateForWhatsApp: at-cap, just-under-cap, just-over-cap for
image (5 MB) / video (16 MB) / audio (16 MB) / document (100 MB);
zero-byte rejected; unknown-mime 60 MB upload accepted as document.
- WA_MAX_BYTES sanity: equals the document cap and is >= every other
per-kind limit (so it's safe to use as the framework body cap).
- formatBytes: bytes / KB (no decimals) / MB (one decimal) rendering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>