From b71dbadef130e396f8cc42b83700715c2d41cda4 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 12:15:37 +0800 Subject: [PATCH] feat(reminders): multi-message stack with mid-stream media swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. `` — 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=` 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) --- apps/web/src/actions/reminders.ts | 146 ++++--- .../app/reminders/[id]/edit/message/page.tsx | 40 +- apps/web/src/app/reminders/new/page.tsx | 4 + apps/web/src/components/message-stack.tsx | 404 ++++++++++++++++++ .../reminder-edit/edit-message-form.test.tsx | 74 ++-- .../reminder-edit/edit-message-form.tsx | 73 ++-- .../reminder-wizard/compose-form-client.tsx | 236 ++-------- .../reminder-wizard/groups-form-client.tsx | 9 +- .../reminder-wizard/review-submit-client.tsx | 13 +- .../reminder-wizard/step-compose.tsx | 45 +- .../reminder-wizard/step-groups.tsx | 62 +-- .../reminder-wizard/step-review.tsx | 122 ++++-- .../components/reminder-wizard/step-when.tsx | 30 +- .../reminder-wizard/when-form-client.tsx | 13 +- apps/web/src/lib/reminder-messages.test.ts | 139 ++++++ apps/web/src/lib/reminder-messages.ts | 75 ++++ apps/web/src/test/no-render-warnings.test.tsx | 16 +- 17 files changed, 1003 insertions(+), 498 deletions(-) create mode 100644 apps/web/src/components/message-stack.tsx create mode 100644 apps/web/src/lib/reminder-messages.test.ts create mode 100644 apps/web/src/lib/reminder-messages.ts diff --git a/apps/web/src/actions/reminders.ts b/apps/web/src/actions/reminders.ts index de188ec..6cc8351 100644 --- a/apps/web/src/actions/reminders.ts +++ b/apps/web/src/actions/reminders.ts @@ -202,10 +202,34 @@ export async function duplicateReminderAction(formData: FormData): Promise redirect(`/reminders/${newId}` as any); } +// A single deliverable message part. See lib/reminder-messages.ts for +// the wire format the wizard URL uses. +const messagePartSchema = z + .object({ + kind: z.enum(["text", "media"]), + textContent: z.string().nullable().optional(), + mediaId: z.string().uuid().nullable().optional(), + }) + .refine( + (m) => + m.kind === "text" + ? Boolean(m.textContent && m.textContent.trim()) + : Boolean(m.mediaId), + { message: "Each message part needs text or a media file" }, + ); + const createReminderSchema = z .object({ accountId: z.string().uuid(), groupIds: z.array(z.string().uuid()), + // The new shape — caller passes one or more MessageParts in send order. + // Optional/nullable here so the legacy fallback below can be used by + // older URL bookmarks; the refine() guarantees we end up with at + // least one valid message either way. + messages: z.array(messagePartSchema).optional(), + // Legacy single-message fields. Still accepted so bookmarked + // /reminders/new URLs don't 400 after the migration. The action body + // collapses these into `messages` before doing any work. text: z.string().nullable().optional(), mediaId: z.string().uuid().nullable().optional(), caption: z.string().nullable().optional(), @@ -215,10 +239,48 @@ const createReminderSchema = z rrule: z.string().nullable().optional(), timezone: z.string().default(DEFAULT_TIMEZONE), }) - .refine((d) => Boolean(d.text?.trim()) || Boolean(d.mediaId), { - message: "Add a message or attach a file", - path: ["text"], - }); + .refine( + (d) => + (d.messages && d.messages.length > 0) || + Boolean(d.text?.trim()) || + Boolean(d.mediaId), + { + message: "Add a message or attach a file", + path: ["messages"], + }, + ); + +/** Resolve the schema's union of new + legacy fields into a flat list. */ +function resolveMessageParts(parsed: z.infer): Array<{ + kind: "text" | "media"; + textContent: string | null; + mediaId: string | null; +}> { + if (parsed.messages && parsed.messages.length > 0) { + return parsed.messages.map((m) => ({ + kind: m.kind, + textContent: m.textContent ?? null, + mediaId: m.mediaId ?? null, + })); + } + // Legacy: fold (text, mediaId, caption) into one part. + if (parsed.mediaId) { + return [ + { + kind: "media", + mediaId: parsed.mediaId, + textContent: parsed.caption?.trim() || parsed.text?.trim() || null, + }, + ]; + } + return [ + { + kind: "text", + textContent: parsed.text!, + mediaId: null, + }, + ]; +} export type CreateReminderResult = | { ok: true; reminderId: string } @@ -232,7 +294,8 @@ export async function createReminderAction( if (!parsed.success) { return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; } - const { accountId, groupIds, text, mediaId, caption, scheduledAtIso, rrule, timezone } = parsed.data; + const { accountId, groupIds, scheduledAtIso, rrule, timezone } = parsed.data; + const parts = resolveMessageParts(parsed.data); const op = await getSeededOperator(); const account = await db.query.whatsappAccounts.findFirst({ @@ -270,12 +333,17 @@ export async function createReminderAction( return { ok: false, error: "One or more groups don't belong to this account" }; } + // Pick a name from the first text-bearing part (text body or caption). + // Falls back to "Reminder" if every part is media-without-caption. + const firstLabel = parts.find((p) => p.textContent?.trim())?.textContent?.trim(); + const reminderName = (firstLabel ?? "Reminder").slice(0, 50); + const reminderId = await db.transaction(async (tx) => { const [rem] = await tx .insert(reminders) .values({ accountId, - name: (text ?? caption ?? "Reminder").slice(0, 50), + name: reminderName, scheduleKind: rrule ? "recurring" : "one_off", scheduledAt, rrule: rrule ?? null, @@ -291,23 +359,15 @@ export async function createReminderAction( ); } - if (text && !mediaId) { - await tx.insert(reminderMessages).values({ + await tx.insert(reminderMessages).values( + parts.map((p, position) => ({ reminderId: rem!.id, - position: 0, - kind: "text", - textContent: text, - mediaId: null, - }); - } else if (mediaId) { - await tx.insert(reminderMessages).values({ - reminderId: rem!.id, - position: 0, - kind: "media", - textContent: caption ?? text ?? null, - mediaId, - }); - } + position, + kind: p.kind, + textContent: p.textContent, + mediaId: p.mediaId, + })), + ); return rem!.id; }); @@ -337,17 +397,8 @@ export async function updateReminderAction( if (!parsed.success) { return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; } - const { - reminderId, - accountId, - groupIds, - text, - mediaId, - caption, - scheduledAtIso, - rrule, - timezone, - } = parsed.data; + const { reminderId, accountId, groupIds, scheduledAtIso, rrule, timezone } = parsed.data; + const parts = resolveMessageParts(parsed.data); const op = await getSeededOperator(); @@ -392,12 +443,15 @@ export async function updateReminderAction( return { ok: false, error: "One or more groups don't belong to this account" }; } + const firstLabel = parts.find((p) => p.textContent?.trim())?.textContent?.trim(); + const reminderName = (firstLabel ?? "Reminder").slice(0, 50); + await db.transaction(async (tx) => { await tx .update(reminders) .set({ accountId, - name: (text ?? caption ?? "Reminder").slice(0, 50), + name: reminderName, scheduleKind: rrule ? "recurring" : "one_off", scheduledAt, rrule: rrule ?? null, @@ -416,23 +470,15 @@ export async function updateReminderAction( } await tx.delete(reminderMessages).where(eq(reminderMessages.reminderId, reminderId)); - if (text && !mediaId) { - await tx.insert(reminderMessages).values({ + await tx.insert(reminderMessages).values( + parts.map((p, position) => ({ reminderId, - position: 0, - kind: "text", - textContent: text, - mediaId: null, - }); - } else if (mediaId) { - await tx.insert(reminderMessages).values({ - reminderId, - position: 0, - kind: "media", - textContent: caption ?? text ?? null, - mediaId, - }); - } + position, + kind: p.kind, + textContent: p.textContent, + mediaId: p.mediaId, + })), + ); }); // Re-arm the pg-boss job at the new scheduled time. The handler uses diff --git a/apps/web/src/app/reminders/[id]/edit/message/page.tsx b/apps/web/src/app/reminders/[id]/edit/message/page.tsx index 3540686..4c647d4 100644 --- a/apps/web/src/app/reminders/[id]/edit/message/page.tsx +++ b/apps/web/src/app/reminders/[id]/edit/message/page.tsx @@ -1,8 +1,10 @@ import { notFound } from "next/navigation"; import { getSeededOperator } from "@/lib/operator"; import { getReminderWithRuns } from "@/lib/queries"; +import { db } from "@/lib/db"; import { EditShell } from "@/components/reminder-edit/edit-shell"; import { EditMessageForm } from "@/components/reminder-edit/edit-message-form"; +import type { MessagePart } from "@/lib/reminder-messages"; interface Props { params: Promise<{ id: string }>; @@ -15,15 +17,40 @@ export default async function EditMessagePage({ params }: Props) { if (!data) notFound(); const { reminder, targets, messages } = data; - const first = messages[0]; - const text = first && !first.mediaId ? first.textContent ?? "" : ""; - const caption = first && first.mediaId ? first.textContent ?? "" : ""; + + // Hydrate the wire-format MessagePart[] from the per-row `reminder_messages` + // table. The DB already supports a stack of parts in `position` order; + // earlier code only ever wrote one row, but parsing N is the same loop. + const initialMessages: MessagePart[] = messages + .slice() + .sort((a, b) => a.position - b.position) + .map((m) => ({ + kind: m.kind === "media" ? "media" : "text", + textContent: m.textContent ?? null, + mediaId: m.mediaId ?? null, + })); + + // Resolve filenames for any attached media so the editor shows what + // the user previously uploaded instead of a blank "Replace" button. + const mediaIds = initialMessages + .map((m) => m.mediaId) + .filter((id): id is string => Boolean(id)); + const mediaInfo: Record = {}; + if (mediaIds.length > 0) { + const rows = await db.query.mediaFiles.findMany({ + where: (m, { inArray }) => inArray(m.id, mediaIds), + columns: { id: true, filenameOriginal: true, mimeType: true }, + }); + for (const r of rows) { + mediaInfo[r.id] = { filename: r.filenameOriginal, mimeType: r.mimeType }; + } + } return ( ); diff --git a/apps/web/src/app/reminders/new/page.tsx b/apps/web/src/app/reminders/new/page.tsx index 6ff4716..0184055 100644 --- a/apps/web/src/app/reminders/new/page.tsx +++ b/apps/web/src/app/reminders/new/page.tsx @@ -11,6 +11,10 @@ interface PageProps { step?: string; accountId?: string; groupIds?: string; + /** New shape — encoded MessagePart[]. Replaces text/mediaId/caption. */ + messages?: string; + /** Legacy single-message fields. Still accepted; the steps fold them + * into the new shape on entry so deep links don't break. */ text?: string; mediaId?: string; caption?: string; diff --git a/apps/web/src/components/message-stack.tsx b/apps/web/src/components/message-stack.tsx new file mode 100644 index 0000000..4dc824e --- /dev/null +++ b/apps/web/src/components/message-stack.tsx @@ -0,0 +1,404 @@ +"use client"; + +import { useRef, useState } from "react"; +import { + ArrowDownIcon, + ArrowUpIcon, + FileIcon, + ImageIcon, + MessageSquareTextIcon, + PaperclipIcon, + PlusIcon, + Trash2Icon, + UploadIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { uploadMediaAction } from "@/actions/media"; +import { cn } from "@/lib/utils"; +import type { MessagePart } from "@/lib/reminder-messages"; + +const MAX_PARTS = 10; + +/** + * Per-part draft. Mirrors `MessagePart` but adds UI-only metadata + * (filename / mime / preview URL) that's discarded before the parts + * cross the wire to the server action. + */ +interface PartDraft extends MessagePart { + /** Local id so React's key stays stable across reorders. */ + uid: string; + filename?: string; + mimeType?: string; + /** ObjectURL preview for image media — not persisted. */ + previewUrl?: string; +} + +function newUid(): string { + return Math.random().toString(36).slice(2); +} + +function emptyText(): PartDraft { + return { uid: newUid(), kind: "text", textContent: "", mediaId: null }; +} + +function emptyMedia(): PartDraft { + return { uid: newUid(), kind: "media", textContent: "", mediaId: null }; +} + +interface MessageStackProps { + initial: MessagePart[]; + /** Called whenever the stack changes — parent owns the canonical state. */ + onChange: (parts: MessagePart[]) => void; + /** Optional resolver for already-uploaded mediaId → display info. */ + initialMediaInfo?: Record; +} + +/** + * Stack-of-blocks editor for a reminder's message parts. + * + * Each row is either a `text` block (Textarea) or a `media` block + * (file picker + caption Input + per-block delete/move buttons). The + * stack supports reorder via up/down buttons and ships at most + * MAX_PARTS rows. The bot fires the parts in this order with ~1.5 s + * spacing between sends. + */ +export function MessageStack({ initial, onChange, initialMediaInfo }: MessageStackProps) { + const [parts, setParts] = useState(() => { + if (initial.length === 0) return [emptyText()]; + return initial.map((p) => { + const info = p.mediaId ? initialMediaInfo?.[p.mediaId] : undefined; + return { + uid: newUid(), + kind: p.kind, + textContent: p.textContent ?? "", + mediaId: p.mediaId ?? null, + filename: info?.filename, + mimeType: info?.mimeType, + }; + }); + }); + + function emit(next: PartDraft[]) { + setParts(next); + onChange( + next + .map((p) => ({ + kind: p.kind, + textContent: p.textContent && p.textContent.trim() ? p.textContent : null, + mediaId: p.mediaId, + })) + // Drop empty text blocks and orphan media blocks at compile-out + // — onChange should reflect only "real" message parts. + .filter((p) => + p.kind === "text" ? Boolean(p.textContent) : Boolean(p.mediaId), + ), + ); + } + + function update(uid: string, patch: Partial) { + emit(parts.map((p) => (p.uid === uid ? { ...p, ...patch } : p))); + } + function remove(uid: string) { + if (parts.length === 1) { + // Don't go below one block; reset it instead. + emit([emptyText()]); + return; + } + emit(parts.filter((p) => p.uid !== uid)); + } + function move(uid: string, direction: -1 | 1) { + const idx = parts.findIndex((p) => p.uid === uid); + const target = idx + direction; + if (idx < 0 || target < 0 || target >= parts.length) return; + const next = parts.slice(); + [next[idx], next[target]] = [next[target]!, next[idx]!]; + emit(next); + } + function add(kind: "text" | "media") { + if (parts.length >= MAX_PARTS) return; + emit([...parts, kind === "text" ? emptyText() : emptyMedia()]); + } + + return ( +
+ {parts.map((part, idx) => ( + update(part.uid, patch)} + onRemove={() => remove(part.uid)} + onMoveUp={() => move(part.uid, -1)} + onMoveDown={() => move(part.uid, 1)} + /> + ))} + +
+ + + {parts.length >= MAX_PARTS && ( + + Up to {MAX_PARTS} parts per reminder + + )} +
+
+ ); +} + +interface BlockProps { + part: PartDraft; + index: number; + total: number; + onChange: (patch: Partial) => void; + onRemove: () => void; + onMoveUp: () => void; + onMoveDown: () => void; +} + +function Block({ part, index, total, onChange, onRemove, onMoveUp, onMoveDown }: BlockProps) { + return ( +
+
+ + {part.kind === "text" ? "Text" : "File"} · #{index + 1} + +
+ + + +
+
+ + {part.kind === "text" ? ( + "); }); - it("hides the caption field when no media is attached", () => { - const html = renderToStaticMarkup(); - expect(html).not.toContain('id="msg-caption"'); - }); - - it("shows the caption field when media is attached", () => { + it("renders one block per initial part", () => { const html = renderToStaticMarkup( , ); - expect(html).toContain('id="msg-caption"'); - expect(html).toMatch(/value="hi there"/); + // Three blocks → three "Text · #N" or "File · #N" labels. + expect((html.match(/Text · #/g) ?? []).length).toBe(2); + expect((html.match(/File · #/g) ?? []).length).toBe(1); + expect(html).toContain("menu.pdf"); }); - it("renders a Save button (not 'Save changes', not 'Schedule reminder')", () => { + it("Add text + Add file buttons are present", () => { const html = renderToStaticMarkup(); - // Must look like a single-section save, not the wizard's submit copy. - expect(html).toMatch(/]+type="button"[^>]*>[\s\S]*Save<\/button>/); + expect(html).toContain("Add text"); + expect(html).toContain("Add file"); + }); + + it("renders a Save button (not 'Schedule reminder')", () => { + const html = renderToStaticMarkup(); + expect(html).toMatch(/Save<\/button>/); expect(html).not.toContain("Schedule Reminder"); }); }); @@ -60,22 +66,13 @@ describe("EditMessageForm — SSR layout", () => { describe("EditMessageForm — submission delegates to updateReminderAction", () => { beforeEach(() => updateMock.mockReset()); - it("constructs the right payload with current text + preserved scheduling", async () => { - // Reach into the form instance directly: import the module and call - // its internal helper logic by simulating React state. Easiest path - // here: render once, locate the form, then assert the action sees - // exactly the payload built from the props. + it("constructs the payload with messages[] and preserved scheduling", async () => { updateMock.mockResolvedValue({ ok: true, reminderId: "r-1" }); - - // Drive the action by invoking it the same way the component would. - // (We rely on the static call signature documented by EditMessageForm.) const expectedCall = { reminderId: baseProps.reminderId, accountId: baseProps.accountId, groupIds: baseProps.groupIds, - text: "Hello", - mediaId: null, - caption: null, + messages: [{ kind: "text", textContent: "Hello", mediaId: null }], scheduledAtIso: baseProps.scheduledAtIso, rrule: baseProps.rrule, timezone: baseProps.timezone, @@ -83,21 +80,4 @@ describe("EditMessageForm — submission delegates to updateReminderAction", () await updateMock(expectedCall); expect(updateMock).toHaveBeenCalledWith(expectedCall); }); - - it("media-attached path passes mediaId + caption (and no caption when empty)", async () => { - updateMock.mockResolvedValue({ ok: true, reminderId: "r-1" }); - const payload = { - reminderId: "r-1", - accountId: "acc-1", - groupIds: ["g-1"], - text: null, - mediaId: "m-1", - caption: "hello caption", - scheduledAtIso: baseProps.scheduledAtIso, - rrule: null, - timezone: baseProps.timezone, - }; - await updateMock(payload); - expect(updateMock).toHaveBeenLastCalledWith(payload); - }); }); diff --git a/apps/web/src/components/reminder-edit/edit-message-form.tsx b/apps/web/src/components/reminder-edit/edit-message-form.tsx index 6064817..6fbb4cb 100644 --- a/apps/web/src/components/reminder-edit/edit-message-form.tsx +++ b/apps/web/src/components/reminder-edit/edit-message-form.tsx @@ -4,10 +4,9 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { AlertCircleIcon, Loader2Icon, SaveIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; +import { MessageStack } from "@/components/message-stack"; import { updateReminderAction } from "@/actions/reminders"; +import type { MessagePart } from "@/lib/reminder-messages"; interface EditMessageFormProps { reminderId: string; @@ -16,11 +15,16 @@ interface EditMessageFormProps { scheduledAtIso: string; rrule: string | null; timezone: string; - initialText: string; - initialMediaId: string | null; - initialCaption: string; + initialMessages: MessagePart[]; + initialMediaInfo?: Record; } +/** + * Per-section "edit messages" page. Reuses the same MessageStack as + * the wizard's compose step so the user gets the same affordances — + * add/remove parts, reorder, swap a file, edit a caption — without + * going through the multi-step flow. + */ export function EditMessageForm({ reminderId, accountId, @@ -28,19 +32,17 @@ export function EditMessageForm({ scheduledAtIso, rrule, timezone, - initialText, - initialMediaId, - initialCaption, + initialMessages, + initialMediaInfo, }: EditMessageFormProps) { const router = useRouter(); - const [text, setText] = useState(initialText); - const [caption, setCaption] = useState(initialCaption); + const [messages, setMessages] = useState(initialMessages); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); async function handleSave() { - if (!text.trim() && !initialMediaId) { - setError("Add a message or keep the existing attachment."); + if (messages.length === 0) { + setError("Add at least one text or file part."); return; } setSubmitting(true); @@ -50,9 +52,7 @@ export function EditMessageForm({ reminderId, accountId, groupIds, - text: text.trim() ? text.trim() : null, - mediaId: initialMediaId, - caption: initialMediaId ? caption.trim() || null : null, + messages, scheduledAtIso, rrule, timezone, @@ -72,39 +72,14 @@ export function EditMessageForm({ return (
-
- -