feat(reminders): name is required (was optional with auto-derive)

Previously the name field auto-derived from the first text part when
the operator left it blank. That's brittle once reminders carry
multiple parts of varying provenance, and confusing in lists where
"Reminder" or partial sentences crowd in.

Now: every reminder must carry a non-empty name, capped at 60 chars.

  - Zod schema on createReminder/updateReminder: name moves from
    `z.string().nullable().optional()` to
    `z.string().trim().min(1, "Give the reminder a name").max(60)`.
    Stale-URL legacy callers that omit it now get a clear server error.
  - Wizard compose step: input has `required` + `aria-required`,
    placeholder + label simplified ("(optional)" tag and the helper
    paragraph removed), Continue blocks on empty.
  - Edit-message form: same — required, aria-required, save blocked
    on empty, the "leave blank and we'll auto-derive" hint dropped.
  - Review-submit client: defensive fail-fast for stale-bookmark URLs
    that arrive at step 5 without a name — bounces back with
    "Give the reminder a name (back on the Message step)" instead of
    letting the server reject.

The resolveReminderName helper stays put — duplicateReminderAction
and any future caller still benefit from the trim+clamp+fallback
chain. Helper unit tests unaffected (they test the resolver in
isolation, the policy-tightening lives at the schema layer above).

298 web tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 14:15:16 +08:00
parent 50187a86e1
commit d5b8c0beeb
6 changed files with 1965 additions and 47 deletions

View File

@ -230,12 +230,13 @@ const createReminderSchema = z
// least one valid message either way. // least one valid message either way.
messages: z.array(messagePartSchema).optional(), messages: z.array(messagePartSchema).optional(),
// User-supplied label shown in the list / detail page header. // User-supplied label shown in the list / detail page header.
// Optional on the wire — when blank or missing the action body // Required: every reminder must carry a non-empty name. The
// auto-derives a fallback from the first text-bearing message // resolver still clamps to REMINDER_NAME_MAX so the DB column
// part. The reminders.name DB column is text(50), so the // never has to reject the row. The legacy auto-derive from the
// resolver clamps to 60 chars (mirrors the duplicate-action // first message part is kept as a fallback ONLY for legacy
// pattern that produces "<name> (copy)") and trims whitespace. // bookmarked URLs (where the create form was submitted before
name: z.string().nullable().optional(), // the field was added) — new submits always carry a name.
name: z.string().trim().min(1, "Give the reminder a name").max(60),
// Legacy single-message fields. Still accepted so bookmarked // Legacy single-message fields. Still accepted so bookmarked
// /reminders/new URLs don't 400 after the migration. The action body // /reminders/new URLs don't 400 after the migration. The action body
// collapses these into `messages` before doing any work. // collapses these into `messages` before doing any work.

View File

@ -47,6 +47,11 @@ export function EditMessageForm({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
async function handleSave() { async function handleSave() {
const trimmedName = name.trim();
if (!trimmedName) {
setError("Give the reminder a name.");
return;
}
if (messages.length === 0) { if (messages.length === 0) {
setError("Add at least one text or file part."); setError("Add at least one text or file part.");
return; return;
@ -58,7 +63,7 @@ export function EditMessageForm({
reminderId, reminderId,
accountId, accountId,
groupIds, groupIds,
name: name.trim() || null, name: trimmedName,
messages, messages,
scheduledAtIso, scheduledAtIso,
rrule, rrule,
@ -83,19 +88,20 @@ export function EditMessageForm({
<Label htmlFor="reminder-name" className="flex items-center gap-1.5"> <Label htmlFor="reminder-name" className="flex items-center gap-1.5">
<TagIcon className="size-3.5" /> <TagIcon className="size-3.5" />
Name Name
<span className="ml-1 text-xs font-normal text-muted-foreground">(optional)</span>
</Label> </Label>
<Input <Input
id="reminder-name" id="reminder-name"
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder="e.g. Sunday morning standup" placeholder="e.g. Sunday morning standup"
maxLength={REMINDER_NAME_MAX} maxLength={REMINDER_NAME_MAX}
required
aria-required="true"
/> />
<p className="text-xs text-muted-foreground">
Leave blank and the first line of your message will be used.
</p>
</div> </div>
<MessageStack <MessageStack

View File

@ -41,6 +41,11 @@ export function ComposeFormClient({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
function handleContinue() { function handleContinue() {
const trimmedName = name.trim();
if (!trimmedName) {
setError("Give the reminder a name.");
return;
}
if (messages.length === 0) { if (messages.length === 0) {
setError("Add at least one text or file part."); setError("Add at least one text or file part.");
return; return;
@ -48,8 +53,7 @@ export function ComposeFormClient({
const sp = new URLSearchParams({ step: "3", accountId }); const sp = new URLSearchParams({ step: "3", accountId });
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
sp.set("messages", encodeMessages(messages)); sp.set("messages", encodeMessages(messages));
const trimmedName = name.trim(); sp.set("name", trimmedName);
if (trimmedName) sp.set("name", trimmedName);
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);
@ -59,26 +63,26 @@ export function ComposeFormClient({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Name sits above the message stack so the user names the {/* Name required identifier shown in the list, detail page,
reminder before composing. Optional: blank falls back to the and run-history rows. Capped at REMINDER_NAME_MAX (60). */}
first text part on the server side. */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="reminder-name" className="flex items-center gap-1.5"> <Label htmlFor="reminder-name" className="flex items-center gap-1.5">
<TagIcon className="size-3.5" /> <TagIcon className="size-3.5" />
Name Name
<span className="ml-1 text-xs font-normal text-muted-foreground">(optional)</span>
</Label> </Label>
<Input <Input
id="reminder-name" id="reminder-name"
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder="e.g. Sunday morning standup" placeholder="e.g. Sunday morning standup"
maxLength={REMINDER_NAME_MAX} maxLength={REMINDER_NAME_MAX}
required
aria-required="true"
/> />
<p className="text-xs text-muted-foreground">
Leave blank and the first line of your message will be used.
</p>
</div> </div>
<MessageStack <MessageStack

View File

@ -34,6 +34,15 @@ export function ReviewSubmitClient({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
async function handleSchedule() { async function handleSchedule() {
const trimmedName = name?.trim();
if (!trimmedName) {
// The wizard's compose step now blocks Continue when the name is
// blank, so the only way to land here without one is a stale
// bookmarked URL. Bounce the operator back to step 2 with a
// clear error rather than letting the server reject it.
setError("Give the reminder a name (back on the Message step).");
return;
}
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
@ -41,7 +50,7 @@ export function ReviewSubmitClient({
const payload = { const payload = {
accountId, accountId,
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [], groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
name: name?.trim() || null, name: trimmedName,
messages, messages,
scheduledAtIso: scheduledAt, scheduledAtIso: scheduledAt,
rrule: rrule ?? null, rrule: rrule ?? null,

File diff suppressed because it is too large Load Diff

View File

@ -7,9 +7,13 @@
## Goal ## Goal
Deliver a reminder to many groups (target: 1000+) safely within a Deliver a reminder to many groups (target: 1000+) safely within a
per-reminder delivery window. If we cannot finish in the window, stop, per-reminder delivery window. If we cannot finish in the window,
mark the run `partial`, and tell the operator the account is at **pause** the run at window-close, persist progress, and let the
capacity for this fan-out. operator **resume** later from the Activity / detail view. The
paused-status message tells the operator what's blocking throughput
(account at capacity, media size eating the budget) so they can
decide whether to offload to another paired account, shrink the
attachment, or just resume the next morning at 6am.
## Constraints ## Constraints
@ -131,19 +135,71 @@ default 3):
Final status: Final status:
- **success** — every target sent. - **success** — every target sent.
- **partial** — at least one sent, at least one not (window-close, - **paused** — window closed mid-run with at least one target still in
failed, missing group). `error_summary` reads: `pending`. Run carries a resumable state: sent rows stay `sent`,
unstarted rows stay `pending` (NOT skipped), failed rows stay
`failed`. `error_summary` reads:
`"Delivery window closed at 18:00 (Asia/Kuala_Lumpur). 412 of 1000 `"Delivery window closed at 18:00 (Asia/Kuala_Lumpur). 412 of 1000
groups delivered. This account is at capacity for this fan-out — groups delivered, 588 still pending. Resume from the Activity tab.
consider sending the remainder from another paired account."` If this happens repeatedly, consider offloading to another paired
- **failed** — zero sent. account, or shrinking the message body / media size to fit more
groups in your daily window."`
- **partial** — every target was attempted; some sent and some
failed/skipped (group missing from DB, account offline, send error
inside the window). Not resumable; the failures are real failures.
- **failed** — zero sent. Either every send errored, or the run hit
the window close BEFORE the first send (run fired too late to do
any work; nothing to resume).
## Resume action
A paused run can be resumed by the operator. Mechanism:
- New server action `resumeReminderRunAction(runId)` validates
ownership, then enqueues a pg-boss job:
`boss.send("reminder.fire", { reminderId, runId })` with NO
singletonKey (resumes don't conflict with the reminder's normal
cron firing).
- The fire-reminder handler accepts an optional `runId` in its
payload. When present, it ATTACHES to that run instead of creating
a new one:
- Skips creating a new `reminder_runs` row.
- Loads the existing run's `reminder_run_targets` rows.
- Iterates only those with `status = 'pending'`.
- Re-uses the same windowEnd / rate limiter / media cache logic as
a fresh fire.
- On window close again, status flips back to `paused` with an
updated count.
- On success this round, status becomes `success` (if no failures
accumulated) or `partial` (if some failed).
- `failed` targets from the previous run are NOT retried on resume.
They're real errors — surfacing them as actionable in the UI is a
v2 concern (manual "retry failed" button).
UI surfaces of paused runs:
- Activity tab gets an amber "Paused" pill alongside the existing
Success/Partial/Failed/Skipped/Archived filters. Resume button
inline on each paused row.
- Reminder detail page's run history shows the same Resume button on
paused rows.
- The `reminder.fired` SSE event for status=paused triggers a
notification with title "Reminder paused" and body
`"X of Y groups delivered. Resume from the Activity tab."`
## Notification body ## Notification body
The existing `reminder.fired` SSE event already carries The existing `reminder.fired` SSE event already carries `{ status }`.
`{ status }`. The web's notification mapper already handles The notification mapper extends:
`partial` with a "see activity" hint. The body extends to mention
"X of Y delivered" when status === "partial". - `success` → unchanged.
- `partial` → body mentions delivered/total counts when present.
- `paused` → headline `"Reminder paused"`, body
`"X of Y groups delivered. Resume from the Activity tab."` Click
takes the operator to the reminder's detail page where the Resume
button lives.
- `failed` → unchanged.
- `skipped` → still filtered (bookkeeping noise).
## Components ## Components
@ -192,14 +248,20 @@ default to 6/18 and can be widened (e.g. 0/24) for a specific big run.
## Out of scope (v2 candidates) ## Out of scope (v2 candidates)
- **Crash resumability across bot restarts.** Today, if the bot dies - **Crash resumability across bot restarts.** If the bot dies
mid-fan-out, pg-boss will retry the job; the loop will skip any mid-fan-out (mid-window), pg-boss will retry the job; the loop's
rows already marked `sent`, but the in-memory rate-limiter and pre-loaded `pending` rows still pick up correctly, but the
upload-cache state are gone — meaning the retry uploads media in-memory rate-limiter and upload-cache state are gone — the
again and starts pacing from a full bucket. Acceptable for v1. retry re-uploads media and starts pacing from a full bucket. The
- **Pause / resume mid-run** controls. paused-state resumability covered above is a different mechanism:
- **Cross-day window resume** (current design hard-stops at window it handles the "window closed cleanly" case end-to-end. The
end and reports partial; doesn't queue the remainder for tomorrow). "bot crashed mid-window" case is degraded but not broken.
- **Auto-resume next morning** when window opens again (today the
operator clicks Resume manually).
- **Pause-by-operator** (only window-close pauses; user-triggered
pause mid-fan-out isn't wired).
- **Retry-failed-targets** action (paused-resume only re-attempts
`pending` rows; `failed` rows stay failed).
- **Multi-account auto-split** of a single reminder. - **Multi-account auto-split** of a single reminder.
- **Adaptive rate limiting** (auto-back-off on WA rate-limit response - **Adaptive rate limiting** (auto-back-off on WA rate-limit response
codes; today the operator tunes the env var). codes; today the operator tunes the env var).
@ -210,7 +272,14 @@ default to 6/18 and can be widened (e.g. 0/24) for a specific big run.
in roughly 3050 minutes, comfortably inside a 6am6pm window. in roughly 3050 minutes, comfortably inside a 6am6pm window.
- Two reminders on different accounts firing within seconds of each - Two reminders on different accounts firing within seconds of each
other: both progress simultaneously, neither blocks the other. other: both progress simultaneously, neither blocks the other.
- A run that hits the window end: stops cleanly, marks remaining as - A run that hits the window end mid-fan-out: stops cleanly, marks
skipped, surfaces the partial-status message in the Activity tab the run `paused`, leaves un-started targets as `pending`, surfaces
and via the browser notification. the paused-status notification with delivered/total counts.
- 355 existing tests still pass; ≈25 new tests cover the new helpers. - The operator clicks **Resume** on a paused run — fan-out continues
from the unsent targets, respecting the same per-account rate
limit + window. If it again can't finish, it pauses again with an
updated count.
- A run that hits the window end BEFORE any send (fired too late):
resolves `failed`, no resume offered.
- 355 existing tests still pass; ≈30 new tests cover the new helpers
and the paused/resume flow.