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 (
-
- -