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>
This commit is contained in:
yiekheng 2026-05-10 12:15:37 +08:00
parent f681be9deb
commit b71dbadef1
17 changed files with 1003 additions and 498 deletions

View File

@ -202,10 +202,34 @@ export async function duplicateReminderAction(formData: FormData): Promise<void>
redirect(`/reminders/${newId}` as any); 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 const createReminderSchema = z
.object({ .object({
accountId: z.string().uuid(), accountId: z.string().uuid(),
groupIds: z.array(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(), text: z.string().nullable().optional(),
mediaId: z.string().uuid().nullable().optional(), mediaId: z.string().uuid().nullable().optional(),
caption: z.string().nullable().optional(), caption: z.string().nullable().optional(),
@ -215,10 +239,48 @@ const createReminderSchema = z
rrule: z.string().nullable().optional(), rrule: z.string().nullable().optional(),
timezone: z.string().default(DEFAULT_TIMEZONE), timezone: z.string().default(DEFAULT_TIMEZONE),
}) })
.refine((d) => Boolean(d.text?.trim()) || Boolean(d.mediaId), { .refine(
(d) =>
(d.messages && d.messages.length > 0) ||
Boolean(d.text?.trim()) ||
Boolean(d.mediaId),
{
message: "Add a message or attach a file", message: "Add a message or attach a file",
path: ["text"], path: ["messages"],
}); },
);
/** Resolve the schema's union of new + legacy fields into a flat list. */
function resolveMessageParts(parsed: z.infer<typeof createReminderSchema>): 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 = export type CreateReminderResult =
| { ok: true; reminderId: string } | { ok: true; reminderId: string }
@ -232,7 +294,8 @@ export async function createReminderAction(
if (!parsed.success) { if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; 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 op = await getSeededOperator();
const account = await db.query.whatsappAccounts.findFirst({ 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" }; 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 reminderId = await db.transaction(async (tx) => {
const [rem] = await tx const [rem] = await tx
.insert(reminders) .insert(reminders)
.values({ .values({
accountId, accountId,
name: (text ?? caption ?? "Reminder").slice(0, 50), name: reminderName,
scheduleKind: rrule ? "recurring" : "one_off", scheduleKind: rrule ? "recurring" : "one_off",
scheduledAt, scheduledAt,
rrule: rrule ?? null, 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, reminderId: rem!.id,
position: 0, position,
kind: "text", kind: p.kind,
textContent: text, textContent: p.textContent,
mediaId: null, mediaId: p.mediaId,
}); })),
} else if (mediaId) { );
await tx.insert(reminderMessages).values({
reminderId: rem!.id,
position: 0,
kind: "media",
textContent: caption ?? text ?? null,
mediaId,
});
}
return rem!.id; return rem!.id;
}); });
@ -337,17 +397,8 @@ export async function updateReminderAction(
if (!parsed.success) { if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
} }
const { const { reminderId, accountId, groupIds, scheduledAtIso, rrule, timezone } = parsed.data;
reminderId, const parts = resolveMessageParts(parsed.data);
accountId,
groupIds,
text,
mediaId,
caption,
scheduledAtIso,
rrule,
timezone,
} = parsed.data;
const op = await getSeededOperator(); 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" }; 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 db.transaction(async (tx) => {
await tx await tx
.update(reminders) .update(reminders)
.set({ .set({
accountId, accountId,
name: (text ?? caption ?? "Reminder").slice(0, 50), name: reminderName,
scheduleKind: rrule ? "recurring" : "one_off", scheduleKind: rrule ? "recurring" : "one_off",
scheduledAt, scheduledAt,
rrule: rrule ?? null, rrule: rrule ?? null,
@ -416,23 +470,15 @@ export async function updateReminderAction(
} }
await tx.delete(reminderMessages).where(eq(reminderMessages.reminderId, reminderId)); 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, reminderId,
position: 0, position,
kind: "text", kind: p.kind,
textContent: text, textContent: p.textContent,
mediaId: null, mediaId: p.mediaId,
}); })),
} else if (mediaId) { );
await tx.insert(reminderMessages).values({
reminderId,
position: 0,
kind: "media",
textContent: caption ?? text ?? null,
mediaId,
});
}
}); });
// Re-arm the pg-boss job at the new scheduled time. The handler uses // Re-arm the pg-boss job at the new scheduled time. The handler uses

View File

@ -1,8 +1,10 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries"; import { getReminderWithRuns } from "@/lib/queries";
import { db } from "@/lib/db";
import { EditShell } from "@/components/reminder-edit/edit-shell"; import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditMessageForm } from "@/components/reminder-edit/edit-message-form"; import { EditMessageForm } from "@/components/reminder-edit/edit-message-form";
import type { MessagePart } from "@/lib/reminder-messages";
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -15,15 +17,40 @@ export default async function EditMessagePage({ params }: Props) {
if (!data) notFound(); if (!data) notFound();
const { reminder, targets, messages } = data; const { reminder, targets, messages } = data;
const first = messages[0];
const text = first && !first.mediaId ? first.textContent ?? "" : ""; // Hydrate the wire-format MessagePart[] from the per-row `reminder_messages`
const caption = first && first.mediaId ? first.textContent ?? "" : ""; // 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<string, { filename: string; mimeType: string }> = {};
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 ( return (
<EditShell <EditShell
reminderId={reminder.id} reminderId={reminder.id}
title="Edit message" title="Edit message"
description="Change the reminder text. The schedule, account, and groups stay as they are." description="Stack as many text and file parts as you need; the bot sends them in order with a short pause between."
> >
<EditMessageForm <EditMessageForm
reminderId={reminder.id} reminderId={reminder.id}
@ -32,9 +59,8 @@ export default async function EditMessagePage({ params }: Props) {
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()} scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule} rrule={reminder.rrule}
timezone={reminder.timezone} timezone={reminder.timezone}
initialText={text} initialMessages={initialMessages}
initialMediaId={first?.mediaId ?? null} initialMediaInfo={mediaInfo}
initialCaption={caption}
/> />
</EditShell> </EditShell>
); );

View File

@ -11,6 +11,10 @@ interface PageProps {
step?: string; step?: string;
accountId?: string; accountId?: string;
groupIds?: 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; text?: string;
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;

View File

@ -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<string, { filename: string; mimeType: string }>;
}
/**
* 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<PartDraft[]>(() => {
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<MessagePart>((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<PartDraft>) {
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 (
<div className="space-y-3">
{parts.map((part, idx) => (
<Block
key={part.uid}
part={part}
index={idx}
total={parts.length}
onChange={(patch) => update(part.uid, patch)}
onRemove={() => remove(part.uid)}
onMoveUp={() => move(part.uid, -1)}
onMoveDown={() => move(part.uid, 1)}
/>
))}
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => add("text")}
disabled={parts.length >= MAX_PARTS}
className="gap-1.5"
>
<PlusIcon className="size-3.5" />
<MessageSquareTextIcon className="size-3.5" />
Add text
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => add("media")}
disabled={parts.length >= MAX_PARTS}
className="gap-1.5"
>
<PlusIcon className="size-3.5" />
<PaperclipIcon className="size-3.5" />
Add file
</Button>
{parts.length >= MAX_PARTS && (
<span className="self-center text-xs text-muted-foreground">
Up to {MAX_PARTS} parts per reminder
</span>
)}
</div>
</div>
);
}
interface BlockProps {
part: PartDraft;
index: number;
total: number;
onChange: (patch: Partial<PartDraft>) => void;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
function Block({ part, index, total, onChange, onRemove, onMoveUp, onMoveDown }: BlockProps) {
return (
<div className="rounded-xl border border-border bg-card p-3 space-y-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{part.kind === "text" ? "Text" : "File"} · #{index + 1}
</span>
<div className="flex items-center gap-0.5">
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={onMoveUp}
disabled={index === 0}
aria-label="Move up"
className="size-7"
>
<ArrowUpIcon className="size-3.5" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={onMoveDown}
disabled={index === total - 1}
aria-label="Move down"
className="size-7"
>
<ArrowDownIcon className="size-3.5" />
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={onRemove}
aria-label="Remove this part"
className="size-7 text-muted-foreground hover:text-destructive"
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</div>
{part.kind === "text" ? (
<Textarea
value={part.textContent ?? ""}
onChange={(e) => onChange({ textContent: e.target.value })}
placeholder="Type your message…"
rows={3}
className="resize-none"
/>
) : (
<MediaBlock part={part} onChange={onChange} />
)}
</div>
);
}
interface MediaBlockProps {
part: PartDraft;
onChange: (patch: Partial<PartDraft>) => void;
}
function MediaBlock({ part, onChange }: MediaBlockProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
async function handleFile(file: File) {
setError(null);
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const result = await uploadMediaAction(null, fd);
setUploading(false);
if (result.ok) {
// Revoke prior preview URL to avoid leaks across replacements.
if (part.previewUrl) URL.revokeObjectURL(part.previewUrl);
const previewUrl = result.mimeType.startsWith("image/")
? URL.createObjectURL(file)
: undefined;
onChange({
mediaId: result.mediaId,
filename: result.filename,
mimeType: result.mimeType,
previewUrl,
});
} else {
setError(result.error);
}
}
function clearMedia() {
if (part.previewUrl) URL.revokeObjectURL(part.previewUrl);
onChange({ mediaId: null, filename: undefined, mimeType: undefined, previewUrl: undefined });
if (fileInputRef.current) fileInputRef.current.value = "";
}
const isImage = part.mimeType?.startsWith("image/");
const hasMedia = Boolean(part.mediaId);
return (
<div className="space-y-2">
{hasMedia ? (
<div className="rounded-lg border border-border bg-muted/30 p-2.5">
<div className="flex items-start gap-2.5">
{isImage && part.previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={part.previewUrl}
alt={part.filename ?? "Uploaded image"}
className="size-14 rounded-md object-cover shrink-0 border border-border"
/>
) : (
<div className="flex size-14 shrink-0 items-center justify-center rounded-md bg-muted border border-border">
<FileIcon className="size-5 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium truncate">
{part.filename ?? "Uploaded file"}
</p>
{part.mimeType && (
<p className="text-xs text-muted-foreground truncate">{part.mimeType}</p>
)}
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => fileInputRef.current?.click()}
className="text-xs"
>
Replace
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={clearMedia}
aria-label="Remove file"
className="size-7 text-muted-foreground hover:text-destructive"
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
<Input
value={part.textContent ?? ""}
onChange={(e) => onChange({ textContent: e.target.value })}
placeholder="Caption (optional)"
className="mt-2 h-8 text-sm"
/>
</div>
) : (
<div
role="button"
tabIndex={0}
aria-label="Click or drag a file to upload"
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) void handleFile(file);
}}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click();
}}
className={cn(
"flex flex-col items-center gap-1.5 rounded-lg border-2 border-dashed px-4 py-5 text-center cursor-pointer transition-colors",
dragOver
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/30",
uploading && "pointer-events-none opacity-60",
)}
>
{uploading ? (
<>
<UploadIcon className="size-5 text-muted-foreground animate-pulse" />
<p className="text-xs text-muted-foreground">Uploading</p>
</>
) : (
<>
<div className="flex items-center gap-1.5 text-muted-foreground">
<ImageIcon className="size-4" />
<PaperclipIcon className="size-3.5" />
</div>
<div>
<p className="text-xs font-medium">Click to upload or drag & drop</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
Images, documents, audio up to 50 MB
</p>
</div>
</>
)}
</div>
)}
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleFile(file);
}}
/>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
</div>
);
}

View File

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import type { MessagePart } from "@/lib/reminder-messages";
// Mocks must come before the import that uses them.
const updateMock = vi.fn(); const updateMock = vi.fn();
vi.mock("@/actions/reminders", () => ({ vi.mock("@/actions/reminders", () => ({
updateReminderAction: (...args: unknown[]) => updateMock(...args), updateReminderAction: (...args: unknown[]) => updateMock(...args),
@ -20,39 +20,45 @@ const baseProps = {
scheduledAtIso: "2026-05-13T09:00:00.000+08:00", scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
rrule: "FREQ=DAILY", rrule: "FREQ=DAILY",
timezone: "Asia/Kuala_Lumpur", timezone: "Asia/Kuala_Lumpur",
initialText: "Hello", initialMessages: [
initialMediaId: null as string | null, { kind: "text", textContent: "Hello", mediaId: null },
initialCaption: "", ] satisfies MessagePart[],
}; };
describe("EditMessageForm — SSR layout", () => { describe("EditMessageForm — SSR layout", () => {
it("pre-fills the textarea with the existing text", () => { it("pre-fills the first part with the existing text", () => {
const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />); const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />);
expect(html).toContain('<textarea'); expect(html).toContain("<textarea");
expect(html).toContain(">Hello</textarea>"); expect(html).toContain(">Hello</textarea>");
}); });
it("hides the caption field when no media is attached", () => { it("renders one block per initial part", () => {
const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />);
expect(html).not.toContain('id="msg-caption"');
});
it("shows the caption field when media is attached", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<EditMessageForm <EditMessageForm
{...baseProps} {...baseProps}
initialMediaId="m-1" initialMessages={[
initialCaption="hi there" { kind: "text", textContent: "Hi", mediaId: null },
{ kind: "media", textContent: "caption", mediaId: "m-1" },
{ kind: "text", textContent: "PS", mediaId: null },
]}
initialMediaInfo={{ "m-1": { filename: "menu.pdf", mimeType: "application/pdf" } }}
/>, />,
); );
expect(html).toContain('id="msg-caption"'); // Three blocks → three "Text · #N" or "File · #N" labels.
expect(html).toMatch(/value="hi there"/); 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(<EditMessageForm {...baseProps} />); const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />);
// Must look like a single-section save, not the wizard's submit copy. expect(html).toContain("Add text");
expect(html).toMatch(/<button[^>]+type="button"[^>]*>[\s\S]*Save<\/button>/); expect(html).toContain("Add file");
});
it("renders a Save button (not 'Schedule reminder')", () => {
const html = renderToStaticMarkup(<EditMessageForm {...baseProps} />);
expect(html).toMatch(/Save<\/button>/);
expect(html).not.toContain("Schedule Reminder"); expect(html).not.toContain("Schedule Reminder");
}); });
}); });
@ -60,22 +66,13 @@ describe("EditMessageForm — SSR layout", () => {
describe("EditMessageForm — submission delegates to updateReminderAction", () => { describe("EditMessageForm — submission delegates to updateReminderAction", () => {
beforeEach(() => updateMock.mockReset()); beforeEach(() => updateMock.mockReset());
it("constructs the right payload with current text + preserved scheduling", async () => { it("constructs the payload with messages[] and 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.
updateMock.mockResolvedValue({ ok: true, reminderId: "r-1" }); 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 = { const expectedCall = {
reminderId: baseProps.reminderId, reminderId: baseProps.reminderId,
accountId: baseProps.accountId, accountId: baseProps.accountId,
groupIds: baseProps.groupIds, groupIds: baseProps.groupIds,
text: "Hello", messages: [{ kind: "text", textContent: "Hello", mediaId: null }],
mediaId: null,
caption: null,
scheduledAtIso: baseProps.scheduledAtIso, scheduledAtIso: baseProps.scheduledAtIso,
rrule: baseProps.rrule, rrule: baseProps.rrule,
timezone: baseProps.timezone, timezone: baseProps.timezone,
@ -83,21 +80,4 @@ describe("EditMessageForm — submission delegates to updateReminderAction", ()
await updateMock(expectedCall); await updateMock(expectedCall);
expect(updateMock).toHaveBeenCalledWith(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);
});
}); });

View File

@ -4,10 +4,9 @@ import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AlertCircleIcon, Loader2Icon, SaveIcon } from "lucide-react"; import { AlertCircleIcon, Loader2Icon, SaveIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { MessageStack } from "@/components/message-stack";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { updateReminderAction } from "@/actions/reminders"; import { updateReminderAction } from "@/actions/reminders";
import type { MessagePart } from "@/lib/reminder-messages";
interface EditMessageFormProps { interface EditMessageFormProps {
reminderId: string; reminderId: string;
@ -16,11 +15,16 @@ interface EditMessageFormProps {
scheduledAtIso: string; scheduledAtIso: string;
rrule: string | null; rrule: string | null;
timezone: string; timezone: string;
initialText: string; initialMessages: MessagePart[];
initialMediaId: string | null; initialMediaInfo?: Record<string, { filename: string; mimeType: string }>;
initialCaption: string;
} }
/**
* 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({ export function EditMessageForm({
reminderId, reminderId,
accountId, accountId,
@ -28,19 +32,17 @@ export function EditMessageForm({
scheduledAtIso, scheduledAtIso,
rrule, rrule,
timezone, timezone,
initialText, initialMessages,
initialMediaId, initialMediaInfo,
initialCaption,
}: EditMessageFormProps) { }: EditMessageFormProps) {
const router = useRouter(); const router = useRouter();
const [text, setText] = useState(initialText); const [messages, setMessages] = useState<MessagePart[]>(initialMessages);
const [caption, setCaption] = useState(initialCaption);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
async function handleSave() { async function handleSave() {
if (!text.trim() && !initialMediaId) { if (messages.length === 0) {
setError("Add a message or keep the existing attachment."); setError("Add at least one text or file part.");
return; return;
} }
setSubmitting(true); setSubmitting(true);
@ -50,9 +52,7 @@ export function EditMessageForm({
reminderId, reminderId,
accountId, accountId,
groupIds, groupIds,
text: text.trim() ? text.trim() : null, messages,
mediaId: initialMediaId,
caption: initialMediaId ? caption.trim() || null : null,
scheduledAtIso, scheduledAtIso,
rrule, rrule,
timezone, timezone,
@ -72,39 +72,14 @@ export function EditMessageForm({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <MessageStack
<Label htmlFor="msg-text">Message</Label> initial={initialMessages}
<Textarea initialMediaInfo={initialMediaInfo}
id="msg-text" onChange={(parts) => {
rows={5} setMessages(parts);
value={text}
onChange={(e) => {
setText(e.target.value);
setError(null); setError(null);
}} }}
placeholder="Type your reminder text…"
className="resize-none"
/> />
</div>
{initialMediaId && (
<div className="space-y-1.5">
<Label htmlFor="msg-caption">Caption (existing attachment)</Label>
<Input
id="msg-caption"
value={caption}
onChange={(e) => {
setCaption(e.target.value);
setError(null);
}}
placeholder="Optional caption"
/>
<p className="text-xs text-muted-foreground">
The original attached file is kept. Replacing it isn't supported here yet
re-create the reminder if you need a different file.
</p>
</div>
)}
{error && ( {error && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">

View File

@ -1,21 +1,14 @@
"use client"; "use client";
import { useState, useRef, useActionState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { AlertCircleIcon } from "lucide-react";
PaperclipIcon,
ImageIcon,
FileIcon,
XIcon,
AlertCircleIcon,
UploadIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { MessageStack } from "@/components/message-stack";
import { Input } from "@/components/ui/input"; import {
import { Label } from "@/components/ui/label"; encodeMessages,
import { uploadMediaAction } from "@/actions/media"; type MessagePart,
import { cn } from "@/lib/utils"; } from "@/lib/reminder-messages";
interface PassThroughParams { interface PassThroughParams {
scheduledAt?: string; scheduledAt?: string;
@ -26,99 +19,32 @@ interface PassThroughParams {
interface ComposeFormClientProps { interface ComposeFormClientProps {
accountId: string; accountId: string;
groupIds: string; groupIds: string;
initialText: string; initialMessages: MessagePart[];
initialMediaId?: string; /** Resolved {filename, mimeType} per mediaId so reload can show file
initialCaption?: string; * metadata without a fresh upload. */
initialMediaInfo?: Record<string, { filename: string; mimeType: string }>;
passThroughParams: PassThroughParams; passThroughParams: PassThroughParams;
} }
export function ComposeFormClient({ export function ComposeFormClient({
accountId, accountId,
groupIds, groupIds,
initialText, initialMessages,
initialMediaId, initialMediaInfo,
initialCaption,
passThroughParams, passThroughParams,
}: ComposeFormClientProps) { }: ComposeFormClientProps) {
const router = useRouter(); const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null); const [messages, setMessages] = useState<MessagePart[]>(initialMessages);
const [error, setError] = useState<string | null>(null);
const [text, setText] = useState(initialText);
const [caption, setCaption] = useState(initialCaption ?? "");
const [mediaId, setMediaId] = useState<string | undefined>(initialMediaId);
const [mediaFilename, setMediaFilename] = useState<string | undefined>(undefined);
const [mediaMimeType, setMediaMimeType] = useState<string | undefined>(undefined);
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined);
const [dragOver, setDragOver] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadState, uploadAction] = useActionState(uploadMediaAction, null);
// Show server-side upload errors
const uploadError =
uploadState && !uploadState.ok ? uploadState.error : null;
async function handleFile(file: File) {
setLocalError(null);
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const result = await uploadMediaAction(null, fd);
setUploading(false);
if (result.ok) {
setMediaId(result.mediaId);
setMediaFilename(result.filename);
setMediaMimeType(result.mimeType);
if (result.mimeType.startsWith("image/")) {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
} else {
setPreviewUrl(undefined);
}
} else {
setLocalError(result.error);
}
}
function handleFileInput(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) handleFile(file);
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
}
function removeMedia() {
setMediaId(undefined);
setMediaFilename(undefined);
setMediaMimeType(undefined);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(undefined);
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
function handleContinue() { function handleContinue() {
if (!text.trim() && !mediaId) { if (messages.length === 0) {
setLocalError("Add a message or attach a file."); setError("Add at least one text or file part.");
return; return;
} }
const sp = new URLSearchParams({ const sp = new URLSearchParams({ step: "3", accountId });
step: "3",
accountId,
});
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
if (text.trim()) sp.set("text", text.trim()); sp.set("messages", encodeMessages(messages));
if (mediaId) sp.set("mediaId", mediaId);
if (caption.trim()) sp.set("caption", caption.trim());
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt); if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule); if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
@ -126,128 +52,26 @@ export function ComposeFormClient({
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
} }
const isImage = mediaMimeType?.startsWith("image/");
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Body text */} <MessageStack
<div className="space-y-1.5"> initial={initialMessages}
<Label htmlFor="body-text">Message</Label> initialMediaInfo={initialMediaInfo}
<Textarea onChange={(parts) => {
id="body-text" setMessages(parts);
placeholder="Type your reminder message here…" setError(null);
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
{/* Media section */}
{mediaId ? (
/* Media preview */
<div className="rounded-xl border border-border bg-muted/30 p-3">
<div className="flex items-start gap-3">
{isImage && previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={mediaFilename ?? "Uploaded image"}
className="size-16 rounded-lg object-cover shrink-0 border border-border"
/>
) : (
<div className="flex size-16 shrink-0 items-center justify-center rounded-lg bg-muted border border-border">
<FileIcon className="size-6 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1 space-y-1.5">
<p className="text-sm font-medium truncate">{mediaFilename ?? "Uploaded file"}</p>
<p className="text-xs text-muted-foreground">{mediaMimeType}</p>
{/* Caption for media */}
<Input
placeholder="Caption (optional)"
value={caption}
onChange={(e) => setCaption(e.target.value)}
className="mt-1"
/>
</div>
<button
type="button"
onClick={removeMedia}
aria-label="Remove attachment"
className="shrink-0 flex size-6 items-center justify-center rounded-full bg-muted hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<XIcon className="size-3" />
</button>
</div>
</div>
) : (
/* Upload drop zone */
<div className="space-y-1.5">
<Label>Attachment (optional)</Label>
<div
role="button"
tabIndex={0}
aria-label="Click or drag a file to upload"
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}} }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click();
}}
className={cn(
"flex flex-col items-center gap-2 rounded-xl border-2 border-dashed px-6 py-8 text-center cursor-pointer transition-colors",
dragOver
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/30",
uploading && "pointer-events-none opacity-60"
)}
>
{uploading ? (
<>
<UploadIcon className="size-6 text-muted-foreground animate-pulse" />
<p className="text-sm text-muted-foreground">Uploading</p>
</>
) : (
<>
<div className="flex items-center gap-2 text-muted-foreground">
<ImageIcon className="size-5" />
<PaperclipIcon className="size-4" />
</div>
<div>
<p className="text-sm font-medium">Click to upload or drag & drop</p>
<p className="text-xs text-muted-foreground mt-0.5">
Images, documents, audio up to 50 MB
</p>
</div>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
aria-hidden="true"
className="sr-only"
onChange={handleFileInput}
/> />
</div>
)}
{/* Errors */} {error && (
{(localError ?? uploadError) && (
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircleIcon className="size-3.5 shrink-0" /> <AlertCircleIcon className="size-3.5 shrink-0" />
{localError ?? uploadError} {error}
</div> </div>
)} )}
{/* Action */}
<div className="flex justify-end pt-1"> <div className="flex justify-end pt-1">
<Button type="button" onClick={handleContinue} disabled={uploading}> <Button type="button" onClick={handleContinue}>
Continue Continue
</Button> </Button>
</div> </div>

View File

@ -15,9 +15,8 @@ interface Group {
} }
interface PassThroughParams { interface PassThroughParams {
text?: string; /** Encoded MessagePart[] from the compose step. */
mediaId?: string; messages?: string;
caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string; rrule?: string;
editReminderId?: string; editReminderId?: string;
@ -68,9 +67,7 @@ export function GroupsFormClient({
if (selected.size > 0) { if (selected.size > 0) {
sp.set("groupIds", Array.from(selected).join(",")); sp.set("groupIds", Array.from(selected).join(","));
} }
if (passThroughParams.text) sp.set("text", passThroughParams.text); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt); if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule); if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);

View File

@ -6,13 +6,12 @@ import { CalendarCheckIcon, AlertCircleIcon, Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { createReminderAction, updateReminderAction } from "@/actions/reminders"; import { createReminderAction, updateReminderAction } from "@/actions/reminders";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { MessagePart } from "@/lib/reminder-messages";
interface ReviewSubmitClientProps { interface ReviewSubmitClientProps {
accountId: string; accountId: string;
groupIds?: string; groupIds?: string;
text?: string; messages: MessagePart[];
mediaId?: string;
caption?: string;
scheduledAt: string; scheduledAt: string;
rrule?: string; rrule?: string;
editReminderId?: string; editReminderId?: string;
@ -22,9 +21,7 @@ interface ReviewSubmitClientProps {
export function ReviewSubmitClient({ export function ReviewSubmitClient({
accountId, accountId,
groupIds, groupIds,
text, messages,
mediaId,
caption,
scheduledAt, scheduledAt,
rrule, rrule,
editReminderId, editReminderId,
@ -42,9 +39,7 @@ export function ReviewSubmitClient({
const payload = { const payload = {
accountId, accountId,
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [], groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
text: text ?? null, messages,
mediaId: mediaId ?? null,
caption: caption ?? null,
scheduledAtIso: scheduledAt, scheduledAtIso: scheduledAt,
rrule: rrule ?? null, rrule: rrule ?? null,
timezone, timezone,

View File

@ -3,11 +3,20 @@ import { redirect } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react"; import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ComposeFormClient } from "./compose-form-client"; import { ComposeFormClient } from "./compose-form-client";
import {
decodeMessages,
legacyMessageToParts,
type MessagePart,
} from "@/lib/reminder-messages";
import { db } from "@/lib/db";
interface StepComposeParams { interface StepComposeParams {
step?: string; step?: string;
accountId?: string; accountId?: string;
groupIds?: string; groupIds?: string;
/** New shape: encoded MessagePart[] JSON. */
messages?: string;
/** Legacy single-message fields — accepted as fallback. */
text?: string; text?: string;
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
@ -20,14 +29,36 @@ interface StepComposeProps {
params: StepComposeParams; params: StepComposeParams;
} }
export function StepCompose({ params }: StepComposeProps) { async function resolveMediaInfo(
const { accountId, groupIds, text, mediaId, caption } = params; parts: MessagePart[],
): Promise<Record<string, { filename: string; mimeType: string }>> {
const ids = Array.from(
new Set(parts.map((p) => p.mediaId).filter((id): id is string => Boolean(id))),
);
if (ids.length === 0) return {};
const rows = await db.query.mediaFiles.findMany({
where: (m, { inArray }) => inArray(m.id, ids),
columns: { id: true, filenameOriginal: true, mimeType: true },
});
return Object.fromEntries(
rows.map((r) => [r.id, { filename: r.filenameOriginal, mimeType: r.mimeType }]),
);
}
export async function StepCompose({ params }: StepComposeProps) {
const { accountId } = params;
if (!accountId) { if (!accountId) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any); redirect("/reminders/new" as any);
} }
const initialMessages =
decodeMessages(params.messages) ??
legacyMessageToParts(params.text, params.mediaId, params.caption) ??
[];
const mediaInfo = await resolveMediaInfo(initialMessages);
const backHref = `/reminders/new?step=1` as const; const backHref = `/reminders/new?step=1` as const;
return ( return (
@ -43,15 +74,15 @@ export function StepCompose({ params }: StepComposeProps) {
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Write your reminder message. You can also attach an image or document. Build your reminder. Stack as many text or file parts as you need
the bot sends them in order with a short pause between.
</p> </p>
<ComposeFormClient <ComposeFormClient
accountId={accountId} accountId={accountId}
groupIds={groupIds ?? ""} groupIds={params.groupIds ?? ""}
initialText={text ?? ""} initialMessages={initialMessages}
initialMediaId={mediaId} initialMediaInfo={mediaInfo}
initialCaption={caption}
passThroughParams={{ passThroughParams={{
scheduledAt: params.scheduledAt, scheduledAt: params.scheduledAt,
rrule: params.rrule, rrule: params.rrule,

View File

@ -5,14 +5,15 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listGroupsForAccount } from "@/lib/queries"; import { listGroupsForAccount } from "@/lib/queries";
import { decodeMessages } from "@/lib/reminder-messages";
import { GroupsFormClient } from "./groups-form-client";
interface StepGroupsParams { interface StepGroupsParams {
step?: string; step?: string;
accountId?: string; accountId?: string;
groupIds?: string; groupIds?: string;
text?: string; /** Encoded MessagePart[]. */
mediaId?: string; messages?: string;
caption?: string;
scheduledAt?: string; scheduledAt?: string;
rrule?: string; rrule?: string;
groupId?: string; groupId?: string;
@ -29,13 +30,12 @@ export async function StepGroups({ params }: StepGroupsProps) {
groupIds: groupIdsParam, groupIds: groupIdsParam,
groupId: singleGroupId, groupId: singleGroupId,
scheduledAt, scheduledAt,
text, messages,
mediaId,
rrule, rrule,
editReminderId, editReminderId,
} = params; } = params;
if (!accountId || !scheduledAt || (!text && !mediaId)) { if (!accountId || !scheduledAt || !decodeMessages(messages)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any); redirect("/reminders/new" as any);
} }
@ -62,8 +62,6 @@ export async function StepGroups({ params }: StepGroupsProps) {
const { groups } = result; const { groups } = result;
// Determine pre-selected group IDs
// Priority: groupIds param > single groupId param
const preSelected: string[] = groupIdsParam const preSelected: string[] = groupIdsParam
? groupIdsParam.split(",").filter(Boolean) ? groupIdsParam.split(",").filter(Boolean)
: singleGroupId : singleGroupId
@ -71,9 +69,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
: []; : [];
const backParams = new URLSearchParams({ step: "3", accountId }); const backParams = new URLSearchParams({ step: "3", accountId });
if (text) backParams.set("text", text); if (messages) backParams.set("messages", messages);
if (mediaId) backParams.set("mediaId", mediaId);
if (params.caption) backParams.set("caption", params.caption);
if (scheduledAt) backParams.set("scheduledAt", scheduledAt); if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
if (rrule) backParams.set("rrule", rrule); if (rrule) backParams.set("rrule", rrule);
if (editReminderId) backParams.set("editReminderId", editReminderId); if (editReminderId) backParams.set("editReminderId", editReminderId);
@ -96,52 +92,12 @@ export async function StepGroups({ params }: StepGroupsProps) {
and add targets later. and add targets later.
</p> </p>
<StepGroupsForm
groups={groups}
preSelected={preSelected}
accountId={accountId}
passThroughParams={{
text: params.text,
mediaId: params.mediaId,
caption: params.caption,
scheduledAt: params.scheduledAt,
rrule,
editReminderId,
}}
/>
</div>
);
}
// Client component for the interactive form
import { GroupsFormClient } from "./groups-form-client";
interface PassThroughParams {
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
rrule?: string;
editReminderId?: string;
}
function StepGroupsForm({
groups,
preSelected,
accountId,
passThroughParams,
}: {
groups: Array<{ id: string; name: string; participantCount: number; isArchived: boolean }>;
preSelected: string[];
accountId: string;
passThroughParams: PassThroughParams;
}) {
return (
<GroupsFormClient <GroupsFormClient
groups={groups} groups={groups}
preSelected={preSelected} preSelected={preSelected}
accountId={accountId} accountId={accountId}
passThroughParams={passThroughParams} passThroughParams={{ messages, scheduledAt, rrule, editReminderId }}
/> />
</div>
); );
} }

View File

@ -6,6 +6,7 @@ import {
CalendarIcon, CalendarIcon,
UsersIcon, UsersIcon,
FileTextIcon, FileTextIcon,
PaperclipIcon,
SmartphoneIcon, SmartphoneIcon,
RepeatIcon, RepeatIcon,
} from "lucide-react"; } from "lucide-react";
@ -13,14 +14,21 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getAccount, listGroupsForAccount } from "@/lib/queries"; import { getAccount, listGroupsForAccount } from "@/lib/queries";
import { db } from "@/lib/db";
import { ReviewSubmitClient } from "./review-submit-client"; import { ReviewSubmitClient } from "./review-submit-client";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { describeRecurrence, specFromRrule } from "@/lib/recurrence"; import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
import {
decodeMessages,
legacyMessageToParts,
type MessagePart,
} from "@/lib/reminder-messages";
interface StepReviewParams { interface StepReviewParams {
step?: string; step?: string;
accountId?: string; accountId?: string;
groupIds?: string; groupIds?: string;
messages?: string;
text?: string; text?: string;
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
@ -46,19 +54,15 @@ function formatScheduledAt(iso: string, timezone: string): string {
function editLink( function editLink(
step: number, step: number,
accountId: string, accountId: string,
groupIds?: string, groupIds: string | undefined,
text?: string, messages: string | undefined,
mediaId?: string, scheduledAt: string | undefined,
caption?: string, rrule: string | undefined,
scheduledAt?: string, editReminderId: string | undefined,
rrule?: string,
editReminderId?: string,
): string { ): string {
const sp = new URLSearchParams({ step: String(step), accountId }); const sp = new URLSearchParams({ step: String(step), accountId });
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
if (text) sp.set("text", text); if (messages) sp.set("messages", messages);
if (mediaId) sp.set("mediaId", mediaId);
if (caption) sp.set("caption", caption);
if (scheduledAt) sp.set("scheduledAt", scheduledAt); if (scheduledAt) sp.set("scheduledAt", scheduledAt);
if (rrule) sp.set("rrule", rrule); if (rrule) sp.set("rrule", rrule);
if (editReminderId) sp.set("editReminderId", editReminderId); if (editReminderId) sp.set("editReminderId", editReminderId);
@ -66,9 +70,20 @@ function editLink(
} }
export async function StepReview({ params }: StepReviewProps) { export async function StepReview({ params }: StepReviewProps) {
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId } = params; const { accountId, groupIds, scheduledAt, rrule, editReminderId } = params;
if (!accountId || !scheduledAt || (!text && !mediaId)) { // Resolve messages from the URL — prefer the new shape, accept legacy.
const parts: MessagePart[] | null =
decodeMessages(params.messages) ??
legacyMessageToParts(params.text, params.mediaId, params.caption);
// Re-encode so all the edit links carry the canonical shape.
const messagesParam = parts
? new URLSearchParams({
m: encodeURIComponent(JSON.stringify(parts)),
}).get("m") ?? undefined
: undefined;
if (!accountId || !scheduledAt || !parts) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any); redirect("/reminders/new" as any);
} }
@ -76,14 +91,12 @@ export async function StepReview({ params }: StepReviewProps) {
const op = await getSeededOperator(); const op = await getSeededOperator();
const timezone = op.defaultTimezone ?? "UTC"; const timezone = op.defaultTimezone ?? "UTC";
// Fetch account details
const account = await getAccount(op.id, accountId); const account = await getAccount(op.id, accountId);
if (!account) { if (!account) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any); redirect("/reminders/new" as any);
} }
// Fetch group names
const groupIdsArray = groupIds ? groupIds.split(",").filter(Boolean) : []; const groupIdsArray = groupIds ? groupIds.split(",").filter(Boolean) : [];
const groupsResult = const groupsResult =
groupIdsArray.length > 0 ? await listGroupsForAccount(op.id, accountId) : null; groupIdsArray.length > 0 ? await listGroupsForAccount(op.id, accountId) : null;
@ -91,10 +104,23 @@ export async function StepReview({ params }: StepReviewProps) {
? groupsResult.groups.filter((g) => groupIdsArray.includes(g.id)) ? groupsResult.groups.filter((g) => groupIdsArray.includes(g.id))
: []; : [];
// Resolve media filenames so the review pane shows what the user
// attached, not just an opaque uuid.
const mediaIds = parts
.map((p) => p.mediaId)
.filter((id): id is string => Boolean(id));
const mediaInfo: Record<string, string> = {};
if (mediaIds.length > 0) {
const rows = await db.query.mediaFiles.findMany({
where: (m, { inArray }) => inArray(m.id, mediaIds),
columns: { id: true, filenameOriginal: true },
});
for (const row of rows) mediaInfo[row.id] = row.filenameOriginal;
}
const formattedDate = formatScheduledAt(scheduledAt, timezone); const formattedDate = formatScheduledAt(scheduledAt, timezone);
// Back goes to step 4 (Groups, the previous step in the new order) const backHref = editLink(4, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId);
const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -117,7 +143,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<SmartphoneIcon className="size-4" />} icon={<SmartphoneIcon className="size-4" />}
label="Account" label="Account"
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)} editHref={editLink(1, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)}
> >
<span className="text-sm font-medium">{account.label}</span> <span className="text-sm font-medium">{account.label}</span>
{account.phoneNumber && ( {account.phoneNumber && (
@ -125,34 +151,50 @@ export async function StepReview({ params }: StepReviewProps) {
)} )}
</ReviewRow> </ReviewRow>
{/* Message */} {/* Messages — one row per part */}
<ReviewRow <ReviewRow
icon={<FileTextIcon className="size-4" />} icon={<FileTextIcon className="size-4" />}
label="Message" label={`Messages · ${parts.length}`}
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)} editHref={editLink(2, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)}
> >
{mediaId ? ( <ol className="space-y-1.5">
<span className="text-sm text-muted-foreground"> {parts.map((p, i) => (
Media file <li key={i} className="text-sm flex items-start gap-2">
{caption && ( <span className="text-xs font-mono text-muted-foreground mt-0.5">
<> with caption: <span className="text-foreground">{caption}</span></> #{i + 1}
)} </span>
{text && ( {p.kind === "text" ? (
<> · <span className="text-foreground">{text}</span></> <span className="whitespace-pre-wrap break-words">
{p.textContent}
</span>
) : (
<span className="flex items-start gap-1.5 min-w-0">
<PaperclipIcon className="size-3.5 shrink-0 text-muted-foreground mt-0.5" />
<span className="min-w-0">
<span className="font-mono text-muted-foreground">
{p.mediaId && mediaInfo[p.mediaId]
? mediaInfo[p.mediaId]
: "media"}
</span>
{p.textContent && (
<>
{" · "}
<span className="break-words">{p.textContent}</span>
</>
)} )}
</span> </span>
) : text ? ( </span>
<p className="text-sm whitespace-pre-wrap break-words">{text}</p>
) : (
<span className="text-sm text-muted-foreground italic">No message</span>
)} )}
</li>
))}
</ol>
</ReviewRow> </ReviewRow>
{/* When */} {/* When */}
<ReviewRow <ReviewRow
icon={<CalendarIcon className="size-4" />} icon={<CalendarIcon className="size-4" />}
label={rrule ? "First fire" : "When"} label={rrule ? "First fire" : "When"}
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)} editHref={editLink(3, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)}
> >
<span className="text-sm font-medium">{formattedDate}</span> <span className="text-sm font-medium">{formattedDate}</span>
</ReviewRow> </ReviewRow>
@ -162,7 +204,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<RepeatIcon className="size-4" />} icon={<RepeatIcon className="size-4" />}
label="Repeats" label="Repeats"
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)} editHref={editLink(3, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)}
> >
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{describeRecurrence( {describeRecurrence(
@ -177,7 +219,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<UsersIcon className="size-4" />} icon={<UsersIcon className="size-4" />}
label="Groups" label="Groups"
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId)} editHref={editLink(4, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)}
> >
{selectedGroups.length > 0 ? ( {selectedGroups.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -201,9 +243,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewSubmitClient <ReviewSubmitClient
accountId={accountId} accountId={accountId}
groupIds={groupIds} groupIds={groupIds}
text={text} messages={parts}
mediaId={mediaId}
caption={caption}
scheduledAt={scheduledAt} scheduledAt={scheduledAt}
rrule={rrule} rrule={rrule}
editReminderId={editReminderId} editReminderId={editReminderId}

View File

@ -6,11 +6,15 @@ import { getSeededOperator } from "@/lib/operator";
import { WhenFormClient } from "./when-form-client"; import { WhenFormClient } from "./when-form-client";
import { specFromRrule } from "@/lib/recurrence"; import { specFromRrule } from "@/lib/recurrence";
import { defaultFirstFireIso } from "@/lib/date-picker"; import { defaultFirstFireIso } from "@/lib/date-picker";
import { decodeMessages, legacyMessageToParts } from "@/lib/reminder-messages";
interface StepWhenParams { interface StepWhenParams {
step?: string; step?: string;
accountId?: string; accountId?: string;
groupIds?: string; groupIds?: string;
/** New shape — encoded MessagePart[]. */
messages?: string;
/** Legacy single-message fields, accepted for back-compat. */
text?: string; text?: string;
mediaId?: string; mediaId?: string;
caption?: string; caption?: string;
@ -24,9 +28,25 @@ interface StepWhenProps {
} }
export async function StepWhen({ params }: StepWhenProps) { export async function StepWhen({ params }: StepWhenProps) {
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule, editReminderId } = params; const { accountId, groupIds, scheduledAt, rrule, editReminderId } = params;
if (!accountId || (!text && !mediaId)) { // Resolve the messages param once: prefer the new shape, fall back to
// the legacy fields. Either way the wizard needs at least one part to
// continue past Compose.
const messagesParam =
params.messages && decodeMessages(params.messages)
? params.messages
: (() => {
const legacy = legacyMessageToParts(params.text, params.mediaId, params.caption);
if (!legacy) return undefined;
// Re-encode the legacy fallback so subsequent steps see one
// canonical wire-format and don't have to know about both.
return new URLSearchParams({
messages: encodeURIComponent(JSON.stringify(legacy)),
}).get("messages") ?? undefined;
})();
if (!accountId || !messagesParam) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any); redirect("/reminders/new" as any);
} }
@ -36,9 +56,7 @@ export async function StepWhen({ params }: StepWhenProps) {
const backParams = new URLSearchParams({ step: "2", accountId }); const backParams = new URLSearchParams({ step: "2", accountId });
if (groupIds) backParams.set("groupIds", groupIds); if (groupIds) backParams.set("groupIds", groupIds);
if (text) backParams.set("text", text); if (messagesParam) backParams.set("messages", messagesParam);
if (mediaId) backParams.set("mediaId", mediaId);
if (caption) backParams.set("caption", caption);
if (rrule) backParams.set("rrule", rrule); if (rrule) backParams.set("rrule", rrule);
if (editReminderId) backParams.set("editReminderId", editReminderId); if (editReminderId) backParams.set("editReminderId", editReminderId);
const backHref = `/reminders/new?${backParams.toString()}`; const backHref = `/reminders/new?${backParams.toString()}`;
@ -66,7 +84,7 @@ export async function StepWhen({ params }: StepWhenProps) {
timezone={timezone} timezone={timezone}
initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)} initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)}
initialSpec={specFromRrule(rrule)} initialSpec={specFromRrule(rrule)}
passThroughParams={{ text, mediaId, caption, editReminderId }} passThroughParams={{ messages: messagesParam, editReminderId }}
/> />
</div> </div>
); );

View File

@ -12,9 +12,8 @@ import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
import { RecurrencePicker } from "@/components/recurrence-picker"; import { RecurrencePicker } from "@/components/recurrence-picker";
interface PassThroughParams { interface PassThroughParams {
text?: string; /** Encoded MessagePart[] from the compose step. */
mediaId?: string; messages?: string;
caption?: string;
editReminderId?: string; editReminderId?: string;
} }
@ -67,9 +66,7 @@ export function WhenFormClient({
const sp = new URLSearchParams({ step: "4", accountId, scheduledAt }); const sp = new URLSearchParams({ step: "4", accountId, scheduledAt });
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
if (rrule) sp.set("rrule", rrule); if (rrule) sp.set("rrule", rrule);
if (passThroughParams.text) sp.set("text", passThroughParams.text); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);
@ -111,9 +108,7 @@ export function WhenFormClient({
}); });
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
if (rrule) sp.set("rrule", rrule); if (rrule) sp.set("rrule", rrule);
if (passThroughParams.text) sp.set("text", passThroughParams.text); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/reminders/new?${sp.toString()}` as any); router.push(`/reminders/new?${sp.toString()}` as any);

View File

@ -0,0 +1,139 @@
import { describe, it, expect } from "vitest";
import {
decodeMessages,
encodeMessages,
isValidMessagePart,
legacyMessageToParts,
type MessagePart,
} from "./reminder-messages";
describe("isValidMessagePart", () => {
it("accepts a non-empty text part", () => {
expect(
isValidMessagePart({ kind: "text", textContent: "hi", mediaId: null }),
).toBe(true);
});
it("rejects an empty/whitespace text part", () => {
expect(
isValidMessagePart({ kind: "text", textContent: " ", mediaId: null }),
).toBe(false);
expect(
isValidMessagePart({ kind: "text", textContent: null, mediaId: null }),
).toBe(false);
});
it("accepts a media part with mediaId, with or without caption", () => {
expect(
isValidMessagePart({ kind: "media", textContent: null, mediaId: "uuid" }),
).toBe(true);
expect(
isValidMessagePart({ kind: "media", textContent: "cap", mediaId: "uuid" }),
).toBe(true);
});
it("rejects a media part without mediaId", () => {
expect(
isValidMessagePart({ kind: "media", textContent: "cap", mediaId: null }),
).toBe(false);
});
it("rejects unknown kinds and non-objects", () => {
expect(isValidMessagePart({ kind: "weird", mediaId: null, textContent: "" })).toBe(false);
expect(isValidMessagePart(null)).toBe(false);
expect(isValidMessagePart("hi")).toBe(false);
});
});
describe("encodeMessages / decodeMessages round-trip", () => {
const sample: MessagePart[] = [
{ kind: "text", textContent: "Hello there", mediaId: null },
{ kind: "media", textContent: "caption", mediaId: "11111111-1111-1111-1111-111111111111" },
{ kind: "media", textContent: null, mediaId: "22222222-2222-2222-2222-222222222222" },
];
it("round-trips a non-trivial stack", () => {
const encoded = encodeMessages(sample);
expect(typeof encoded).toBe("string");
expect(decodeMessages(encoded)).toEqual(sample);
});
it("survives URL-unsafe characters in text content (newlines, &, =, %, #)", () => {
const trickier: MessagePart[] = [
{
kind: "text",
textContent: "Line 1\nLine 2 & more = special % # values",
mediaId: null,
},
];
const encoded = encodeMessages(trickier);
// Encoded form must be safely embeddable as a URL param value.
expect(encoded).not.toContain("\n");
expect(encoded).not.toContain("&");
expect(decodeMessages(encoded)).toEqual(trickier);
});
it("decodeMessages returns null on null/undefined/empty/garbage input", () => {
expect(decodeMessages(null)).toBeNull();
expect(decodeMessages(undefined)).toBeNull();
expect(decodeMessages("")).toBeNull();
expect(decodeMessages("not-json")).toBeNull();
expect(decodeMessages(encodeURIComponent("not an array"))).toBeNull();
});
it("decodeMessages drops invalid entries and returns null when nothing valid is left", () => {
const mixed = encodeURIComponent(
JSON.stringify([
{ kind: "text", textContent: "", mediaId: null }, // invalid
{ kind: "media", textContent: null, mediaId: null }, // invalid
]),
);
expect(decodeMessages(mixed)).toBeNull();
});
it("decodeMessages keeps valid entries when some are invalid", () => {
const mixed = encodeURIComponent(
JSON.stringify([
{ kind: "text", textContent: "good", mediaId: null },
{ kind: "media", textContent: null, mediaId: null }, // invalid
{ kind: "media", textContent: null, mediaId: "uuid" },
]),
);
expect(decodeMessages(mixed)).toEqual([
{ kind: "text", textContent: "good", mediaId: null },
{ kind: "media", textContent: null, mediaId: "uuid" },
]);
});
});
describe("legacyMessageToParts back-compat fallback", () => {
it("text-only legacy URL → one text part", () => {
expect(legacyMessageToParts("hi", null, null)).toEqual([
{ kind: "text", textContent: "hi", mediaId: null },
]);
});
it("media + caption legacy URL → one media part with caption", () => {
expect(legacyMessageToParts(null, "uuid", "cap")).toEqual([
{ kind: "media", textContent: "cap", mediaId: "uuid" },
]);
});
it("media without caption → media part with null textContent", () => {
expect(legacyMessageToParts(null, "uuid", null)).toEqual([
{ kind: "media", textContent: null, mediaId: "uuid" },
]);
});
it("media + legacy text (no caption) reuses text as caption", () => {
expect(legacyMessageToParts("text-too", "uuid", null)).toEqual([
{ kind: "media", textContent: "text-too", mediaId: "uuid" },
]);
});
it("nothing useful returns null", () => {
expect(legacyMessageToParts(null, null, null)).toBeNull();
expect(legacyMessageToParts("", null, null)).toBeNull();
expect(legacyMessageToParts(" ", null, null)).toBeNull();
});
});

View File

@ -0,0 +1,75 @@
/**
* 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;
}

View File

@ -130,9 +130,9 @@ describe("SSR render — no React errors or warnings", () => {
scheduledAtIso: "2026-05-13T09:00:00.000+08:00", scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
rrule: "FREQ=DAILY", rrule: "FREQ=DAILY",
timezone: "Asia/Kuala_Lumpur", timezone: "Asia/Kuala_Lumpur",
initialText: "Hello", initialMessages: [
initialMediaId: null as string | null, { kind: "text" as const, textContent: "Hello", mediaId: null },
initialCaption: "", ],
}; };
const a = renderQuiet(<EditMessageForm {...baseProps} />); const a = renderQuiet(<EditMessageForm {...baseProps} />);
expect(a.errors).toEqual([]); expect(a.errors).toEqual([]);
@ -141,8 +141,10 @@ describe("SSR render — no React errors or warnings", () => {
const b = renderQuiet( const b = renderQuiet(
<EditMessageForm <EditMessageForm
{...baseProps} {...baseProps}
initialMediaId="m-1" initialMessages={[
initialCaption="caption text" { kind: "media", textContent: "caption text", mediaId: "m-1" },
]}
initialMediaInfo={{ "m-1": { filename: "f.pdf", mimeType: "application/pdf" } }}
/>, />,
); );
expect(b.errors).toEqual([]); expect(b.errors).toEqual([]);
@ -178,9 +180,7 @@ describe("SSR markup — no <div> inside <button> (the bug we just fixed)", () =
scheduledAtIso="2026-05-13T09:00:00.000+08:00" scheduledAtIso="2026-05-13T09:00:00.000+08:00"
rrule={null} rrule={null}
timezone="Asia/Kuala_Lumpur" timezone="Asia/Kuala_Lumpur"
initialText="Hello" initialMessages={[{ kind: "text", textContent: "Hello", mediaId: null }]}
initialMediaId={null}
initialCaption=""
/>, />,
); );
const buttons = [...html.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>/g)]; const buttons = [...html.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>/g)];