Reminders
- Add recurrence to wizard step 3 (None / Daily / Weekly+weekday picker /
Monthly / Yearly). Build the RRULE client-side and thread it through
the wizard URL state.
- Action stores rrule + scheduleKind="recurring" on insert.
- Bot reschedules the next occurrence after firing a recurring reminder
using the existing rrule helpers in @cmbot/shared. One-off behavior
unchanged.
- Add reminders.last_fired_at column to track last fire.
Pairing
- Move QR PNG out of the pg_notify payload (the 8000-byte limit was
silently truncating it; QR never reached the web → "QR hang"). PNG
now lives on whatsapp_accounts.last_qr_png; NOTIFY just signals
{type: session.qr, accountId, ts}. Web fetches the bytes from a new
read-only /api/qr/[accountId] route (allowed via middleware).
- handleStartPairing now stops any in-flight session before starting a
fresh one — fixes Re-pair where session.start was a silent no-op and
Baileys never re-emitted QR.
- Pair-live: countdown moved out from over the QR (it was overlapping
the scan area); shown as a discrete progress bar above the QR.
- Add a "Save QR" download button.
Account detail page
- Pair / Unpair / Delete cards are themselves the trigger (form submit
or DialogTrigger) — no inline buttons, whole card is clickable.
- Sync Groups Now card removed earlier; bot already auto-syncs.
Account list page
- Cards are the link target. A small floating Delete trigger (top-right
trash icon) opens the destructive confirm dialog without blocking
navigation on the rest of the card.
Tests
- recurrence.test.ts: 10 tests for buildRrule / kindFromRrule /
describeRecurrence (incl. weekly day combos and BYDAY ordering).
- reminders.schema.test.ts: regression for the "Invalid datetime" bug —
proves strict Zod .datetime() rejected luxon's offset ISO and the
{ offset: true } option accepts both forms.
Migration: 0004_next_prowler.sql
- whatsapp_accounts.last_qr_png (text)
- reminders.last_fired_at (timestamptz)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
264 lines
8.1 KiB
TypeScript
264 lines
8.1 KiB
TypeScript
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import {
|
|
ArrowLeftIcon,
|
|
PencilIcon,
|
|
CalendarIcon,
|
|
UsersIcon,
|
|
FileTextIcon,
|
|
SmartphoneIcon,
|
|
RepeatIcon,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { getSeededOperator } from "@/lib/operator";
|
|
import { getAccount, listGroupsForAccount } from "@/lib/queries";
|
|
import { ReviewSubmitClient } from "./review-submit-client";
|
|
import { DateTime } from "luxon";
|
|
import { describeRecurrence, kindFromRrule } from "@/lib/recurrence";
|
|
|
|
interface StepReviewParams {
|
|
step?: string;
|
|
accountId?: string;
|
|
groupIds?: string;
|
|
text?: string;
|
|
mediaId?: string;
|
|
caption?: string;
|
|
scheduledAt?: string;
|
|
rrule?: string;
|
|
}
|
|
|
|
interface StepReviewProps {
|
|
params: StepReviewParams;
|
|
}
|
|
|
|
const WEEKDAY_TO_ISO: Record<string, number> = {
|
|
MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7,
|
|
};
|
|
function parseWeeklyDaysFromRrule(rrule: string | undefined): number[] {
|
|
if (!rrule) return [];
|
|
const m = rrule.match(/BYDAY=([A-Z,]+)/i);
|
|
if (!m) return [];
|
|
return m[1]!
|
|
.split(",")
|
|
.map((d) => WEEKDAY_TO_ISO[d.toUpperCase()])
|
|
.filter((d): d is number => d !== undefined);
|
|
}
|
|
|
|
function formatScheduledAt(iso: string, timezone: string): string {
|
|
try {
|
|
const dt = DateTime.fromISO(iso, { zone: timezone });
|
|
if (!dt.isValid) return iso;
|
|
return dt.toLocaleString(DateTime.DATETIME_FULL);
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function editLink(
|
|
step: number,
|
|
accountId: string,
|
|
groupIds?: string,
|
|
text?: string,
|
|
mediaId?: string,
|
|
caption?: string,
|
|
scheduledAt?: string,
|
|
rrule?: string,
|
|
): string {
|
|
const sp = new URLSearchParams({ step: String(step), accountId });
|
|
if (groupIds) sp.set("groupIds", groupIds);
|
|
if (text) sp.set("text", text);
|
|
if (mediaId) sp.set("mediaId", mediaId);
|
|
if (caption) sp.set("caption", caption);
|
|
if (scheduledAt) sp.set("scheduledAt", scheduledAt);
|
|
if (rrule) sp.set("rrule", rrule);
|
|
return `/reminders/new?${sp.toString()}`;
|
|
}
|
|
|
|
export async function StepReview({ params }: StepReviewProps) {
|
|
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = params;
|
|
|
|
if (!accountId || !scheduledAt || (!text && !mediaId)) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
redirect("/reminders/new" as any);
|
|
}
|
|
|
|
const op = await getSeededOperator();
|
|
const timezone = op.defaultTimezone ?? "UTC";
|
|
|
|
// Fetch account details
|
|
const account = await getAccount(op.id, accountId);
|
|
if (!account) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
redirect("/reminders/new" as any);
|
|
}
|
|
|
|
// Fetch group names
|
|
const groupIdsArray = groupIds ? groupIds.split(",").filter(Boolean) : [];
|
|
const groupsResult =
|
|
groupIdsArray.length > 0 ? await listGroupsForAccount(op.id, accountId) : null;
|
|
const selectedGroups = groupsResult
|
|
? groupsResult.groups.filter((g) => groupIdsArray.includes(g.id))
|
|
: [];
|
|
|
|
const formattedDate = formatScheduledAt(scheduledAt, timezone);
|
|
|
|
// Back goes to step 4 (Groups, the previous step in the new order)
|
|
const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule);
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
<Link href={backHref as any}>
|
|
<ArrowLeftIcon />
|
|
Back
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
Review your reminder before scheduling.
|
|
</p>
|
|
|
|
<div className="space-y-3">
|
|
{/* Account */}
|
|
<ReviewRow
|
|
icon={<SmartphoneIcon className="size-4" />}
|
|
label="Account"
|
|
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
|
>
|
|
<span className="text-sm font-medium">{account.label}</span>
|
|
{account.phoneNumber && (
|
|
<span className="text-xs text-muted-foreground ml-1.5">{account.phoneNumber}</span>
|
|
)}
|
|
</ReviewRow>
|
|
|
|
{/* Message */}
|
|
<ReviewRow
|
|
icon={<FileTextIcon className="size-4" />}
|
|
label="Message"
|
|
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
|
>
|
|
{mediaId ? (
|
|
<span className="text-sm text-muted-foreground">
|
|
Media file
|
|
{caption && (
|
|
<> with caption: <span className="text-foreground">{caption}</span></>
|
|
)}
|
|
{text && (
|
|
<> · <span className="text-foreground">{text}</span></>
|
|
)}
|
|
</span>
|
|
) : text ? (
|
|
<p className="text-sm whitespace-pre-wrap break-words">{text}</p>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground italic">No message</span>
|
|
)}
|
|
</ReviewRow>
|
|
|
|
{/* When */}
|
|
<ReviewRow
|
|
icon={<CalendarIcon className="size-4" />}
|
|
label={rrule ? "First fire" : "When"}
|
|
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
|
>
|
|
<span className="text-sm font-medium">{formattedDate}</span>
|
|
</ReviewRow>
|
|
|
|
{/* Recurrence (only if set) */}
|
|
{rrule && (
|
|
<ReviewRow
|
|
icon={<RepeatIcon className="size-4" />}
|
|
label="Repeats"
|
|
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
|
>
|
|
<span className="text-sm font-medium">
|
|
{describeRecurrence(
|
|
kindFromRrule(rrule),
|
|
DateTime.fromISO(scheduledAt!, { zone: timezone }),
|
|
parseWeeklyDaysFromRrule(rrule),
|
|
)}
|
|
</span>
|
|
</ReviewRow>
|
|
)}
|
|
|
|
{/* Groups */}
|
|
<ReviewRow
|
|
icon={<UsersIcon className="size-4" />}
|
|
label="Groups"
|
|
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
|
>
|
|
{selectedGroups.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{selectedGroups.map((g) => (
|
|
<span
|
|
key={g.id}
|
|
className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
|
|
>
|
|
{g.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground italic">
|
|
No groups — reminder will be saved without targets
|
|
</span>
|
|
)}
|
|
</ReviewRow>
|
|
</div>
|
|
|
|
<ReviewSubmitClient
|
|
accountId={accountId}
|
|
groupIds={groupIds}
|
|
text={text}
|
|
mediaId={mediaId}
|
|
caption={caption}
|
|
scheduledAt={scheduledAt}
|
|
rrule={rrule}
|
|
timezone={timezone}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ReviewRow({
|
|
icon,
|
|
label,
|
|
editHref,
|
|
children,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
editHref: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-3 px-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground mt-0.5">
|
|
{icon}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
|
{label}
|
|
</p>
|
|
<div>{children}</div>
|
|
</div>
|
|
<Link
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
href={editHref as any}
|
|
className="shrink-0 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1.5 py-1 hover:bg-muted"
|
|
aria-label={`Edit ${label}`}
|
|
>
|
|
<PencilIcon className="size-3" />
|
|
Edit
|
|
</Link>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|