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.
|
// 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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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
|
## 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 30–50 minutes, comfortably inside a 6am–6pm window.
|
in roughly 30–50 minutes, comfortably inside a 6am–6pm 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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user