Reminders pick up a real, user-controlled name instead of being auto-named from the first message body. Auto-derive stays as the fallback so empty inputs still produce something useful. Resolution policy (single source of truth in lib/reminder-name.ts) ------------------------------------------------------------------ 1. User-supplied name, trimmed, clamped to 60 chars. 2. First text-bearing message part — text body or media caption, trimmed, clamped to 60. 3. Literal "Reminder" (only if every part is media-without-caption and no name was given). Wizard ------ - New "Name" input above the message stack on step 2 (Compose). Optional (label says so), maxLength 60, placeholder gives an example. Blank flows through the URL as an absent param. - The name parameter passes through every subsequent step (when, groups, review) via the existing URL-state pattern. - Review step gains a "Name" row at the very top showing what the resolver will produce. If the user left it blank, the row shows the auto-derived value plus a muted "(auto from message)" tag so they know what's happening. Edit forms ---------- - `EditMessageForm` gains the same Name input at the top — consistent with the wizard's compose step. - `EditAccountForm` / `EditWhenForm` / `EditGroupsForm` accept the current `name` and forward it unchanged on save. Otherwise saving any of those sections would re-auto-derive the name from the message body, silently overriding what the operator typed. Server action ------------- - Both `createReminderAction` and `updateReminderAction` accept an optional `name` field on the schema. The body collapses through the new `resolveReminderName` helper, replacing the inline `firstLabel ?? "Reminder"` slice. Tests (+17 new in lib/reminder-name.test.ts) -------------------------------------------- - User priority: user name wins over message body even when both are present; trimming. - Auto-derive: first text part, first non-empty after skipping empties, media caption when present, trims around the value. - Fallback: null/undefined/empty stack, every-part-empty, every part media-without-caption. - Clamping: user-supplied long names truncate at 60; auto-derived long names truncate at 60; short names pass through. - The 60-char ceiling matches what the wizard's <Input maxLength> enforces and what the DB column allows. Existing tests updated to pass the new required prop (`initialName` on EditMessageForm, `name` on EditAccountForm/EditGroupsForm SSR fixtures, plus a couple in no-render-warnings.test.tsx). Total: 298 web + 31 shared + 26 bot = 355 passing (was 338). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
509 lines
18 KiB
TypeScript
509 lines
18 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { headers } from "next/headers";
|
|
import { eq } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import { DateTime } from "luxon";
|
|
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
|
|
import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared";
|
|
import { db } from "@/lib/db";
|
|
import { getSeededOperator } from "@/lib/operator";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { pgNotifyBot } from "@/lib/notify";
|
|
import { validateUpdateScheduledAt } from "@/lib/reminder-update";
|
|
import { resolveReminderName } from "@/lib/reminder-name";
|
|
|
|
async function rateLimit(key: string) {
|
|
const h = await headers();
|
|
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown";
|
|
const r = await checkRateLimit(`${key}:${ip}`, { max: 30, windowSec: 10 });
|
|
if (r.limited) throw new Error("Too many requests");
|
|
}
|
|
|
|
export async function deleteReminderAction(formData: FormData): Promise<void> {
|
|
await rateLimit("delete-reminder");
|
|
const reminderId = formData.get("reminderId");
|
|
if (typeof reminderId !== "string") return;
|
|
|
|
const op = await getSeededOperator();
|
|
const reminder = await db.query.reminders.findFirst({
|
|
where: (r, { eq }) => eq(r.id, reminderId),
|
|
});
|
|
if (!reminder) return;
|
|
|
|
// Verify ownership via the account
|
|
const account = await db.query.whatsappAccounts.findFirst({
|
|
where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, op.id)),
|
|
});
|
|
if (!account) return;
|
|
|
|
// Cascading FKs (reminder_runs + reminder_targets + reminder_messages) clean up.
|
|
// pg-boss job for this reminder will fire and find the row gone (soft cancel).
|
|
await db.delete(reminders).where(eq(reminders.id, reminderId));
|
|
|
|
revalidatePath("/reminders" as any);
|
|
redirect("/reminders" as any);
|
|
}
|
|
|
|
/**
|
|
* Resolve and verify the reminder owned by the seeded operator. Returns
|
|
* null if the reminder doesn't exist or belongs to a different account.
|
|
*/
|
|
async function loadOwnedReminder(reminderId: string) {
|
|
const op = await getSeededOperator();
|
|
const reminder = await db.query.reminders.findFirst({
|
|
where: (r, { eq }) => eq(r.id, reminderId),
|
|
});
|
|
if (!reminder) return null;
|
|
const account = await db.query.whatsappAccounts.findFirst({
|
|
where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, op.id)),
|
|
});
|
|
if (!account) return null;
|
|
return reminder;
|
|
}
|
|
|
|
/**
|
|
* Pause an active reminder. The pg-boss job stays armed (we don't have
|
|
* a hard cancel) but `fireReminder` exits early when status !== "active".
|
|
*/
|
|
export async function pauseReminderAction(formData: FormData): Promise<void> {
|
|
await rateLimit("pause-reminder");
|
|
const reminderId = formData.get("reminderId");
|
|
if (typeof reminderId !== "string") return;
|
|
const reminder = await loadOwnedReminder(reminderId);
|
|
if (!reminder) return;
|
|
if (reminder.status !== "active") return; // already not running
|
|
await db
|
|
.update(reminders)
|
|
.set({ status: "paused", updatedAt: new Date() })
|
|
.where(eq(reminders.id, reminderId));
|
|
revalidatePath("/reminders" as any);
|
|
revalidatePath(`/reminders/${reminderId}` as any);
|
|
}
|
|
|
|
/**
|
|
* Restart a paused or ended reminder. For a one-off whose scheduledAt is
|
|
* in the past, push it to "now + 1 minute" so it fires soon. For a
|
|
* recurring reminder, compute the next occurrence from the RRULE.
|
|
* Either way the row flips back to `active` and the pg-boss job is
|
|
* re-armed.
|
|
*/
|
|
export async function restartReminderAction(formData: FormData): Promise<void> {
|
|
await rateLimit("restart-reminder");
|
|
const reminderId = formData.get("reminderId");
|
|
if (typeof reminderId !== "string") return;
|
|
const reminder = await loadOwnedReminder(reminderId);
|
|
if (!reminder) return;
|
|
|
|
let nextFire: Date | null = null;
|
|
const now = new Date();
|
|
if (reminder.scheduleKind === "recurring" && reminder.rrule) {
|
|
const { nextOccurrence } = await import("@cmbot/shared");
|
|
nextFire = nextOccurrence(reminder.rrule, reminder.timezone, now);
|
|
} else if (reminder.scheduledAt && reminder.scheduledAt.getTime() > Date.now() + 30_000) {
|
|
// The original time is still in the future and far enough away to
|
|
// be useful — keep it.
|
|
nextFire = reminder.scheduledAt;
|
|
} else {
|
|
nextFire = new Date(Date.now() + 60_000);
|
|
}
|
|
if (!nextFire) return;
|
|
|
|
await db
|
|
.update(reminders)
|
|
.set({
|
|
status: "active",
|
|
scheduledAt: nextFire,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(reminders.id, reminderId));
|
|
|
|
await pgNotifyBot({
|
|
type: "reminder.schedule",
|
|
reminderId,
|
|
scheduledAtIso: nextFire.toISOString(),
|
|
});
|
|
|
|
revalidatePath("/reminders" as any);
|
|
revalidatePath(`/reminders/${reminderId}` as any);
|
|
}
|
|
|
|
/**
|
|
* Duplicate a reminder. Creates a new reminder with the same account,
|
|
* groups, and message parts. The copy starts \`paused\` and inherits
|
|
* the source's scheduledAt / rrule unchanged — the user can edit the
|
|
* schedule from the detail page and Restart when ready.
|
|
*/
|
|
export async function duplicateReminderAction(formData: FormData): Promise<void> {
|
|
await rateLimit("duplicate-reminder");
|
|
const reminderId = formData.get("reminderId");
|
|
if (typeof reminderId !== "string") return;
|
|
const op = await getSeededOperator();
|
|
|
|
const source = await db.query.reminders.findFirst({
|
|
where: (r, { eq }) => eq(r.id, reminderId),
|
|
});
|
|
if (!source) return;
|
|
const account = await db.query.whatsappAccounts.findFirst({
|
|
where: (a, { eq, and }) =>
|
|
and(eq(a.id, source.accountId), eq(a.operatorId, op.id)),
|
|
});
|
|
if (!account) return;
|
|
|
|
const sourceTargets = await db.query.reminderTargets.findMany({
|
|
where: (t, { eq }) => eq(t.reminderId, reminderId),
|
|
});
|
|
const sourceMessages = await db.query.reminderMessages.findMany({
|
|
where: (m, { eq }) => eq(m.reminderId, reminderId),
|
|
});
|
|
|
|
const newId = await db.transaction(async (tx) => {
|
|
const [rem] = await tx
|
|
.insert(reminders)
|
|
.values({
|
|
accountId: source.accountId,
|
|
name: `${source.name} (copy)`.slice(0, 60),
|
|
scheduleKind: source.scheduleKind,
|
|
scheduledAt: source.scheduledAt,
|
|
rrule: source.rrule,
|
|
timezone: source.timezone,
|
|
// Start paused so the copy doesn't fire on top of the original
|
|
// — the user picks a new time / reactivates from the detail page.
|
|
status: "paused",
|
|
createdBy: op.id,
|
|
})
|
|
.returning({ id: reminders.id });
|
|
|
|
if (sourceTargets.length > 0) {
|
|
await tx.insert(reminderTargets).values(
|
|
sourceTargets.map((t) => ({
|
|
reminderId: rem!.id,
|
|
groupId: t.groupId,
|
|
position: t.position,
|
|
})),
|
|
);
|
|
}
|
|
if (sourceMessages.length > 0) {
|
|
await tx.insert(reminderMessages).values(
|
|
sourceMessages.map((m) => ({
|
|
reminderId: rem!.id,
|
|
position: m.position,
|
|
kind: m.kind,
|
|
textContent: m.textContent,
|
|
mediaId: m.mediaId,
|
|
})),
|
|
);
|
|
}
|
|
return rem!.id;
|
|
});
|
|
|
|
revalidatePath("/reminders");
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-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
|
|
.object({
|
|
accountId: z.string().uuid(),
|
|
groupIds: z.array(z.string().uuid()),
|
|
// The new shape — caller passes one or more MessageParts in send order.
|
|
// Optional/nullable here so the legacy fallback below can be used by
|
|
// older URL bookmarks; the refine() guarantees we end up with at
|
|
// least one valid message either way.
|
|
messages: z.array(messagePartSchema).optional(),
|
|
// 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(),
|
|
// Legacy single-message fields. Still accepted so bookmarked
|
|
// /reminders/new URLs don't 400 after the migration. The action body
|
|
// collapses these into `messages` before doing any work.
|
|
text: z.string().nullable().optional(),
|
|
mediaId: z.string().uuid().nullable().optional(),
|
|
caption: z.string().nullable().optional(),
|
|
// `.datetime({ offset: true })` accepts both UTC `Z` and zoned offsets
|
|
// like `+08:00` (luxon's `toISO()` produces the offset form).
|
|
scheduledAtIso: z.string().datetime({ offset: true }),
|
|
rrule: z.string().nullable().optional(),
|
|
timezone: z.string().default(DEFAULT_TIMEZONE),
|
|
})
|
|
.refine(
|
|
(d) =>
|
|
(d.messages && d.messages.length > 0) ||
|
|
Boolean(d.text?.trim()) ||
|
|
Boolean(d.mediaId),
|
|
{
|
|
message: "Add a message or attach a file",
|
|
path: ["messages"],
|
|
},
|
|
);
|
|
|
|
/** Resolve the schema's union of new + legacy fields into a flat list. */
|
|
function resolveMessageParts(parsed: z.infer<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 =
|
|
| { ok: true; reminderId: string }
|
|
| { ok: false; error: string };
|
|
|
|
export async function createReminderAction(
|
|
input: z.infer<typeof createReminderSchema>,
|
|
): Promise<CreateReminderResult> {
|
|
await rateLimit("create-reminder");
|
|
const parsed = createReminderSchema.safeParse(input);
|
|
if (!parsed.success) {
|
|
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
|
}
|
|
const { accountId, groupIds, scheduledAtIso, rrule, timezone } = parsed.data;
|
|
const parts = resolveMessageParts(parsed.data);
|
|
|
|
const op = await getSeededOperator();
|
|
const account = await db.query.whatsappAccounts.findFirst({
|
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
|
});
|
|
if (!account) return { ok: false, error: "Account not yours" };
|
|
|
|
// Resolve the first-fire timestamp. Cron rules ignore the user-
|
|
// supplied date+time (the form sends a placeholder) and let the cron
|
|
// expression define when the reminder runs first.
|
|
let scheduledAt: Date;
|
|
if (rrule && isCronRule(rrule)) {
|
|
const minCheck = validateMinInterval(rrule, timezone);
|
|
if (!minCheck.ok) return { ok: false, error: minCheck.reason };
|
|
const firstFire = nextOccurrence(rrule, timezone, new Date());
|
|
if (!firstFire) {
|
|
return { ok: false, error: "Cron expression doesn't produce any future fire times" };
|
|
}
|
|
scheduledAt = firstFire;
|
|
} else {
|
|
scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate();
|
|
if (Number.isNaN(scheduledAt.getTime())) {
|
|
return { ok: false, error: "Invalid date" };
|
|
}
|
|
if (scheduledAt.getTime() <= Date.now()) {
|
|
return { ok: false, error: "Time is in the past" };
|
|
}
|
|
}
|
|
|
|
// Verify all groups belong to this account
|
|
const groups = await db.query.whatsappGroups.findMany({
|
|
where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)),
|
|
});
|
|
if (groups.length !== groupIds.length) {
|
|
return { ok: false, error: "One or more groups don't belong to this account" };
|
|
}
|
|
|
|
// User-supplied name wins. If they didn't supply one, derive from
|
|
// the first text-bearing part (text body or caption). Falls back to
|
|
// the literal "Reminder" if every part is media-without-caption.
|
|
const reminderName = resolveReminderName(parsed.data.name, parts);
|
|
|
|
const reminderId = await db.transaction(async (tx) => {
|
|
const [rem] = await tx
|
|
.insert(reminders)
|
|
.values({
|
|
accountId,
|
|
name: reminderName,
|
|
scheduleKind: rrule ? "recurring" : "one_off",
|
|
scheduledAt,
|
|
rrule: rrule ?? null,
|
|
timezone,
|
|
status: "active",
|
|
createdBy: op.id,
|
|
})
|
|
.returning({ id: reminders.id });
|
|
|
|
if (groupIds.length > 0) {
|
|
await tx.insert(reminderTargets).values(
|
|
groupIds.map((groupId, position) => ({ reminderId: rem!.id, groupId, position })),
|
|
);
|
|
}
|
|
|
|
await tx.insert(reminderMessages).values(
|
|
parts.map((p, position) => ({
|
|
reminderId: rem!.id,
|
|
position,
|
|
kind: p.kind,
|
|
textContent: p.textContent,
|
|
mediaId: p.mediaId,
|
|
})),
|
|
);
|
|
return rem!.id;
|
|
});
|
|
|
|
// Schedule via the bot's IPC consumer (Postgres NOTIFY)
|
|
await pgNotifyBot({
|
|
type: "reminder.schedule",
|
|
reminderId,
|
|
scheduledAtIso: scheduledAt.toISOString(),
|
|
});
|
|
|
|
return { ok: true, reminderId };
|
|
}
|
|
|
|
const updateReminderSchema = createReminderSchema.and(
|
|
z.object({ reminderId: z.string().uuid() }),
|
|
);
|
|
|
|
export type UpdateReminderResult =
|
|
| { ok: true; reminderId: string }
|
|
| { ok: false; error: string };
|
|
|
|
export async function updateReminderAction(
|
|
input: z.infer<typeof updateReminderSchema>,
|
|
): Promise<UpdateReminderResult> {
|
|
await rateLimit("update-reminder");
|
|
const parsed = updateReminderSchema.safeParse(input);
|
|
if (!parsed.success) {
|
|
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
|
}
|
|
const { reminderId, accountId, groupIds, scheduledAtIso, rrule, timezone } = parsed.data;
|
|
const parts = resolveMessageParts(parsed.data);
|
|
|
|
const op = await getSeededOperator();
|
|
|
|
// Verify the reminder exists, the operator owns its account, and the
|
|
// (possibly changed) target account is also theirs.
|
|
const existing = await db.query.reminders.findFirst({
|
|
where: (r, { eq }) => eq(r.id, reminderId),
|
|
});
|
|
if (!existing) return { ok: false, error: "Reminder not found" };
|
|
const ownerOfExisting = await db.query.whatsappAccounts.findFirst({
|
|
where: (a, { eq, and }) => and(eq(a.id, existing.accountId), eq(a.operatorId, op.id)),
|
|
});
|
|
if (!ownerOfExisting) return { ok: false, error: "Reminder not yours" };
|
|
const targetAccount = await db.query.whatsappAccounts.findFirst({
|
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
|
|
});
|
|
if (!targetAccount) return { ok: false, error: "Account not yours" };
|
|
|
|
let scheduledAt: Date;
|
|
if (rrule && isCronRule(rrule)) {
|
|
const minCheck = validateMinInterval(rrule, timezone);
|
|
if (!minCheck.ok) return { ok: false, error: minCheck.reason };
|
|
const firstFire = nextOccurrence(rrule, timezone, new Date());
|
|
if (!firstFire) {
|
|
return { ok: false, error: "Cron expression doesn't produce any future fire times" };
|
|
}
|
|
scheduledAt = firstFire;
|
|
} else {
|
|
const validated = validateUpdateScheduledAt({
|
|
iso: scheduledAtIso,
|
|
timezone,
|
|
existingStatus: existing.status,
|
|
existingScheduledAt: existing.scheduledAt,
|
|
now: new Date(),
|
|
});
|
|
if (!validated.ok) return { ok: false, error: validated.error };
|
|
scheduledAt = validated.scheduledAt;
|
|
}
|
|
|
|
const groups = await db.query.whatsappGroups.findMany({
|
|
where: (g, { eq, inArray, and }) => and(eq(g.accountId, accountId), inArray(g.id, groupIds)),
|
|
});
|
|
if (groups.length !== groupIds.length) {
|
|
return { ok: false, error: "One or more groups don't belong to this account" };
|
|
}
|
|
|
|
const reminderName = resolveReminderName(parsed.data.name, parts);
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(reminders)
|
|
.set({
|
|
accountId,
|
|
name: reminderName,
|
|
scheduleKind: rrule ? "recurring" : "one_off",
|
|
scheduledAt,
|
|
rrule: rrule ?? null,
|
|
timezone,
|
|
// Preserve the lifecycle status. Editing fields shouldn't
|
|
// implicitly re-activate a paused or ended reminder — the
|
|
// user can use the explicit Restart action for that.
|
|
status: existing.status,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(reminders.id, reminderId));
|
|
|
|
// Replace targets and messages wholesale — simpler than diffing.
|
|
await tx.delete(reminderTargets).where(eq(reminderTargets.reminderId, reminderId));
|
|
if (groupIds.length > 0) {
|
|
await tx.insert(reminderTargets).values(
|
|
groupIds.map((groupId, position) => ({ reminderId, groupId, position })),
|
|
);
|
|
}
|
|
|
|
await tx.delete(reminderMessages).where(eq(reminderMessages.reminderId, reminderId));
|
|
await tx.insert(reminderMessages).values(
|
|
parts.map((p, position) => ({
|
|
reminderId,
|
|
position,
|
|
kind: p.kind,
|
|
textContent: p.textContent,
|
|
mediaId: p.mediaId,
|
|
})),
|
|
);
|
|
});
|
|
|
|
// Re-arm the pg-boss job at the new scheduled time. The handler uses
|
|
// singletonKey=reminder:<id> so this supersedes the prior arming.
|
|
await pgNotifyBot({
|
|
type: "reminder.schedule",
|
|
reminderId,
|
|
scheduledAtIso: scheduledAt.toISOString(),
|
|
});
|
|
|
|
revalidatePath("/reminders");
|
|
revalidatePath(`/reminders/${reminderId}`);
|
|
return { ok: true, reminderId };
|
|
}
|