diff --git a/apps/bot/src/ipc/notify.ts b/apps/bot/src/ipc/notify.ts index 75ae679..d2c6089 100644 --- a/apps/bot/src/ipc/notify.ts +++ b/apps/bot/src/ipc/notify.ts @@ -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 } diff --git a/apps/bot/src/ipc/pair-handler.ts b/apps/bot/src/ipc/pair-handler.ts index 50ca755..2add1fa 100644 --- a/apps/bot/src/ipc/pair-handler.ts +++ b/apps/bot/src/ipc/pair-handler.ts @@ -51,6 +51,19 @@ export async function handleStartPairing(accountId: string): Promise { 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 { 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); diff --git a/apps/bot/src/scheduler/fire-reminder.ts b/apps/bot/src/scheduler/fire-reminder.ts index a78279a..b7e4db1 100644 --- a/apps/bot/src/scheduler/fire-reminder.ts +++ b/apps/bot/src/scheduler/fire-reminder.ts @@ -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 .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, { diff --git a/apps/web/src/actions/reminders.schema.test.ts b/apps/web/src/actions/reminders.schema.test.ts new file mode 100644 index 0000000..e344933 --- /dev/null +++ b/apps/web/src/actions/reminders.schema.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/actions/reminders.ts b/apps/web/src/actions/reminders.ts index 1cd852c..ee6a661 100644 --- a/apps/web/src/actions/reminders.ts +++ b/apps/web/src/actions/reminders.ts @@ -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, diff --git a/apps/web/src/app/accounts/[id]/page.tsx b/apps/web/src/app/accounts/[id]/page.tsx index cdca5c9..963b293 100644 --- a/apps/web/src/app/accounts/[id]/page.tsx +++ b/apps/web/src/app/accounts/[id]/page.tsx @@ -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 (
- {/* Back link */} - {/* Header */}

{account.label}

@@ -75,43 +73,43 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro )}
- {/* Actions */}
- {/* Pair / Re-pair — visible when not currently connected */} + {/* Pair / Re-pair — entire card is the submit button */} {account.status !== "connected" && ( - - -
-
- -
-
-

- {account.status === "unpaired" ? "Pair WhatsApp" : "Re-pair WhatsApp"} -

-

- Show a QR code so this account can connect to WhatsApp -

-
-
-
- - -
-
-
+
+ + +
)} - {/* Groups + Sync — visible when connected */} {account.status === "connected" && ( <> @@ -124,101 +122,106 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro

View synced WhatsApp groups

+ - - -
-
- -
-
-

Unpair

-

- Disconnect from WhatsApp; keep the account so you can re-pair later -

-
-
- - - - - - - Unpair this account? - - {account.label} 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. - - - -
- - -
-
-
-
-
-
- - )} - - {/* Delete — always available */} - - -
-
- -
-
-

Delete Account

-

- Remove the account and all its reminders, groups, and history -

-
-
+ {/* Unpair — entire card opens the confirm dialog */} - + - Delete this account permanently? + Unpair this account? - {account.label} will be removed along with its - synced groups, scheduled reminders, and all run history. This cannot be - undone. + {account.label} 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. -
+ -
-
-
+ + )} + + {/* Delete — entire card opens the confirm dialog */} + + + + + + + Delete this account permanently? + + {account.label} will be removed along with its + synced groups, scheduled reminders, and all run history. This cannot be + undone. + + + +
+ + +
+
+
+
- {/* Detail grid */} diff --git a/apps/web/src/app/accounts/page.tsx b/apps/web/src/app/accounts/page.tsx index dd9ef76..6ebf3ab 100644 --- a/apps/web/src/app/accounts/page.tsx +++ b/apps/web/src/app/accounts/page.tsx @@ -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 ? (
{accounts.map((account) => ( - - - -
- {account.label} - -
-
- - {account.phoneNumber ? ( -
- - {account.phoneNumber} +
+ + + +
+ + {account.label} + +
- ) : ( -

Not paired yet

- )} - {account.lastConnectedAt ? ( -
- - - Last connected{" "} - {account.lastConnectedAt.toLocaleDateString("en-MY", { - timeZone: "Asia/Kuala_Lumpur", - year: "numeric", - month: "short", - day: "numeric", - })} - -
- ) : null} - -
- + + + {account.phoneNumber ? ( +
+ + {account.phoneNumber} +
+ ) : ( +

Not paired yet

+ )} + {account.lastConnectedAt ? ( +
+ + + Last connected{" "} + {account.lastConnectedAt.toLocaleDateString("en-MY", { + timeZone: "Asia/Kuala_Lumpur", + year: "numeric", + month: "short", + day: "numeric", + })} + +
+ ) : null} +
+ + + + {/* Floating delete trigger — sits over the card without + blocking the link target on the rest of the surface. */} + + + + + + + Delete this account? + + {account.label} and all its reminders, groups, + and history will be permanently removed. This cannot be undone. + + + +
+ + +
+
+
+
+
))}
) : ( diff --git a/apps/web/src/app/api/qr/[accountId]/route.ts b/apps/web/src/app/api/qr/[accountId]/route.ts new file mode 100644 index 0000000..074099d --- /dev/null +++ b/apps/web/src/app/api/qr/[accountId]/route.ts @@ -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 { + 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", + }, + }); +} diff --git a/apps/web/src/components/pair-live.tsx b/apps/web/src/components/pair-live.tsx index 00a75bf..31e177c 100644 --- a/apps/web/src/components/pair-live.tsx +++ b/apps/web/src/components/pair-live.tsx @@ -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 ( - - {/* Track */} - - {/* Remaining */} - - +
+
+ QR expires in + + {seconds}s + +
+
+
+
+
); } @@ -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" && (
- {/* QR + ring overlay */} -
- {/* Countdown ring positioned around the QR */} -
-
- -
- - {countdown}s - -
-
-
+ {/* Countdown — separate from the QR so it doesn't obstruct scanning */} + - {/* QR image */} - WhatsApp QR code -
+ {/* QR image */} + {/* eslint-disable-next-line @next/next/no-img-element */} + WhatsApp QR code -
+

Scan with WhatsApp → Linked Devices

Open WhatsApp → tap ⋮ → Linked Devices → Link a device

+
)} diff --git a/apps/web/src/components/reminder-wizard/groups-form-client.tsx b/apps/web/src/components/reminder-wizard/groups-form-client.tsx index c743c15..a065e99 100644 --- a/apps/web/src/components/reminder-wizard/groups-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/groups-form-client.tsx @@ -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); } diff --git a/apps/web/src/components/reminder-wizard/review-submit-client.tsx b/apps/web/src/components/reminder-wizard/review-submit-client.tsx index 28a25de..8cebb2b 100644 --- a/apps/web/src/components/reminder-wizard/review-submit-client.tsx +++ b/apps/web/src/components/reminder-wizard/review-submit-client.tsx @@ -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, }); diff --git a/apps/web/src/components/reminder-wizard/step-groups.tsx b/apps/web/src/components/reminder-wizard/step-groups.tsx index a458636..cf88506 100644 --- a/apps/web/src/components/reminder-wizard/step-groups.tsx +++ b/apps/web/src/components/reminder-wizard/step-groups.tsx @@ -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, }} />
@@ -106,6 +117,7 @@ interface PassThroughParams { mediaId?: string; caption?: string; scheduledAt?: string; + rrule?: string; } function StepGroupsForm({ diff --git a/apps/web/src/components/reminder-wizard/step-review.tsx b/apps/web/src/components/reminder-wizard/step-review.tsx index 994a289..9f755ae 100644 --- a/apps/web/src/components/reminder-wizard/step-review.tsx +++ b/apps/web/src/components/reminder-wizard/step-review.tsx @@ -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 = { + 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 (
@@ -102,7 +127,7 @@ export async function StepReview({ params }: StepReviewProps) { } label="Account" - editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt)} + editHref={editLink(1, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} > {account.label} {account.phoneNumber && ( @@ -114,7 +139,7 @@ export async function StepReview({ params }: StepReviewProps) { } label="Message" - editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt)} + editHref={editLink(2, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} > {mediaId ? ( @@ -136,17 +161,34 @@ export async function StepReview({ params }: StepReviewProps) { {/* When */} } - 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)} > {formattedDate} + {/* Recurrence (only if set) */} + {rrule && ( + } + label="Repeats" + editHref={editLink(3, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} + > + + {describeRecurrence( + kindFromRrule(rrule), + DateTime.fromISO(scheduledAt!, { zone: timezone }), + parseWeeklyDaysFromRrule(rrule), + )} + + + )} + {/* Groups */} } label="Groups" - editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt)} + editHref={editLink(4, accountId, groupIds, text, mediaId, caption, scheduledAt, rrule)} > {selectedGroups.length > 0 ? (
@@ -174,6 +216,7 @@ export async function StepReview({ params }: StepReviewProps) { mediaId={mediaId} caption={caption} scheduledAt={scheduledAt} + rrule={rrule} timezone={timezone} />
diff --git a/apps/web/src/components/reminder-wizard/step-when.tsx b/apps/web/src/components/reminder-wizard/step-when.tsx index 9c3c0f2..6fdee9f 100644 --- a/apps/web/src/components/reminder-wizard/step-when.tsx +++ b/apps/web/src/components/reminder-wizard/step-when.tsx @@ -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 = { + 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 }} />
diff --git a/apps/web/src/components/reminder-wizard/when-form-client.tsx b/apps/web/src/components/reminder-wizard/when-form-client.tsx index 450fa43..63f4387 100644 --- a/apps/web/src/components/reminder-wizard/when-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/when-form-client.tsx @@ -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(initialKind); + const [weeklyDays, setWeeklyDays] = useState(initialWeeklyDays); const [error, setError] = useState(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 (
+ {/* Date + time */}
+ {/* Recurrence */} +
+ +
+ {KINDS.map(({ value, label }) => { + const active = kind === value; + return ( + + ); + })} +
+
+ + {/* Weekday picker — only for weekly */} + {kind === "weekly" && ( +
+ +
+ {WEEKDAY_LABELS.map(({ iso, short }) => { + const active = weeklyDays.includes(iso); + return ( + + ); + })} +
+

+ Leave empty to use the start date's weekday only. +

+
+ )} + {error && (
diff --git a/apps/web/src/hooks/use-events.ts b/apps/web/src/hooks/use-events.ts index 675fe5b..c863dff 100644 --- a/apps/web/src/hooks/use-events.ts +++ b/apps/web/src/hooks/use-events.ts @@ -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 }; diff --git a/apps/web/src/lib/recurrence.test.ts b/apps/web/src/lib/recurrence.test.ts new file mode 100644 index 0000000..a327e11 --- /dev/null +++ b/apps/web/src/lib/recurrence.test.ts @@ -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"); + }); +}); diff --git a/apps/web/src/lib/recurrence.ts b/apps/web/src/lib/recurrence.ts new file mode 100644 index 0000000..dadf2a2 --- /dev/null +++ b/apps/web/src/lib/recurrence.ts @@ -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"; +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 9360754..b30af82 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -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 }); } diff --git a/packages/db/migrations/0004_next_prowler.sql b/packages/db/migrations/0004_next_prowler.sql new file mode 100644 index 0000000..561e1de --- /dev/null +++ b/packages/db/migrations/0004_next_prowler.sql @@ -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; \ No newline at end of file diff --git a/packages/db/migrations/meta/0004_snapshot.json b/packages/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..f470e56 --- /dev/null +++ b/packages/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,1013 @@ +{ + "id": "634bcce7-311b-4492-86ec-48afa1ee4d3a", + "prevId": "ca99f65e-00f1-44e6-bb97-61d578a1acea", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "operator_id": { + "name": "operator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_operator_id_operators_id_fk": { + "name": "audit_log_operator_id_operators_id_fk", + "tableFrom": "audit_log", + "tableTo": "operators", + "columnsFrom": [ + "operator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "operator_id": { + "name": "operator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_operator_id_operators_id_fk": { + "name": "auth_sessions_operator_id_operators_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "operators", + "columnsFrom": [ + "operator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_sessions_token_hash_unique": { + "name": "auth_sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cache_entries": { + "name": "cache_entries", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_files": { + "name": "media_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "operator_id": { + "name": "operator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename_original": { + "name": "filename_original", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "media_files_operator_id_operators_id_fk": { + "name": "media_files_operator_id_operators_id_fk", + "tableFrom": "media_files", + "tableTo": "operators", + "columnsFrom": [ + "operator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operators": { + "name": "operators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "default_timezone": { + "name": "default_timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Asia/Kuala_Lumpur'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "operators_telegram_user_id_uq": { + "name": "operators_telegram_user_id_uq", + "columns": [ + { + "expression": "telegram_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_buckets": { + "name": "rate_limit_buckets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_messages": { + "name": "reminder_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reminder_id": { + "name": "reminder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_messages_reminder_id_reminders_id_fk": { + "name": "reminder_messages_reminder_id_reminders_id_fk", + "tableFrom": "reminder_messages", + "tableTo": "reminders", + "columnsFrom": [ + "reminder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reminder_messages_media_id_media_files_id_fk": { + "name": "reminder_messages_media_id_media_files_id_fk", + "tableFrom": "reminder_messages", + "tableTo": "media_files", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_run_targets": { + "name": "reminder_run_targets", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wa_message_id": { + "name": "wa_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_run_targets_run_id_reminder_runs_id_fk": { + "name": "reminder_run_targets_run_id_reminder_runs_id_fk", + "tableFrom": "reminder_run_targets", + "tableTo": "reminder_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reminder_run_targets_group_id_whatsapp_groups_id_fk": { + "name": "reminder_run_targets_group_id_whatsapp_groups_id_fk", + "tableFrom": "reminder_run_targets", + "tableTo": "whatsapp_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reminder_run_targets_run_id_group_id_pk": { + "name": "reminder_run_targets_run_id_group_id_pk", + "columns": [ + "run_id", + "group_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_runs": { + "name": "reminder_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reminder_id": { + "name": "reminder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_summary": { + "name": "error_summary", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_runs_reminder_id_reminders_id_fk": { + "name": "reminder_runs_reminder_id_reminders_id_fk", + "tableFrom": "reminder_runs", + "tableTo": "reminders", + "columnsFrom": [ + "reminder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_targets": { + "name": "reminder_targets", + "schema": "", + "columns": { + "reminder_id": { + "name": "reminder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_targets_reminder_id_reminders_id_fk": { + "name": "reminder_targets_reminder_id_reminders_id_fk", + "tableFrom": "reminder_targets", + "tableTo": "reminders", + "columnsFrom": [ + "reminder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reminder_targets_group_id_whatsapp_groups_id_fk": { + "name": "reminder_targets_group_id_whatsapp_groups_id_fk", + "tableFrom": "reminder_targets", + "tableTo": "whatsapp_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reminder_targets_reminder_id_group_id_pk": { + "name": "reminder_targets_reminder_id_group_id_pk", + "columns": [ + "reminder_id", + "group_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminders": { + "name": "reminders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_kind": { + "name": "schedule_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rrule": { + "name": "rrule", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reminders_account_id_whatsapp_accounts_id_fk": { + "name": "reminders_account_id_whatsapp_accounts_id_fk", + "tableFrom": "reminders", + "tableTo": "whatsapp_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reminders_created_by_operators_id_fk": { + "name": "reminders_created_by_operators_id_fk", + "tableFrom": "reminders", + "tableTo": "operators", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.whatsapp_accounts": { + "name": "whatsapp_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "operator_id": { + "name": "operator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_qr_at": { + "name": "last_qr_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_qr_png": { + "name": "last_qr_png", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "whatsapp_accounts_operator_label_uq": { + "name": "whatsapp_accounts_operator_label_uq", + "columns": [ + { + "expression": "operator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "label", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "whatsapp_accounts_operator_id_operators_id_fk": { + "name": "whatsapp_accounts_operator_id_operators_id_fk", + "tableFrom": "whatsapp_accounts", + "tableTo": "operators", + "columnsFrom": [ + "operator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.whatsapp_groups": { + "name": "whatsapp_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "wa_group_jid": { + "name": "wa_group_jid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participant_count": { + "name": "participant_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "whatsapp_groups_account_jid_uq": { + "name": "whatsapp_groups_account_jid_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wa_group_jid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "whatsapp_groups_account_id_whatsapp_accounts_id_fk": { + "name": "whatsapp_groups_account_id_whatsapp_accounts_id_fk", + "tableFrom": "whatsapp_groups", + "tableTo": "whatsapp_accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index c220ec5..f69b568 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 59b6280..3245d19 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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(