20 Commits

Author SHA1 Message Date
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
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
cfd3308477 feat(media): unsupported image/video/audio formats fall back to document delivery
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>
2026-05-10 13:07:54 +08:00
c7a6f5f1b0 feat: humanise cron in list summaries; magic-byte detect HEIC; sidebar brand link tests
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>
2026-05-10 12:59:13 +08:00
82b00508f0 feat(uploads): per-kind WhatsApp media size limits, lift Server Action body cap
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>
2026-05-10 12:35:41 +08:00
32319feeea fix(reminders): edit paused/ended one-off; lock list order; collapse swipe after action
Three small bugs the user surfaced after the swipe rollout:

1. Editing a paused/ended one-off reminder threw "Time is in the past"
   ----------------------------------------------------------------------
   The four edit-section pages (account / message / groups / when) all
   POST through `updateReminderAction`. The action's "scheduledAt
   must be in the future" check fires on every submit — including the
   three section pages that don't change the time and just pass the
   original `scheduledAt` straight through. So a user editing the
   message body of a reminder they paused yesterday saw their save
   rejected with "Time is in the past".

   New pure helper `validateUpdateScheduledAt` in lib/reminder-update.ts
   keeps the future-time check in place for active reminders that are
   actually changing the time, but allows past timestamps when:
     - the reminder is paused or ended (won't fire while in those
       states regardless of what the row says about scheduledAt), OR
     - the submitted timestamp matches the existing one within a
       second of rounding (the form is a passthrough).

   Tests: 10 cases in `lib/reminder-update.test.ts` covering active
   future, active past, paused passthrough, ended passthrough, paused
   with deliberate change, sub-second drift tolerance, exact-NOW edge,
   null existing scheduledAt, malformed ISO.

   Also (drive-by, related): `updateReminderAction` no longer force-
   sets `status: "active"` on save. Editing a paused reminder's
   message shouldn't silently un-pause it. The user uses Restart for
   that.

2. Reminder list reshuffled after Pause/Restart
   ----------------------------------------------------------------------
   The list defaulted to `sort=scheduled_desc`, so clicking Restart on
   row N (which moves scheduledAt forward to the next occurrence) flipped
   the row to row 0. Felt like the wrong action ran. Fixed:
     - Page now hard-codes `sort = "created_desc"` (created_at never
       changes, so a row stays where it is).
     - Sort dropdown removed from `<ReminderFilterBar>` since it has
       nothing to drive anymore. Account + Group filters and the
       search box stay.

3. Swipe shelf stayed open after the action ran
   ----------------------------------------------------------------------
   `SwipeableRow` keeps its offset in component state. When a shelf
   button submits the form, the page revalidates and re-renders, but
   React keeps the same row instance (matched by `key={reminder.id}`),
   so the open offset stuck around. Now both row sites encode the
   "row state" into the key:
     - reminders: `key={\`${reminder.id}-${reminder.status}\`}`
     - activity:  `key={\`${run.id}-${run.archivedAt ? "1" : "0"}\`}`
   Status flip → key change → React unmounts/remounts → offset back
   to 0 → shelf closed. Costs nothing (these rows are cheap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:29:10 +08:00
704bc5e788 feat(activity): swipe-to-archive/delete; quieter send-test toast
Two unrelated bits the user asked for in the same breath:

1. Activity row swipe-to-reveal actions
   ----------------------------------------
   On the mobile activity tab, drag a row left to reveal an Archive
   button (Restore when already archived) and a Delete button. Past a
   60 px threshold the shelf locks open; below that it springs back.
   Tapping anywhere outside an open row closes it. Desktop keeps a
   table layout but gains the same two row-level icon-buttons in a
   new Actions column, since hover-then-discover is more natural with
   a mouse than a swipe.

   - New `<SwipeableRow>` (apps/web/src/components/swipeable-row.tsx)
     — pointer-events only (no third-party gesture lib), 130 lines.
     The drag math lives in a pure helper `computeSwipeNext` so it's
     unit-testable without a DOM.

   - Migration 0007 adds `reminder_runs.archived_at timestamptz`
     (null = visible by default, non-null = archived). Soft-archive
     keeps the row queryable under a new "Archived" filter tab; hard
     Delete drops the row entirely (run_targets cascade via FK).

   - Server actions: `archiveRunAction` / `unarchiveRunAction` /
     `deleteRunAction`. Each rate-limits to 30/min/IP. Ownership
     check piggybacks on the same operator-or-orphan rule the
     activity query already uses.

   - `listActivityRuns(operatorId, { archived })` extended to filter
     in or out of the archived window. Default is archived: false so
     the existing tabs (All / Success / Partial / Failed / Skipped)
     keep showing only live runs.

   - Tests
     * `swipeable-row.test.tsx` — 6 unit tests covering the drag math
       (clamp at 0 / -SHELF_WIDTH, snap-to-closed below threshold,
       snap-to-open at or past threshold, snap math respects the
       previous offset) plus 2 SSR markup contracts (data-testid /
       aria-hidden / starts at translateX(0px) / data-state="closed").
     * Total web suite: 154 passing (was 146).

2. Send-test toast text trim
   ----------------------------------------
   "Sent ✓ — check the WhatsApp group." → "Sent ✓". The trailing
   note told the user something they could already see (they're the
   one who clicked Send Test on a specific group). Less noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:20:05 +08:00
b71dbadef1 feat(reminders): multi-message stack with mid-stream media swap
Reminders can now deliver a stack of message parts in send order. The
DB and bot's fire-reminder loop already supported this — only the UI
and the server action's input shape were single-message. This change
makes the whole flow stack-aware end-to-end.

What's new
----------
A reminder is now a list of MessagePart objects:

    { kind: "text",  textContent: "Hi",   mediaId: null  }
    { kind: "media", textContent: "cap",  mediaId: uuid  }
    { kind: "media", textContent: null,   mediaId: uuid  }

The bot fires them in order with ~1.5 s spacing (already the case in
fire-reminder.ts).

Cap of 10 parts per reminder. Anything more clutters the URL beyond
the 2KB practical budget for the wizard's encoded `messages=…` param.

Where this shows up
-------------------
1. `<MessageStack>` — new shared component (apps/web/src/components/
   message-stack.tsx). Each block is either a text Textarea or a
   media block (file picker + preview + caption Input). Per-block
   move-up / move-down / delete buttons. "+ Add text" / "+ Add file"
   buttons at the bottom. Reused by both the wizard's compose step
   AND the per-section Edit Message page.

2. Edit Message page — was a single Textarea + read-only attachment
   indicator with a "Replacing it isn't supported" note. Now uses
   MessageStack and lets the operator add/remove/reorder parts AND
   swap the file on a media block, fixing
   the asked-for "should let user change media files too" gap.

3. Wizard — Compose / When / Groups / Review pass a single
   `messages=<urlencoded JSON>` param instead of three separate
   text/mediaId/caption fields. The Review step renders one row per
   part, with file names resolved from the DB so users see "menu.pdf"
   not an opaque uuid. Every step accepts the legacy fields too and
   folds them into the new shape on entry, so older bookmarked URLs
   keep working.

4. Server actions (createReminder / updateReminder) accept either:
     - The new `messages: MessagePart[]` field, OR
     - The legacy `text` / `mediaId` / `caption` triple,
   and resolve to a flat parts list before doing anything else. Both
   actions then write one row per part into `reminder_messages` with
   a sequential `position` column, replacing the old "always 1 row"
   logic in updateReminderAction.

5. The reminder name (visible in lists, detail header, etc.) is
   sourced from the first part with a non-empty text body — falling
   back to the literal "Reminder" if every part is media-without-
   caption. Capped at 50 chars to fit the existing column.

Wire-format helpers
-------------------
New `lib/reminder-messages.ts`:
- `MessagePart` interface (the canonical shape)
- `isValidMessagePart` — reject empty texts and orphan-mediaId rows
- `encodeMessages` / `decodeMessages` — URI-encoded JSON, drops
  invalid entries, returns null when nothing valid is left
- `legacyMessageToParts` — synthesise a one-element stack from the
  old text/mediaId/caption fields (used by step pages on entry)

Tests (15 + 5 = 20 new; 146 total, was 132 + adjustment)
--------------------------------------------------------
- `lib/reminder-messages.test.ts`: round-trip a non-trivial stack;
  survive URL-unsafe characters in text (\\n, & = % #); reject
  null / empty / garbage; drop invalid entries; legacy-fallback paths.
- `edit-message-form.test.tsx`: rewrites for the new prop shape
  (initialMessages instead of initialText/initialMediaId/initialCaption);
  asserts the form renders one block per initial part and that media
  filename appears in the SSR markup.
- `no-render-warnings.test.tsx`: same prop-shape update for the two
  EditMessageForm hydration / button-nesting guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:15:37 +08:00
f681be9deb feat: full timestamp on accounts list; Duplicate action on reminder detail
Two small additions:

1. Accounts list "Last connected" line: bumped from a date-only string
   ("10 May 2026") to a full timestamp with day, month, year, hour
   (12-hour with AM/PM), minute, second — same KL timezone, en-MY
   locale. Useful for diagnosing recent disconnects vs old ones at a
   glance.

2. New \`duplicateReminderAction\` server action plus a fourth card on
   the reminder detail's ActionsBar (Pause / Restart / Duplicate /
   Delete). The action copies the source reminder's account, groups,
   message parts, and schedule (rrule unchanged). The new row starts
   \`paused\` so it doesn't fire on top of the original — operator
   tweaks the schedule from the detail page and Restarts when ready.
   Name is suffixed with " (copy)" (capped at 60 chars).

   ActionsBar grid bumped from 3-column to 4-column at lg, with a 2x2
   fallback at sm so it doesn't get cramped on narrower screens.

Test mock for actions-bar.test.tsx widened to include the new action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:03:41 +08:00
5f1897daa5 feat(recurrence): full crontab support — sec/min/hour/day/month/dow combinational
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>
2026-05-10 10:25:47 +08:00
50df7fcb11 feat(reminders): search + filter + sort on the list, Pause/Restart/Delete on detail
/reminders list
- New ReminderFilterBar (client component, URL-driven):
  * Free-text search across reminder name, first message text,
    account label, and target group names. Debounced 250 ms.
  * Account dropdown — filters to one paired account.
  * Group dropdown — narrows to a single group; auto-scoped to the
    chosen account.
  * Sort dropdown — Newest first / Oldest first / Recently created /
    Name A→Z. Default is `scheduled_desc`.
- Status tabs (All / Active / Ended / Paused) preserve all other
  filter params when flipping, so changing tab doesn't lose context.
- Empty-state copy is filter-aware ("No reminders match your filters."
  vs "No <status> reminders yet.").
- Pure helpers in `lib/reminder-filter.ts` so the same q+account+
  group+status+sort logic can be unit-tested without a DB.

/reminders/[id] detail
- New ActionsBar (Pause / Restart / Delete) replaces the bare delete
  button. Each card is a transparent <button> overlay over a Card
  (no <button>-wrapping-Card — the static guard keeps it that way).
  Confirm dialogs gate every destructive action.
  - Pause: visible only when status === "active"; flips to "paused".
  - Restart: visible when status is "paused" or "ended". For a
    recurring reminder, computes the next occurrence from the RRULE
    and re-arms pg-boss; for a one-off reminder it sets the next
    fire to "now + 1 minute".
  - Delete: always available (run history is preserved on /activity).

Server actions
- `pauseReminderAction(formData)` — sets status="paused" if active.
- `restartReminderAction(formData)` — recomputes next fire and
  re-arms via pg_notify(`reminder.schedule`).
- The existing deleteReminderAction is reused.

`lib/queries.ts#listReminders`
- Now also returns accountId, group ids, joined group names, and the
  first message text — fields the search/filter logic needs.
- Coerces SQL timestamp strings to Date objects (raw `db.execute(sql)`
  hands them back as strings, which broke .getTime() in the sorter).

Tests (+22 new, 130 web tests + 26 bot tests = 156 across the repo)
- lib/reminder-filter.test.ts (16 tests):
  * search hits across all four indexed fields, case-insensitive
  * account / group / status filters
  * every sort key, including handling of null scheduledAt
  * combined AND-of-all-filters check
- app/reminders/[id]/actions-bar.test.tsx (6 tests):
  * Pause card only shown for `active`
  * Restart card only shown for `paused` / `ended`
  * Delete card always rendered
  * Restart description differs for recurring vs one-off
  * every confirm dialog carries the matching `reminderId` hidden input

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:11:46 +08:00
ba9e50fec0 feat: dashboard navigation, preserve run history, QR refresh fix
Dashboard
- Stat cards are now clickable: Accounts → /accounts, Active reminders →
  /reminders?filter=active, Recent runs → /reminders.
- Recent activity rows link to the underlying reminder when it still
  exists. Runs whose reminder has been deleted render with a "(deleted)"
  marker and stay non-clickable.
- New "Clear history" action wipes all run rows the operator owns plus
  any orphan rows (reminderId=NULL).

Run history persists after reminder delete
- reminder_runs.reminder_id is now nullable with ON DELETE SET NULL, so
  deleting a reminder no longer cascade-erases its history.
- New reminder_runs.reminder_name column snapshots the name at fire
  time so history rows stay readable even after the reminder is gone.
- Fire-reminder records the snapshot.
- Dashboard query LEFT JOINs and COALESCEs name from the live reminder,
  the snapshot, or "(deleted reminder)" as last resort.

QR
- Drop the 25 s server-side throttle. With listener accumulation already
  fixed (previous commit), the payload-equality dedupe is enough.
  Symptom: after the first QR expired the throttle blocked the next
  emit, and the QR never refreshed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:27:53 +08:00
f19ea03e0d feat: edit reminders, mature recurrence, QR throttle, more tests
Reminders
- Reminder list / detail show recurrence summary ("Every Mon, Wed, Fri",
  "Every 2 weeks until 2027-01-01", etc).
- Detail page reorganised: each section (Account / Message / When /
  Groups) is itself a clickable card that deep-links into the wizard
  step in edit mode (editReminderId URL param). No standalone Edit
  button. Run history stays read-only.
- New /reminders/[id]/edit shell loads the row, encodes its state into
  wizard URL params, and forwards to /reminders/new. The wizard
  threads editReminderId through every step.
- updateReminderAction: validates ownership of both the existing
  reminder and the (possibly changed) target account, replaces targets
  + messages wholesale, re-arms the pg-boss job (singleton key picks
  up the new fire time).
- Wizard submit branches to updateReminderAction when editReminderId
  is set; button reads "Save changes" / "Saving…".
- Wizard default first-fire is now the current minute in the operator
  zone (not now+1h). Same-minute clicks bump silently to next minute
  via a 60 s grace window so the user isn't punished.
- /reminders empty state is filter-aware: "No failed reminders yet."
  when ?filter=failed and there are reminders in other states.

Recurrence
- Spec is now a structured object: { kind, interval, weeklyDays,
  monthDay, end }. Builder produces RRULEs with INTERVAL, BYDAY,
  BYMONTHDAY, COUNT, UNTIL as appropriate. specFromRrule round-trips
  for resuming/edit.
- When-step UI: frequency pills, "Every N days/weeks/…" interval,
  weekday picker (weekly), day-of-month input (monthly), end picker
  (Never / After N occurrences / On date), live human-readable
  summary preview.

QR pairing
- Throttle QR refresh to once per 25 s and detach the previous
  per-account session listener on Re-pair so listeners can't
  accumulate. The UI countdown was flicking every ~5 s because each
  Re-pair attached an extra listener — every Baileys QR event then
  triggered a fresh DB write + NOTIFY.

Tests (60 green total, +33 in this batch)
- recurrence.test.ts: extended to 25 tests covering interval,
  monthday, end conditions (COUNT/UNTIL), and round-trip parsing.
- date-picker.test.ts: 14 tests for splitDateTime / combineDateTime /
  validateScheduledAt (incl. the "click-too-fast" same-minute grace)
  and defaultFirstFireIso.
- /api/qr/[accountId] route.test.ts: 4 tests — 404 when no QR yet,
  404 on missing row, 200 with image/png + no-store + correct PNG
  bytes, and verifies the where-clause queries by accountId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:22:22 +08:00
2b738383e4 feat: recurring reminders, fix QR pairing, account UX polish, tests
Reminders
- Add recurrence to wizard step 3 (None / Daily / Weekly+weekday picker /
  Monthly / Yearly). Build the RRULE client-side and thread it through
  the wizard URL state.
- Action stores rrule + scheduleKind="recurring" on insert.
- Bot reschedules the next occurrence after firing a recurring reminder
  using the existing rrule helpers in @cmbot/shared. One-off behavior
  unchanged.
- Add reminders.last_fired_at column to track last fire.

Pairing
- Move QR PNG out of the pg_notify payload (the 8000-byte limit was
  silently truncating it; QR never reached the web → "QR hang"). PNG
  now lives on whatsapp_accounts.last_qr_png; NOTIFY just signals
  {type: session.qr, accountId, ts}. Web fetches the bytes from a new
  read-only /api/qr/[accountId] route (allowed via middleware).
- handleStartPairing now stops any in-flight session before starting a
  fresh one — fixes Re-pair where session.start was a silent no-op and
  Baileys never re-emitted QR.
- Pair-live: countdown moved out from over the QR (it was overlapping
  the scan area); shown as a discrete progress bar above the QR.
- Add a "Save QR" download button.

Account detail page
- Pair / Unpair / Delete cards are themselves the trigger (form submit
  or DialogTrigger) — no inline buttons, whole card is clickable.
- Sync Groups Now card removed earlier; bot already auto-syncs.

Account list page
- Cards are the link target. A small floating Delete trigger (top-right
  trash icon) opens the destructive confirm dialog without blocking
  navigation on the rest of the card.

Tests
- recurrence.test.ts: 10 tests for buildRrule / kindFromRrule /
  describeRecurrence (incl. weekly day combos and BYDAY ordering).
- reminders.schema.test.ts: regression for the "Invalid datetime" bug —
  proves strict Zod .datetime() rejected luxon's offset ISO and the
  { offset: true } option accepts both forms.

Migration: 0004_next_prowler.sql
- whatsapp_accounts.last_qr_png (text)
- reminders.last_fired_at (timestamptz)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:01:31 +08:00
86f2fe0124 fix(web): reminder wizard date/time picker, reorder, optional groups
- Fix "Invalid datetime" error: createReminderAction's Zod schema rejected
  offset-suffixed ISO strings (luxon's `toISO()` produces +08:00 form).
  Switched to `.datetime({ offset: true })`.

- Replace the single datetime-local input with separate native date + time
  inputs (proper UI pickers on both desktop and mobile). Default value is
  now computed server-side ("now + 1h") and passed in as a prop, so first
  render is fully populated and there's no SSR/client hydration mismatch
  from `Date.now()` inside the client component. Removed the quick-pick
  shortcuts.

- Reorder wizard steps: Account → Compose → When → Groups → Review.
  Groups is now the last and optional step (Continue button reads
  "Skip groups" when empty); the action accepts an empty array and
  inserts no reminder_targets in that case.

- Account list: card is the link target. Removed inline Pair / Open /
  Delete quick-action buttons; lifecycle actions stay on the detail page.

- Account detail: removed the "Sync Groups Now" card. The bot already
  auto-syncs on `groups.upsert` / `groups.update` events. The Groups card
  itself is now a clickable link instead of carrying an inline View
  button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:45:19 +08:00
9437df74ee feat(web): split Add Account from Pair; add Unpair/Re-pair/Delete actions
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).
2026-05-10 00:27:33 +08:00
3d470069d3 feat(web): create reminder + media upload server actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:48:35 +08:00
6916f5a0ed feat(web): delete reminder server action wired to detail page 2026-05-09 23:46:23 +08:00
83a19d4800 feat(web): send-test server action wired into group detail 2026-05-09 23:44:22 +08:00
68b46f8d71 feat(web): pair / unpair / sync server actions + live QR page 2026-05-09 23:42:16 +08:00