feat: recurring reminders, fix QR pairing, account UX polish, tests
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>
This commit is contained in:
parent
86f2fe0124
commit
2b738383e4
@ -3,7 +3,10 @@ import { db } from "../db.js";
|
||||
import { logger } from "../logger.js";
|
||||
|
||||
export type WebEvent =
|
||||
| { type: "session.qr"; accountId: string; qrPng: string /* base64 */ }
|
||||
// QR PNG bytes live in `whatsapp_accounts.last_qr_png` so this NOTIFY
|
||||
// payload stays under Postgres' 8000-byte limit. Web fetches the PNG
|
||||
// from /api/qr/[accountId] when it sees this event.
|
||||
| { type: "session.qr"; accountId: string; ts: number }
|
||||
| { type: "session.connected"; accountId: string; phoneNumber: string | null }
|
||||
| { type: "session.disconnected"; accountId: string }
|
||||
| { type: "session.timeout"; accountId: string }
|
||||
|
||||
@ -51,6 +51,19 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// For Re-pair, an old session may still be alive. Stop it so
|
||||
// sessionManager.start() actually opens a fresh socket and Baileys emits
|
||||
// a new QR. (start() is a no-op when a session is already registered.)
|
||||
if (sessionManager.hasSession(accountId)) {
|
||||
await sessionManager.stop(accountId);
|
||||
}
|
||||
// Clear any stale QR lingering from a prior attempt.
|
||||
lastQrPayload.delete(accountId);
|
||||
await db
|
||||
.update(whatsappAccounts)
|
||||
.set({ lastQrPng: null })
|
||||
.where(eq(whatsappAccounts.id, accountId));
|
||||
|
||||
const off = sessionManager.on(async (id, _state, event) => {
|
||||
if (id !== accountId) return;
|
||||
try {
|
||||
@ -58,10 +71,16 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
||||
if (lastQrPayload.get(id) === event.payload) return;
|
||||
lastQrPayload.set(id, event.payload);
|
||||
const png = await renderQrPng(event.payload);
|
||||
// PNG is too large (~5-10KB) for pg_notify (8000 byte limit).
|
||||
// Persist on the account row; web fetches via /api/qr/[id].
|
||||
await db
|
||||
.update(whatsappAccounts)
|
||||
.set({ lastQrPng: png.toString("base64"), lastQrAt: new Date() })
|
||||
.where(eq(whatsappAccounts.id, id));
|
||||
await pgNotifyWeb({
|
||||
type: "session.qr",
|
||||
accountId: id,
|
||||
qrPng: png.toString("base64"),
|
||||
ts: Date.now(),
|
||||
});
|
||||
} else if (event.type === "open") {
|
||||
const t = pairTimeouts.get(id);
|
||||
|
||||
@ -4,10 +4,12 @@ import { db } from "../db.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { sessionManager } from "../whatsapp/session-manager.js";
|
||||
import { sendTextToGroup, sendMediaToGroup } from "../whatsapp/sender.js";
|
||||
import { absoluteMediaPath } from "@cmbot/shared";
|
||||
import { absoluteMediaPath, nextOccurrence } from "@cmbot/shared";
|
||||
import { env } from "../env.js";
|
||||
import { writeAuditLog } from "../audit.js";
|
||||
import { getReminderWithDetails } from "../reminders/crud.js";
|
||||
import { getBoss } from "./pgboss-client.js";
|
||||
import { scheduleReminderFire } from "./reminder-jobs.js";
|
||||
|
||||
export type FireReminderPayload = { reminderId: string };
|
||||
|
||||
@ -119,14 +121,31 @@ export async function fireReminder(payload: FireReminderPayload): Promise<void>
|
||||
.set({ status })
|
||||
.where(eq(reminderRuns.id, runId));
|
||||
|
||||
// One-off reminders are done after firing — flip them to 'ended' so the
|
||||
// menu shows ⚪ instead of 🟢. Recurring reminders stay 'active' (more
|
||||
// occurrences pending; recurring is a future-plan feature).
|
||||
// One-off reminders end after firing. Recurring reminders compute the
|
||||
// next occurrence from the RRULE and re-arm the pg-boss job; only the
|
||||
// last fire timestamp + updatedAt move forward.
|
||||
if (reminder.scheduleKind === "one_off") {
|
||||
await db
|
||||
.update(reminders)
|
||||
.set({ status: "ended", updatedAt: new Date() })
|
||||
.where(eq(reminders.id, reminder.id));
|
||||
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
|
||||
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
|
||||
await db
|
||||
.update(reminders)
|
||||
.set({ lastFiredAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(reminders.id, reminder.id));
|
||||
if (next) {
|
||||
try {
|
||||
await scheduleReminderFire(getBoss(), reminder.id, next);
|
||||
logger.info({ reminderId: reminder.id, next }, "fire-reminder: re-armed for next occurrence");
|
||||
} catch (err) {
|
||||
logger.error({ err, reminderId: reminder.id }, "fire-reminder: failed to re-arm next occurrence");
|
||||
}
|
||||
} else {
|
||||
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
|
||||
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
|
||||
}
|
||||
}
|
||||
|
||||
await writeAuditLog(db, {
|
||||
|
||||
30
apps/web/src/actions/reminders.schema.test.ts
Normal file
30
apps/web/src/actions/reminders.schema.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
/**
|
||||
* Regression test for the "Invalid datetime" error.
|
||||
*
|
||||
* Earlier the action used `z.string().datetime()` (strict — UTC `Z` only).
|
||||
* Luxon's `dt.toISO()` produces an offset-suffixed form like
|
||||
* `2026-05-10T09:00:00.000+08:00`, which the strict validator rejects.
|
||||
* The fix uses `.datetime({ offset: true })`.
|
||||
*
|
||||
* If this test ever fails again it means the schema regressed and any
|
||||
* Asia/Kuala_Lumpur reminder will be rejected at submit.
|
||||
*/
|
||||
describe("createReminderAction Zod schema (datetime validator)", () => {
|
||||
const offsetIso = DateTime.fromISO("2026-05-10T09:00:00", { zone: "Asia/Kuala_Lumpur" }).toISO()!;
|
||||
const utcIso = DateTime.fromISO("2026-05-10T09:00:00", { zone: "UTC" }).toISO()!;
|
||||
|
||||
it("strict .datetime() (no options) rejects offset-suffixed ISO — that was the bug", () => {
|
||||
const strict = z.string().datetime();
|
||||
expect(strict.safeParse(offsetIso).success).toBe(false);
|
||||
});
|
||||
|
||||
it(".datetime({ offset: true }) accepts both offset and UTC ISO — that's the fix", () => {
|
||||
const lenient = z.string().datetime({ offset: true });
|
||||
expect(lenient.safeParse(offsetIso).success).toBe(true);
|
||||
expect(lenient.safeParse(utcIso).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -55,6 +55,7 @@ const createReminderSchema = z
|
||||
// `.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) => Boolean(d.text?.trim()) || Boolean(d.mediaId), {
|
||||
@ -74,7 +75,7 @@ export async function createReminderAction(
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
||||
}
|
||||
const { accountId, groupIds, text, mediaId, caption, scheduledAtIso, timezone } = parsed.data;
|
||||
const { accountId, groupIds, text, mediaId, caption, scheduledAtIso, rrule, timezone } = parsed.data;
|
||||
|
||||
const op = await getSeededOperator();
|
||||
const account = await db.query.whatsappAccounts.findFirst({
|
||||
@ -104,8 +105,9 @@ export async function createReminderAction(
|
||||
.values({
|
||||
accountId,
|
||||
name: (text ?? caption ?? "Reminder").slice(0, 50),
|
||||
scheduleKind: "one_off",
|
||||
scheduleKind: rrule ? "recurring" : "one_off",
|
||||
scheduledAt,
|
||||
rrule: rrule ?? null,
|
||||
timezone,
|
||||
status: "active",
|
||||
createdBy: op.id,
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
DatabaseIcon,
|
||||
PowerIcon,
|
||||
PowerOffIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -17,7 +18,6 @@ import {
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
@ -52,7 +52,6 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
|
||||
{/* Back link */}
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={"/accounts" as any}>
|
||||
@ -61,7 +60,6 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{account.label}</h1>
|
||||
@ -75,43 +73,43 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Pair / Re-pair — visible when not currently connected */}
|
||||
{/* Pair / Re-pair — entire card is the submit button */}
|
||||
{account.status !== "connected" && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10">
|
||||
<PowerIcon className="size-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{account.status === "unpaired" ? "Pair WhatsApp" : "Re-pair WhatsApp"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show a QR code so this account can connect to WhatsApp
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={pairAccountAction}>
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<Button type="submit" size="sm">
|
||||
<PowerIcon />
|
||||
{account.status === "unpaired" ? "Pair Now" : "Re-pair"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<form action={pairAccountAction} className="contents">
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10">
|
||||
<PowerIcon className="size-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{account.status === "unpaired" ? "Pair WhatsApp" : "Re-pair WhatsApp"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show a QR code so this account can connect to WhatsApp
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Groups + Sync — visible when connected */}
|
||||
{account.status === "connected" && (
|
||||
<>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/accounts/${account.id}/groups` as any}
|
||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
||||
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
@ -124,101 +122,106 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
||||
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
||||
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Unpair</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disconnect from WhatsApp; keep the account so you can re-pair later
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<PowerOffIcon />
|
||||
Unpair
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unpair this account?</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{account.label}</strong> will disconnect from WhatsApp and
|
||||
scheduled reminders using it will stop firing until you re-pair.
|
||||
The account itself is kept; reminders and other data are not deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton>
|
||||
<form action={unpairAccountAction}>
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<Button type="submit" variant="default" size="sm">
|
||||
<PowerOffIcon />
|
||||
Yes, unpair
|
||||
</Button>
|
||||
</form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete — always available */}
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<Trash2Icon className="size-4 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive">Delete Account</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove the account and all its reminders, groups, and history
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Unpair — entire card opens the confirm dialog */}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Trash2Icon />
|
||||
Delete
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<Card className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
|
||||
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Unpair</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disconnect from WhatsApp; keep the account so you can re-pair later
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete this account permanently?</DialogTitle>
|
||||
<DialogTitle>Unpair this account?</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{account.label}</strong> will be removed along with its
|
||||
synced groups, scheduled reminders, and all run history. This cannot be
|
||||
undone.
|
||||
<strong>{account.label}</strong> will disconnect from WhatsApp and
|
||||
scheduled reminders using it will stop firing until you re-pair.
|
||||
The account itself is kept; reminders and other data are not deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton>
|
||||
<form action={deleteAccountAction}>
|
||||
<form action={unpairAccountAction}>
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2Icon />
|
||||
Yes, delete
|
||||
<Button type="submit" variant="default" size="sm">
|
||||
<PowerOffIcon />
|
||||
Yes, unpair
|
||||
</Button>
|
||||
</form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete — entire card opens the confirm dialog */}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<Card className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<Trash2Icon className="size-4 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive">Delete Account</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove the account and all its reminders, groups, and history
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete this account permanently?</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{account.label}</strong> will be removed along with its
|
||||
synced groups, scheduled reminders, and all run history. This cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton>
|
||||
<form action={deleteAccountAction}>
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2Icon />
|
||||
Yes, delete
|
||||
</Button>
|
||||
</form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Detail grid */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
|
||||
import { PlusIcon, SmartphoneIcon, CalendarIcon, Trash2Icon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@ -7,9 +7,19 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { listAccounts } from "@/lib/queries";
|
||||
import { deleteAccountAction } from "@/actions/accounts";
|
||||
|
||||
export default async function AccountsPage() {
|
||||
const op = await getSeededOperator();
|
||||
@ -31,45 +41,80 @@ export default async function AccountsPage() {
|
||||
{accounts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{accounts.map((account) => (
|
||||
<Link
|
||||
key={account.id}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/accounts/${account.id}` as any}
|
||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
||||
>
|
||||
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base leading-snug">{account.label}</CardTitle>
|
||||
<AccountStatusBadge status={account.status} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{account.phoneNumber ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<SmartphoneIcon className="size-3.5 shrink-0" />
|
||||
<span>{account.phoneNumber}</span>
|
||||
<div key={account.id} className="relative">
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/accounts/${account.id}` as any}
|
||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
||||
>
|
||||
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base leading-snug pr-8">
|
||||
{account.label}
|
||||
</CardTitle>
|
||||
<AccountStatusBadge status={account.status} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/60 italic">Not paired yet</p>
|
||||
)}
|
||||
{account.lastConnectedAt ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarIcon className="size-3 shrink-0" />
|
||||
<span>
|
||||
Last connected{" "}
|
||||
{account.lastConnectedAt.toLocaleDateString("en-MY", {
|
||||
timeZone: "Asia/Kuala_Lumpur",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{account.phoneNumber ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<SmartphoneIcon className="size-3.5 shrink-0" />
|
||||
<span>{account.phoneNumber}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/60 italic">Not paired yet</p>
|
||||
)}
|
||||
{account.lastConnectedAt ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CalendarIcon className="size-3 shrink-0" />
|
||||
<span>
|
||||
Last connected{" "}
|
||||
{account.lastConnectedAt.toLocaleDateString("en-MY", {
|
||||
timeZone: "Asia/Kuala_Lumpur",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Floating delete trigger — sits over the card without
|
||||
blocking the link target on the rest of the surface. */}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Delete ${account.label}`}
|
||||
className="absolute right-2 top-2 z-10 flex size-7 items-center justify-center rounded-full bg-background/80 text-muted-foreground hover:bg-destructive/10 hover:text-destructive backdrop-blur-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete this account?</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{account.label}</strong> and all its reminders, groups,
|
||||
and history will be permanently removed. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton>
|
||||
<form action={deleteAccountAction}>
|
||||
<input type="hidden" name="accountId" value={account.id} />
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2Icon />
|
||||
Yes, delete
|
||||
</Button>
|
||||
</form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
24
apps/web/src/app/api/qr/[accountId]/route.ts
Normal file
24
apps/web/src/app/api/qr/[accountId]/route.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ accountId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, ctx: RouteContext): Promise<Response> {
|
||||
const { accountId } = await ctx.params;
|
||||
const account = await db.query.whatsappAccounts.findFirst({
|
||||
where: (a, { eq }) => eq(a.id, accountId),
|
||||
columns: { lastQrPng: true },
|
||||
});
|
||||
if (!account?.lastQrPng) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
}
|
||||
const buf = Buffer.from(account.lastQrPng, "base64");
|
||||
return new NextResponse(new Uint8Array(buf), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -3,14 +3,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2Icon, XCircleIcon, ScanLineIcon } from "lucide-react";
|
||||
import { CheckCircle2Icon, XCircleIcon, ScanLineIcon, DownloadIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useEvents } from "@/hooks/use-events";
|
||||
|
||||
type PairingState =
|
||||
| { phase: "waiting" }
|
||||
| { phase: "qr"; qrPng: string }
|
||||
| { phase: "qr"; qrUrl: string }
|
||||
| { phase: "connected"; phoneNumber: string }
|
||||
| { phase: "timeout" };
|
||||
|
||||
@ -19,42 +19,31 @@ interface PairLiveProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** SVG countdown ring. radius=54 so circumference ≈ 339.3 */
|
||||
function CountdownRing({ seconds, total }: { seconds: number; total: number }) {
|
||||
const r = 54;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const progress = seconds / total;
|
||||
const dash = circ * progress;
|
||||
|
||||
function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round((seconds / total) * 100)));
|
||||
const danger = seconds <= 10;
|
||||
return (
|
||||
<svg
|
||||
className="absolute inset-0 -rotate-90"
|
||||
width="128"
|
||||
height="128"
|
||||
viewBox="0 0 128 128"
|
||||
aria-hidden
|
||||
>
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth="3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
{/* Remaining */}
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={`${dash} ${circ}`}
|
||||
strokeLinecap="round"
|
||||
className="stroke-foreground transition-[stroke-dasharray] duration-1000 ease-linear"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex w-full max-w-64 flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">QR expires in</span>
|
||||
<span
|
||||
className={`font-mono tabular-nums font-medium ${
|
||||
danger ? "text-destructive" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{seconds}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full transition-[width] duration-1000 ease-linear ${
|
||||
danger ? "bg-destructive" : "bg-foreground/70"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -84,7 +73,8 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
||||
useEvents({
|
||||
"session.qr": (data) => {
|
||||
if (data.accountId !== accountId) return;
|
||||
setPairingState({ phase: "qr", qrPng: data.qrPng });
|
||||
// Bust the URL with the timestamp so the browser refetches each time.
|
||||
setPairingState({ phase: "qr", qrUrl: `/api/qr/${accountId}?t=${data.ts}` });
|
||||
startCountdown();
|
||||
},
|
||||
"session.connected": (data) => {
|
||||
@ -136,39 +126,33 @@ export function PairLive({ accountId, label }: PairLiveProps) {
|
||||
|
||||
{pairingState.phase === "qr" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* QR + ring overlay */}
|
||||
<div className="relative">
|
||||
{/* Countdown ring positioned around the QR */}
|
||||
<div className="absolute -inset-5 flex items-center justify-center pointer-events-none">
|
||||
<div className="relative size-32">
|
||||
<CountdownRing seconds={countdown} total={COUNTDOWN_TOTAL} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span
|
||||
className={`font-mono text-xs tabular-nums font-medium transition-colors ${
|
||||
countdown <= 10 ? "text-destructive" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{countdown}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Countdown — separate from the QR so it doesn't obstruct scanning */}
|
||||
<CountdownBar seconds={countdown} total={COUNTDOWN_TOTAL} />
|
||||
|
||||
{/* QR image */}
|
||||
<img
|
||||
src={`data:image/png;base64,${pairingState.qrPng}`}
|
||||
alt="WhatsApp QR code"
|
||||
width={256}
|
||||
height={256}
|
||||
className="rounded-lg ring-1 ring-foreground/10"
|
||||
/>
|
||||
</div>
|
||||
{/* QR image */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={pairingState.qrUrl}
|
||||
alt="WhatsApp QR code"
|
||||
width={256}
|
||||
height={256}
|
||||
className="rounded-lg ring-1 ring-foreground/10"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-sm font-medium">Scan with WhatsApp → Linked Devices</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Open WhatsApp → tap ⋮ → Linked Devices → Link a device
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-1">
|
||||
<a
|
||||
href={pairingState.qrUrl}
|
||||
download={`whatsapp-qr-${label.replace(/\s+/g, "-").toLowerCase() || accountId}.png`}
|
||||
>
|
||||
<DownloadIcon />
|
||||
Save QR
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -19,6 +19,7 @@ interface PassThroughParams {
|
||||
mediaId?: string;
|
||||
caption?: string;
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
}
|
||||
|
||||
interface GroupsFormClientProps {
|
||||
@ -70,6 +71,7 @@ export function GroupsFormClient({
|
||||
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
|
||||
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
|
||||
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
|
||||
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ interface ReviewSubmitClientProps {
|
||||
mediaId?: string;
|
||||
caption?: string;
|
||||
scheduledAt: string;
|
||||
rrule?: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
@ -24,6 +25,7 @@ export function ReviewSubmitClient({
|
||||
mediaId,
|
||||
caption,
|
||||
scheduledAt,
|
||||
rrule,
|
||||
timezone,
|
||||
}: ReviewSubmitClientProps) {
|
||||
const router = useRouter();
|
||||
@ -42,6 +44,7 @@ export function ReviewSubmitClient({
|
||||
mediaId: mediaId ?? null,
|
||||
caption: caption ?? null,
|
||||
scheduledAtIso: scheduledAt,
|
||||
rrule: rrule ?? null,
|
||||
timezone,
|
||||
});
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ interface StepGroupsParams {
|
||||
mediaId?: string;
|
||||
caption?: string;
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
@ -22,7 +23,15 @@ interface StepGroupsProps {
|
||||
}
|
||||
|
||||
export async function StepGroups({ params }: StepGroupsProps) {
|
||||
const { accountId, groupIds: groupIdsParam, groupId: singleGroupId, scheduledAt, text, mediaId } = params;
|
||||
const {
|
||||
accountId,
|
||||
groupIds: groupIdsParam,
|
||||
groupId: singleGroupId,
|
||||
scheduledAt,
|
||||
text,
|
||||
mediaId,
|
||||
rrule,
|
||||
} = params;
|
||||
|
||||
if (!accountId || !scheduledAt || (!text && !mediaId)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -64,6 +73,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
|
||||
if (mediaId) backParams.set("mediaId", mediaId);
|
||||
if (params.caption) backParams.set("caption", params.caption);
|
||||
if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
|
||||
if (rrule) backParams.set("rrule", rrule);
|
||||
const backHref = `/reminders/new?${backParams.toString()}`;
|
||||
|
||||
return (
|
||||
@ -92,6 +102,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
|
||||
mediaId: params.mediaId,
|
||||
caption: params.caption,
|
||||
scheduledAt: params.scheduledAt,
|
||||
rrule,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -106,6 +117,7 @@ interface PassThroughParams {
|
||||
mediaId?: string;
|
||||
caption?: string;
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
}
|
||||
|
||||
function StepGroupsForm({
|
||||
|
||||
@ -1,12 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowLeftIcon, PencilIcon, CalendarIcon, UsersIcon, FileTextIcon, SmartphoneIcon } from "lucide-react";
|
||||
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;
|
||||
@ -16,12 +25,26 @@ interface StepReviewParams {
|
||||
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 });
|
||||
@ -39,7 +62,8 @@ function editLink(
|
||||
text?: string,
|
||||
mediaId?: string,
|
||||
caption?: string,
|
||||
scheduledAt?: string
|
||||
scheduledAt?: string,
|
||||
rrule?: string,
|
||||
): string {
|
||||
const sp = new URLSearchParams({ step: String(step), accountId });
|
||||
if (groupIds) sp.set("groupIds", groupIds);
|
||||
@ -47,11 +71,12 @@ function editLink(
|
||||
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 } = params;
|
||||
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = params;
|
||||
|
||||
if (!accountId || !scheduledAt || (!text && !mediaId)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -79,7 +104,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
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);
|
||||
const backHref = editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@ -102,7 +127,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<SmartphoneIcon className="size-4" />}
|
||||
label="Account"
|
||||
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt)}
|
||||
editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||
>
|
||||
<span className="text-sm font-medium">{account.label}</span>
|
||||
{account.phoneNumber && (
|
||||
@ -114,7 +139,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<FileTextIcon className="size-4" />}
|
||||
label="Message"
|
||||
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt)}
|
||||
editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||
>
|
||||
{mediaId ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@ -136,17 +161,34 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
{/* When */}
|
||||
<ReviewRow
|
||||
icon={<CalendarIcon className="size-4" />}
|
||||
label="When"
|
||||
editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt)}
|
||||
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)}
|
||||
editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)}
|
||||
>
|
||||
{selectedGroups.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@ -174,6 +216,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
mediaId={mediaId}
|
||||
caption={caption}
|
||||
scheduledAt={scheduledAt}
|
||||
rrule={rrule}
|
||||
timezone={timezone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import { DateTime } from "luxon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { WhenFormClient } from "./when-form-client";
|
||||
import { kindFromRrule } from "@/lib/recurrence";
|
||||
|
||||
interface StepWhenParams {
|
||||
step?: string;
|
||||
@ -14,14 +15,29 @@ interface StepWhenParams {
|
||||
mediaId?: string;
|
||||
caption?: string;
|
||||
scheduledAt?: string;
|
||||
rrule?: string;
|
||||
}
|
||||
|
||||
interface StepWhenProps {
|
||||
params: StepWhenParams;
|
||||
}
|
||||
|
||||
const WEEKDAY_TO_ISO: Record<string, number> = {
|
||||
MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7,
|
||||
};
|
||||
|
||||
function parseWeeklyDays(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);
|
||||
}
|
||||
|
||||
export async function StepWhen({ params }: StepWhenProps) {
|
||||
const { accountId, groupIds, text, mediaId, caption, scheduledAt } = params;
|
||||
const { accountId, groupIds, text, mediaId, caption, scheduledAt, rrule } = params;
|
||||
|
||||
if (!accountId || (!text && !mediaId)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -36,6 +52,7 @@ export async function StepWhen({ params }: StepWhenProps) {
|
||||
if (text) backParams.set("text", text);
|
||||
if (mediaId) backParams.set("mediaId", mediaId);
|
||||
if (caption) backParams.set("caption", caption);
|
||||
if (rrule) backParams.set("rrule", rrule);
|
||||
const backHref = `/reminders/new?${backParams.toString()}`;
|
||||
|
||||
return (
|
||||
@ -63,6 +80,8 @@ export async function StepWhen({ params }: StepWhenProps) {
|
||||
scheduledAt ??
|
||||
DateTime.now().setZone(timezone).plus({ hours: 1 }).startOf("minute").toISO()!
|
||||
}
|
||||
initialKind={kindFromRrule(rrule)}
|
||||
initialWeeklyDays={parseWeeklyDays(rrule)}
|
||||
passThroughParams={{ text, mediaId, caption }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -3,10 +3,16 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DateTime } from "luxon";
|
||||
import { CalendarIcon, ClockIcon, AlertCircleIcon } from "lucide-react";
|
||||
import { CalendarIcon, ClockIcon, AlertCircleIcon, RepeatIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
WEEKDAY_LABELS,
|
||||
buildRrule,
|
||||
type RecurrenceKind,
|
||||
} from "@/lib/recurrence";
|
||||
|
||||
interface PassThroughParams {
|
||||
text?: string;
|
||||
@ -18,11 +24,20 @@ interface WhenFormClientProps {
|
||||
accountId: string;
|
||||
groupIds: string;
|
||||
timezone: string;
|
||||
/** Pre-computed default ISO from the server — guarantees no hydration drift. */
|
||||
initialDefaultIso: string;
|
||||
initialKind?: RecurrenceKind;
|
||||
initialWeeklyDays?: number[];
|
||||
passThroughParams: PassThroughParams;
|
||||
}
|
||||
|
||||
const KINDS: Array<{ value: RecurrenceKind; label: string }> = [
|
||||
{ value: "none", label: "One-off" },
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
];
|
||||
|
||||
function splitDateTime(iso: string, tz: string): { date: string; time: string } {
|
||||
const dt = DateTime.fromISO(iso, { zone: tz });
|
||||
if (!dt.isValid) return { date: "", time: "" };
|
||||
@ -34,6 +49,8 @@ export function WhenFormClient({
|
||||
groupIds,
|
||||
timezone,
|
||||
initialDefaultIso,
|
||||
initialKind = "none",
|
||||
initialWeeklyDays = [],
|
||||
passThroughParams,
|
||||
}: WhenFormClientProps) {
|
||||
const router = useRouter();
|
||||
@ -41,8 +58,16 @@ export function WhenFormClient({
|
||||
|
||||
const [date, setDate] = useState(initial.date);
|
||||
const [time, setTime] = useState(initial.time);
|
||||
const [kind, setKind] = useState<RecurrenceKind>(initialKind);
|
||||
const [weeklyDays, setWeeklyDays] = useState<number[]>(initialWeeklyDays);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggleWeekday(iso: number) {
|
||||
setWeeklyDays((prev) =>
|
||||
prev.includes(iso) ? prev.filter((d) => d !== iso) : [...prev, iso].sort((a, b) => a - b),
|
||||
);
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
if (!date || !time) {
|
||||
setError("Pick both a date and a time.");
|
||||
@ -54,9 +79,10 @@ export function WhenFormClient({
|
||||
return;
|
||||
}
|
||||
if (dt.toMillis() <= Date.now()) {
|
||||
setError("The selected time is in the past. Choose a future time.");
|
||||
setError("The first occurrence is in the past. Pick a future date and time.");
|
||||
return;
|
||||
}
|
||||
const rrule = buildRrule(kind, dt, weeklyDays);
|
||||
const scheduledAt = dt.toISO()!;
|
||||
const sp = new URLSearchParams({
|
||||
step: "4",
|
||||
@ -64,6 +90,7 @@ export function WhenFormClient({
|
||||
scheduledAt,
|
||||
});
|
||||
if (groupIds) sp.set("groupIds", groupIds);
|
||||
if (rrule) sp.set("rrule", rrule);
|
||||
if (passThroughParams.text) sp.set("text", passThroughParams.text);
|
||||
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
|
||||
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
|
||||
@ -73,11 +100,12 @@ export function WhenFormClient({
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Date + time */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="scheduled-date" className="flex items-center gap-1.5">
|
||||
<CalendarIcon className="size-3.5" />
|
||||
Date
|
||||
{kind === "none" ? "Date" : "Starts on"}
|
||||
</Label>
|
||||
<Input
|
||||
id="scheduled-date"
|
||||
@ -108,6 +136,66 @@ export function WhenFormClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-1.5">
|
||||
<RepeatIcon className="size-3.5" />
|
||||
Repeats
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{KINDS.map(({ value, label }) => {
|
||||
const active = kind === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setKind(value)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekday picker — only for weekly */}
|
||||
{kind === "weekly" && (
|
||||
<div className="space-y-2">
|
||||
<Label>Days of the week</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WEEKDAY_LABELS.map(({ iso, short }) => {
|
||||
const active = weeklyDays.includes(iso);
|
||||
return (
|
||||
<button
|
||||
key={iso}
|
||||
type="button"
|
||||
onClick={() => toggleWeekday(iso)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-muted/50 hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{short}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the start date's weekday only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||
|
||||
@ -5,7 +5,7 @@ import { useEffect } from "react";
|
||||
export type WebEventMap = {
|
||||
hello: { ts: number };
|
||||
ping: { ts: number };
|
||||
"session.qr": { accountId: string; qrPng: string };
|
||||
"session.qr": { accountId: string; ts: number };
|
||||
"session.connected": { accountId: string; phoneNumber: string | null };
|
||||
"session.disconnected": { accountId: string };
|
||||
"session.timeout": { accountId: string };
|
||||
|
||||
67
apps/web/src/lib/recurrence.test.ts
Normal file
67
apps/web/src/lib/recurrence.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
buildRrule,
|
||||
describeRecurrence,
|
||||
kindFromRrule,
|
||||
} from "./recurrence";
|
||||
|
||||
const FIRST = DateTime.fromISO("2026-05-13T09:00:00", { zone: "Asia/Kuala_Lumpur" });
|
||||
|
||||
describe("buildRrule", () => {
|
||||
it("returns null for one-off", () => {
|
||||
expect(buildRrule("none", FIRST, [])).toBe(null);
|
||||
});
|
||||
|
||||
it("daily → FREQ=DAILY", () => {
|
||||
expect(buildRrule("daily", FIRST, [])).toBe("FREQ=DAILY");
|
||||
});
|
||||
|
||||
it("weekly with explicit days uses BYDAY in MO,TU,WE,TH,FR,SA,SU order", () => {
|
||||
// Pass days in mixed order — should be sorted by ISO weekday number
|
||||
expect(buildRrule("weekly", FIRST, [3, 1, 5])).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR");
|
||||
});
|
||||
|
||||
it("weekly with no days falls back to first-fire weekday", () => {
|
||||
// 2026-05-13 is a Wednesday in luxon ISO weekday → 3
|
||||
expect(buildRrule("weekly", FIRST, [])).toBe("FREQ=WEEKLY;BYDAY=WE");
|
||||
});
|
||||
|
||||
it("monthly uses BYMONTHDAY of the first-fire date", () => {
|
||||
expect(buildRrule("monthly", FIRST, [])).toBe("FREQ=MONTHLY;BYMONTHDAY=13");
|
||||
});
|
||||
|
||||
it("yearly uses BYMONTH and BYMONTHDAY", () => {
|
||||
expect(buildRrule("yearly", FIRST, [])).toBe("FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kindFromRrule", () => {
|
||||
it("recognises every supported FREQ", () => {
|
||||
expect(kindFromRrule(null)).toBe("none");
|
||||
expect(kindFromRrule(undefined)).toBe("none");
|
||||
expect(kindFromRrule("FREQ=DAILY")).toBe("daily");
|
||||
expect(kindFromRrule("FREQ=WEEKLY;BYDAY=MO,WE,FR")).toBe("weekly");
|
||||
expect(kindFromRrule("FREQ=MONTHLY;BYMONTHDAY=13")).toBe("monthly");
|
||||
expect(kindFromRrule("FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13")).toBe("yearly");
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(kindFromRrule("freq=daily")).toBe("daily");
|
||||
});
|
||||
|
||||
it("returns 'none' for an unrecognised rule", () => {
|
||||
expect(kindFromRrule("FREQ=HOURLY")).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeRecurrence", () => {
|
||||
it("renders human-readable summaries", () => {
|
||||
expect(describeRecurrence("none", FIRST, [])).toBe("One-off");
|
||||
expect(describeRecurrence("daily", FIRST, [])).toBe("Every day");
|
||||
expect(describeRecurrence("weekly", FIRST, [1, 3, 5])).toBe("Every Mon, Wed, Fri");
|
||||
expect(describeRecurrence("weekly", FIRST, [])).toBe("Every Wed");
|
||||
expect(describeRecurrence("monthly", FIRST, [])).toBe("Every month on day 13");
|
||||
expect(describeRecurrence("yearly", FIRST, [])).toBe("Every year on May 13");
|
||||
});
|
||||
});
|
||||
91
apps/web/src/lib/recurrence.ts
Normal file
91
apps/web/src/lib/recurrence.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
||||
|
||||
/** ISO weekday → RRULE day code. Luxon weekday: 1=Mon ... 7=Sun. */
|
||||
const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const;
|
||||
|
||||
export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; long: string }> = [
|
||||
{ iso: 1, code: "MO", short: "Mon", long: "Monday" },
|
||||
{ iso: 2, code: "TU", short: "Tue", long: "Tuesday" },
|
||||
{ iso: 3, code: "WE", short: "Wed", long: "Wednesday" },
|
||||
{ iso: 4, code: "TH", short: "Thu", long: "Thursday" },
|
||||
{ iso: 5, code: "FR", short: "Fri", long: "Friday" },
|
||||
{ iso: 6, code: "SA", short: "Sat", long: "Saturday" },
|
||||
{ iso: 7, code: "SU", short: "Sun", long: "Sunday" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Build an RRULE for the given recurrence pattern. Returns null for "none"
|
||||
* (one-off reminders don't carry an RRULE).
|
||||
*
|
||||
* For weekly with no weekdays selected, falls back to the weekday of
|
||||
* `firstFire` so the rule is always concrete.
|
||||
*/
|
||||
export function buildRrule(
|
||||
kind: RecurrenceKind,
|
||||
firstFire: DateTime,
|
||||
weeklyDays: number[],
|
||||
): string | null {
|
||||
switch (kind) {
|
||||
case "none":
|
||||
return null;
|
||||
case "daily":
|
||||
return "FREQ=DAILY";
|
||||
case "weekly": {
|
||||
const days =
|
||||
weeklyDays.length > 0
|
||||
? weeklyDays
|
||||
: [firstFire.weekday];
|
||||
const codes = days
|
||||
.slice()
|
||||
.sort((a, b) => a - b)
|
||||
.map((d) => WEEKDAY_CODES[d - 1])
|
||||
.filter(Boolean);
|
||||
return `FREQ=WEEKLY;BYDAY=${codes.join(",")}`;
|
||||
}
|
||||
case "monthly":
|
||||
return `FREQ=MONTHLY;BYMONTHDAY=${firstFire.day}`;
|
||||
case "yearly":
|
||||
return `FREQ=YEARLY;BYMONTH=${firstFire.month};BYMONTHDAY=${firstFire.day}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable summary, e.g. "Every Mon, Wed" or "Every month on the 14th". */
|
||||
export function describeRecurrence(
|
||||
kind: RecurrenceKind,
|
||||
firstFire: DateTime,
|
||||
weeklyDays: number[],
|
||||
): string {
|
||||
switch (kind) {
|
||||
case "none":
|
||||
return "One-off";
|
||||
case "daily":
|
||||
return "Every day";
|
||||
case "weekly": {
|
||||
const days = weeklyDays.length > 0 ? weeklyDays : [firstFire.weekday];
|
||||
const labels = days
|
||||
.slice()
|
||||
.sort((a, b) => a - b)
|
||||
.map((d) => WEEKDAY_LABELS[d - 1]?.short)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return `Every ${labels}`;
|
||||
}
|
||||
case "monthly":
|
||||
return `Every month on day ${firstFire.day}`;
|
||||
case "yearly":
|
||||
return `Every year on ${firstFire.toFormat("MMM d")}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the kind back from an RRULE string (best-effort, for review display). */
|
||||
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind {
|
||||
if (!rrule) return "none";
|
||||
const upper = rrule.toUpperCase();
|
||||
if (upper.includes("FREQ=DAILY")) return "daily";
|
||||
if (upper.includes("FREQ=WEEKLY")) return "weekly";
|
||||
if (upper.includes("FREQ=MONTHLY")) return "monthly";
|
||||
if (upper.includes("FREQ=YEARLY")) return "yearly";
|
||||
return "none";
|
||||
}
|
||||
@ -3,9 +3,13 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
export function middleware(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname;
|
||||
|
||||
// Block all /api/* except the read-only SSE and health endpoints.
|
||||
// Block all /api/* except a small set of read-only endpoints.
|
||||
// Mutations happen via Server Actions which post to page URLs, not /api/*.
|
||||
if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health") {
|
||||
const allowed =
|
||||
path === "/api/events" ||
|
||||
path === "/api/health" ||
|
||||
path.startsWith("/api/qr/");
|
||||
if (path.startsWith("/api/") && !allowed) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
2
packages/db/migrations/0004_next_prowler.sql
Normal file
2
packages/db/migrations/0004_next_prowler.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "reminders" ADD COLUMN "last_fired_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "whatsapp_accounts" ADD COLUMN "last_qr_png" text;
|
||||
1013
packages/db/migrations/meta/0004_snapshot.json
Normal file
1013
packages/db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -29,6 +29,13 @@
|
||||
"when": 1778343712901,
|
||||
"tag": "0003_messy_bruce_banner",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1778345543406,
|
||||
"tag": "0004_next_prowler",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -37,6 +37,7 @@ export const whatsappAccounts = pgTable(
|
||||
status: text("status").notNull().default("pending"),
|
||||
lastConnectedAt: timestamp("last_connected_at", { withTimezone: true }),
|
||||
lastQrAt: timestamp("last_qr_at", { withTimezone: true }),
|
||||
lastQrPng: text("last_qr_png"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
@ -85,6 +86,7 @@ export const reminders = pgTable("reminders", {
|
||||
createdBy: uuid("created_by").notNull().references(() => operators.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
lastFiredAt: timestamp("last_fired_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const reminderTargets = pgTable(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user