17 Commits

Author SHA1 Message Date
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
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
52126765f4 fix(unpair): allow whatsapp_groups delete by relaxing run_targets FK
Symptom
-------
Click Unpair on a connected account (or Delete Account). The web
action runs:

    DELETE FROM whatsapp_groups WHERE account_id = ?

and Postgres rejects it:

    error: update or delete on table "whatsapp_groups" violates
    foreign key constraint
    "reminder_run_targets_group_id_whatsapp_groups_id_fk"
    on table "reminder_run_targets"

Cause
-----
\`reminder_run_targets.group_id\` had a non-null FK to
whatsapp_groups.id with no ON DELETE rule (defaults to NO ACTION /
RESTRICT). So any reminder that had ever fired pinned the group rows
in place. Unpair couldn't wipe the synced groups, the action threw,
and the row never reached \`status='unpaired'\`.

Fix
---
Mirror the pattern \`reminder_runs.reminder_id\` already uses
(migration 0005): nullable column + ON DELETE SET NULL + a
denormalised label snapshot, so historical fan-out records survive a
group wipe but stay readable.

Migration 0006:
- Drop the composite \`(run_id, group_id)\` PK; add a surrogate
  \`id uuid pk default gen_random_uuid()\` since \`group_id\` can no
  longer be part of the PK once it's nullable.
- Make \`group_id\` nullable.
- Re-create the FK with ON DELETE SET NULL.
- Add \`group_label text\` for the snapshot.

fire-reminder.ts now writes the group's name into \`group_label\`
on every insert path (success / failed / skipped /
account-not-connected / group-missing) so the Activity tab can keep
showing "Sent to <Group Name>" even after the group is gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:01:16 +08:00
797917a4ba feat(recurrence): inline picker + multiple recurring schedules per reminder
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>
2026-05-10 11:09:30 +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
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
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
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
e45bcb581a fix(web,build): consume packages/db + shared via dist; bind web to LAN
Two related fixes:

1. Phone (and any LAN client) couldn't reach the web container because
   the dev compose mapped 127.0.0.1:WEB_PORT instead of binding all
   interfaces. Drop the loopback prefix.

2. Turbopack and NodeNext disagree on extension handling: bot's tsc
   needs `.js` extensions in source imports; Turbopack's transpilePackages
   path can't resolve those `.js` requests back to `.ts` source. Switch
   to consuming the workspace packages via their compiled dist instead:
   - packages/db + packages/shared point `main`/`exports` at ./dist/*
   - drop transpilePackages from next.config.ts; web picks up the
     compiled `.js` files directly
   - dev compose command for web builds shared+db before running
     `next dev` so dist is fresh when Turbopack starts
   - put the `.js` extensions back in packages/db source so NodeNext
     compilers (bot's tsc, packages/db's own tsc) are happy
2026-05-10 00:18:56 +08:00
7708dd671c feat(web): dashboard + accounts list + account detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:27:24 +08:00
17f9ee179f feat(db,web): pg_trgm + indexes + Postgres-backed cache and rate-limit
- Add cacheEntries and rateLimitBuckets tables to schema
- Generate migration 0002_left_jimmy_woo.sql with pg_trgm extension and all indexes
- Implement cache.ts (get/set/delete/getOrSet/sweep) backed by Postgres
- Implement rate-limit.ts (sliding-window UPSERT) backed by Postgres
- Implement search.ts (trigramMatch / trigramRank helpers)
- Add vitest 2.1.9 + vitest.config.ts; 7 unit tests pass (4 cache + 3 rate-limit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:03:10 +08:00
499bcf22ed fix(build): production tsc + Next.js workspace root resolution
Three small build-time fixes surfaced when the Docker images first ran
their full production build (previously only dev mode via tsx):

- packages/shared: exclude *.test.ts from tsc (vitest types not needed
  for shipped output), add @types/node dep so node:crypto resolves
- packages/db: add @types/node dep for the same reason
- apps/web: pin Next.js Turbopack root to the workspace root via
  next.config.ts so the bundler doesn't fail to detect the monorepo
  layout from inside the Docker image
2026-05-09 22:54:51 +08:00
e9f3fd6e29 fix(db): cascade reminder_runs on reminder delete
Deleting a reminder that had already fired failed with FK violation
'reminder_runs_reminder_id_reminders_id_fk'. Add ON DELETE CASCADE so
the run history is removed alongside its reminder.

reminder_run_targets cascades on run_id (already), so the chain is:
reminder → reminder_runs → reminder_run_targets, all removed in one go.
2026-05-09 17:54:20 +08:00
01eb5752ee feat(scheduler): add fire-reminder handler + job registration
Also fix rrule default-import workaround so the shared package loads
correctly under NodeNext ESM resolution (rrule@2.8.1 has no exports field).
2026-05-09 17:29:21 +08:00
1aef3e969c feat(reminders): add time-parsing + CRUD helpers 2026-05-09 17:22:00 +08:00
fa4970a76c feat(db): add drizzle schema for all tables + initial migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:19:36 +08:00
09a9f5f348 feat(shared): add rrule, media-path, timezone helpers 2026-05-09 15:16:46 +08:00