cm_whatsapp_bot_v1/apps/web/src/lib/reminder-messages.ts
yiekheng 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

76 lines
2.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* A single deliverable part of a reminder. The bot's fire-reminder loop
* walks these in order with ~1.5 s spacing between sends. The DB stores
* one row per part in `reminder_messages` keyed by `position`.
*
* { kind: "text", textContent: "...", mediaId: null } -> plain text
* { kind: "media", textContent: "...", mediaId: "uuid" } -> file w/ caption
* { kind: "media", textContent: null, mediaId: "uuid" } -> file w/o caption
*
* `textContent` for a `media` part is the caption (sent by the bot via
* the WhatsApp API's caption field, not as a separate text message).
*/
export interface MessagePart {
kind: "text" | "media";
textContent: string | null;
mediaId: string | null;
}
export function isValidMessagePart(p: unknown): p is MessagePart {
if (!p || typeof p !== "object") return false;
const m = p as Partial<MessagePart>;
if (m.kind !== "text" && m.kind !== "media") return false;
if (m.textContent !== null && typeof m.textContent !== "string") return false;
if (m.mediaId !== null && typeof m.mediaId !== "string") return false;
if (m.kind === "text" && (!m.textContent || !m.textContent.trim())) return false;
if (m.kind === "media" && !m.mediaId) return false;
return true;
}
/**
* Serialise a message stack into a single URL param. URI-encoded JSON
* is shorter than base64-of-JSON for typical short-message stacks and
* stays inside the ~2KB URL budget for 510 messages.
*/
export function encodeMessages(messages: MessagePart[]): string {
return encodeURIComponent(JSON.stringify(messages));
}
export function decodeMessages(s: string | null | undefined): MessagePart[] | null {
if (!s) return null;
try {
const parsed = JSON.parse(decodeURIComponent(s));
if (!Array.isArray(parsed)) return null;
const out = parsed.filter(isValidMessagePart);
return out.length > 0 ? out : null;
} catch {
return null;
}
}
/**
* Backward-compat: if the wizard URL still carries the legacy single-
* message fields (`text` / `mediaId` / `caption`) — bookmarked links,
* old emails, etc. — synthesise a one-element MessagePart[] from them
* so the new schema sees a uniform shape.
*/
export function legacyMessageToParts(
text: string | null | undefined,
mediaId: string | null | undefined,
caption: string | null | undefined,
): MessagePart[] | null {
if (mediaId) {
return [
{
kind: "media",
mediaId,
textContent: caption?.trim() || text?.trim() || null,
},
];
}
if (text && text.trim()) {
return [{ kind: "text", textContent: text, mediaId: null }];
}
return null;
}