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 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>
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>
Two changes in one cut, both per the user's redesign asks:
1. Bring the recurrence picker INLINE into the When form section.
The dialog is gone — the type tabs and per-type config now live
directly under the date+time inputs:
[ Starts on ] [ Time ]
Repeats
┌──────────────────────────────────────────────────┐
│ Schedule 1 [✕] │
│ [Daily] [Weekly] [Monthly] [Yearly] │
│ <per-tab config> │
│ Every weekday at 09:00 │
├──────────────────────────────────────────────────┤
│ Schedule 2 [✕] │
│ [Daily] [Weekly] [Monthly] [Yearly] │
│ <per-tab config> │
│ Every Friday at 17:00 │
└──────────────────────────────────────────────────┘
[+ Add another schedule]
2. Allow multiple recurrence rules per reminder. Each row is its own
tab strip + config; the picker compiles them down to a single
newline-joined CRON: rule. Empty list = "Don't repeat" (one-off).
MAX_RULES is 8.
Storage stays the same (`reminders.rrule`, `CRON:` sentinel). The
multi-rule format is just newline-separated cron expressions:
CRON:0 9 * * 1
0 17 * * 5
`@cmbot/shared` updates to support that:
- nextOccurrence: splits on newline, computes the next match for
each rule independently, returns the earliest. Malformed lines
are skipped (so one bad rule doesn't kill the whole schedule).
- validateMinInterval: validates every line; any single line firing
more often than the 5-min minimum fails the whole rule.
Removed: the standalone modal Dialog wrapper, Reset/Cancel/Save
buttons, and the saved-vs-draft synchronisation. The picker now
edits state directly and the parent form's Save commits everything
at once (consistent with the date+time inputs that have always
behaved that way).
Tests (+3 in shared rrule.test.ts; total 20 shared + 26 bot + 132 web
= 178)
- nextOccurrence on a multi-line cron picks the earliest:
* "0 9 * * 1\n0 17 * * 5" starting Saturday → Mon 09:00 KL
* Same rule starting Tuesday → Fri 17:00 KL
- nextOccurrence ignores malformed lines and still returns the next
match from the valid ones.
- validateMinInterval: passes a clean two-line rule; rejects a rule
containing a too-frequent line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The custom RRULE panel covers most patterns but can't express "every
weekday at 9, 12, and 18" or "every 15 minutes within working hours"
in a single rule. RRULE's BYxxx fields can technically combine, but
the picker UX gets unwieldy fast. Cron expressions cover everything
in one line.
Storage / dispatch
- Cron rules live in the same `reminders.rrule` column with a sentinel
prefix: `CRON:0 9 * * 1-5`. No schema change.
- `@cmbot/shared` now exports:
CRON_PREFIX, isCronRule, stripCronPrefix, validateCronExpression
- `nextOccurrence(rule, tz, after)` and `validateMinInterval(rule, tz)`
detect the prefix and dispatch to `cron-parser`; non-cron rules
continue to flow through rrule unchanged.
- `cron-parser@^5.5.0` added as an explicit dep on @cmbot/shared
(it was already transitively present via pg-boss).
Server actions
- `createReminderAction` / `updateReminderAction`: when rrule has the
CRON: prefix, the user's date+time inputs are ignored — the action
validates the cron, runs the min-interval check (5 min between
fires), and computes scheduledAt as the next match of the cron
expression after now. The bot's existing fire-reminder loop
re-arms via `nextOccurrence` after each fire, which already speaks
cron via the dispatch above.
Picker
- New "Cron expression…" preset at the bottom of the radio list:
"Full sec/min/hour/day/month/dow combinational power"
Selecting it reveals a CronPanel:
* font-mono cron input (5- or 6-field accepted)
* inline examples: 0 9 * * 1-5, */15 * * * *, 0 9,12,18 * * *,
0 0 1 * *
* note that the Date+Time controls above are ignored once a cron
expression is set
- RecurrenceSpec gains an optional `cron` string and a new `kind: "cron"`.
- buildRrule emits `CRON:<expr>` for cron specs.
- specFromRrule round-trips a CRON-prefixed rule back into the spec.
- describeRecurrence renders "Cron: <expr>" so the list view and
review steps show the expression.
Tests (+10; 17 shared + 26 bot + 138 web = 181 total)
- packages/shared rrule.test.ts (+8):
* CRON_PREFIX / isCronRule / stripCronPrefix
* nextOccurrence on a CRON rule returns the right next match in the
operator timezone (e.g. weekday 9 AM KL ↔ exact UTC instant)
* RRULE rules still flow through unchanged
* validateMinInterval on cron: hourly OK, every-minute rejected,
malformed string returns a useful error
* validateCronExpression positive + negative cases
- recurrence.test.ts (+5): cron preset round-trip, label assertions,
`buildRrule`/`specFromRrule` for cron specs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshape the account lifecycle to match how operators actually want to
work the system:
- Add Account → creates a row with status='unpaired'. No QR yet; the
operator lands on the detail page.
- Pair / Re-pair → transitions an unpaired account to status='pending'
and opens the live QR flow. Works for first-time pair AND for re-pair
of an account that was previously unpaired.
- Unpair → asks the bot to stop the live Baileys session and clean
session files; sets status='unpaired' but KEEPS the row (and its
reminders) so the operator can re-pair without retyping anything.
- Delete → permanently removes the account and cascades to its groups,
reminders, run history.
Schema:
- whatsapp_groups.account_id and reminders.account_id now have
ON DELETE CASCADE so deleting an account fans out cleanly.
UI:
- /accounts list shows everything except the transient 'pending' state.
- /accounts/[id] shows state-aware buttons: Pair (when unpaired/banned/
disconnected), Sync + Unpair (when connected), Delete (always).
- /accounts/new is now an "Add Account" form (label only).
Other fixes:
- next.config.ts: allowedDevOrigins includes 192.168.0.253 +
test/rexwa subdomains so Server Actions work across the LAN.
- packages/shared/src/rrule.ts: rrule@2.8.1 has no exports field and
ships ESM that some bundlers can't resolve via default OR named
import. Use createRequire to bridge — works under both NodeNext
(bot runtime) and Turbopack (web SSR).