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:
parent
50187a86e1
commit
d5b8c0beeb
@ -230,12 +230,13 @@ const createReminderSchema = z
|
||||
// least one valid message either way.
|
||||
messages: z.array(messagePartSchema).optional(),
|
||||
// User-supplied label shown in the list / detail page header.
|
||||
// Optional on the wire — when blank or missing the action body
|
||||
// auto-derives a fallback from the first text-bearing message
|
||||
// part. The reminders.name DB column is text(50), so the
|
||||
// resolver clamps to 60 chars (mirrors the duplicate-action
|
||||
// pattern that produces "<name> (copy)") and trims whitespace.
|
||||
name: z.string().nullable().optional(),
|
||||
// Required: every reminder must carry a non-empty name. The
|
||||
// resolver still clamps to REMINDER_NAME_MAX so the DB column
|
||||
// never has to reject the row. The legacy auto-derive from the
|
||||
// first message part is kept as a fallback ONLY for legacy
|
||||
// bookmarked URLs (where the create form was submitted before
|
||||
// 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
|
||||
// /reminders/new URLs don't 400 after the migration. The action body
|
||||
// collapses these into `messages` before doing any work.
|
||||
|
||||
@ -47,6 +47,11 @@ export function EditMessageForm({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSave() {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setError("Give the reminder a name.");
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
setError("Add at least one text or file part.");
|
||||
return;
|
||||
@ -58,7 +63,7 @@ export function EditMessageForm({
|
||||
reminderId,
|
||||
accountId,
|
||||
groupIds,
|
||||
name: name.trim() || null,
|
||||
name: trimmedName,
|
||||
messages,
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
@ -83,19 +88,20 @@ export function EditMessageForm({
|
||||
<Label htmlFor="reminder-name" className="flex items-center gap-1.5">
|
||||
<TagIcon className="size-3.5" />
|
||||
Name
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="reminder-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="e.g. Sunday morning standup"
|
||||
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>
|
||||
|
||||
<MessageStack
|
||||
|
||||
@ -41,6 +41,11 @@ export function ComposeFormClient({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleContinue() {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setError("Give the reminder a name.");
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
setError("Add at least one text or file part.");
|
||||
return;
|
||||
@ -48,8 +53,7 @@ export function ComposeFormClient({
|
||||
const sp = new URLSearchParams({ step: "3", accountId });
|
||||
if (groupIds) sp.set("groupIds", groupIds);
|
||||
sp.set("messages", encodeMessages(messages));
|
||||
const trimmedName = name.trim();
|
||||
if (trimmedName) sp.set("name", trimmedName);
|
||||
sp.set("name", trimmedName);
|
||||
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
|
||||
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
@ -59,26 +63,26 @@ export function ComposeFormClient({
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Name — sits above the message stack so the user names the
|
||||
reminder before composing. Optional: blank falls back to the
|
||||
first text part on the server side. */}
|
||||
{/* Name — required identifier shown in the list, detail page,
|
||||
and run-history rows. Capped at REMINDER_NAME_MAX (60). */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reminder-name" className="flex items-center gap-1.5">
|
||||
<TagIcon className="size-3.5" />
|
||||
Name
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="reminder-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="e.g. Sunday morning standup"
|
||||
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>
|
||||
|
||||
<MessageStack
|
||||
|
||||
@ -34,6 +34,15 @@ export function ReviewSubmitClient({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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);
|
||||
setError(null);
|
||||
|
||||
@ -41,7 +50,7 @@ export function ReviewSubmitClient({
|
||||
const payload = {
|
||||
accountId,
|
||||
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
|
||||
name: name?.trim() || null,
|
||||
name: trimmedName,
|
||||
messages,
|
||||
scheduledAtIso: scheduledAt,
|
||||
rrule: rrule ?? null,
|
||||
|
||||
1829
docs/superpowers/plans/2026-05-10-windowed-fanout.md
Normal file
1829
docs/superpowers/plans/2026-05-10-windowed-fanout.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,9 +7,13 @@
|
||||
## Goal
|
||||
|
||||
Deliver a reminder to many groups (target: 1000+) safely within a
|
||||
per-reminder delivery window. If we cannot finish in the window, stop,
|
||||
mark the run `partial`, and tell the operator the account is at
|
||||
capacity for this fan-out.
|
||||
per-reminder delivery window. If we cannot finish in the window,
|
||||
**pause** the run at window-close, persist progress, and let the
|
||||
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
|
||||
|
||||
@ -131,19 +135,71 @@ default 3):
|
||||
Final status:
|
||||
|
||||
- **success** — every target sent.
|
||||
- **partial** — at least one sent, at least one not (window-close,
|
||||
failed, missing group). `error_summary` reads:
|
||||
- **paused** — window closed mid-run with at least one target still in
|
||||
`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
|
||||
groups delivered. This account is at capacity for this fan-out —
|
||||
consider sending the remainder from another paired account."`
|
||||
- **failed** — zero sent.
|
||||
groups delivered, 588 still pending. Resume from the Activity tab.
|
||||
If this happens repeatedly, consider offloading to another paired
|
||||
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
|
||||
|
||||
The existing `reminder.fired` SSE event already carries
|
||||
`{ status }`. The web's notification mapper already handles
|
||||
`partial` with a "see activity" hint. The body extends to mention
|
||||
"X of Y delivered" when status === "partial".
|
||||
The existing `reminder.fired` SSE event already carries `{ status }`.
|
||||
The notification mapper extends:
|
||||
|
||||
- `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
|
||||
|
||||
@ -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)
|
||||
|
||||
- **Crash resumability across bot restarts.** Today, if the bot dies
|
||||
mid-fan-out, pg-boss will retry the job; the loop will skip any
|
||||
rows already marked `sent`, but the in-memory rate-limiter and
|
||||
upload-cache state are gone — meaning the retry uploads media
|
||||
again and starts pacing from a full bucket. Acceptable for v1.
|
||||
- **Pause / resume mid-run** controls.
|
||||
- **Cross-day window resume** (current design hard-stops at window
|
||||
end and reports partial; doesn't queue the remainder for tomorrow).
|
||||
- **Crash resumability across bot restarts.** If the bot dies
|
||||
mid-fan-out (mid-window), pg-boss will retry the job; the loop's
|
||||
pre-loaded `pending` rows still pick up correctly, but the
|
||||
in-memory rate-limiter and upload-cache state are gone — the
|
||||
retry re-uploads media and starts pacing from a full bucket. The
|
||||
paused-state resumability covered above is a different mechanism:
|
||||
it handles the "window closed cleanly" case end-to-end. The
|
||||
"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.
|
||||
- **Adaptive rate limiting** (auto-back-off on WA rate-limit response
|
||||
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 30–50 minutes, comfortably inside a 6am–6pm window.
|
||||
- Two reminders on different accounts firing within seconds of each
|
||||
other: both progress simultaneously, neither blocks the other.
|
||||
- A run that hits the window end: stops cleanly, marks remaining as
|
||||
skipped, surfaces the partial-status message in the Activity tab
|
||||
and via the browser notification.
|
||||
- 355 existing tests still pass; ≈25 new tests cover the new helpers.
|
||||
- A run that hits the window end mid-fan-out: stops cleanly, marks
|
||||
the run `paused`, leaves un-started targets as `pending`, surfaces
|
||||
the paused-status notification with delivered/total counts.
|
||||
- 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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user