feat(web): reminders list + detail pages with run history
This commit is contained in:
parent
6b1a9191ab
commit
8fd5468e3a
63
apps/web/src/app/reminders/[id]/delete-dialog.tsx
Normal file
63
apps/web/src/app/reminders/[id]/delete-dialog.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Trash2Icon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface DeleteDialogProps {
|
||||||
|
deleteAction: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteDialog({ deleteAction }: DeleteDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await deleteAction();
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
<Trash2Icon />
|
||||||
|
Delete Reminder
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this reminder?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This action cannot be undone. The reminder and all its run history
|
||||||
|
will be permanently deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{pending ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
apps/web/src/app/reminders/[id]/page.tsx
Normal file
249
apps/web/src/app/reminders/[id]/page.tsx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
SmartphoneIcon,
|
||||||
|
UsersIcon,
|
||||||
|
ClockIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { getReminderWithRuns } from "@/lib/queries";
|
||||||
|
import { DeleteDialog } from "./delete-dialog";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function formatWhen(date: Date | null, tz: string): string {
|
||||||
|
if (!date) return "—";
|
||||||
|
return new Intl.DateTimeFormat("en-MY", {
|
||||||
|
timeZone: tz,
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status pill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
active:
|
||||||
|
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||||
|
ended:
|
||||||
|
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
|
||||||
|
paused:
|
||||||
|
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
||||||
|
failed:
|
||||||
|
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
|
||||||
|
success:
|
||||||
|
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const cls =
|
||||||
|
STATUS_STYLES[status] ??
|
||||||
|
"bg-secondary text-secondary-foreground border-transparent";
|
||||||
|
const label = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className={cls}>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server action (no-op placeholder — real delete wired in Task 19)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function _deleteReminderStub() {
|
||||||
|
"use server";
|
||||||
|
// wired in Task 19
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ReminderDetailPage({ params }: Props) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const data = await getReminderWithRuns(op.id, id);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { reminder, account, targets, messages, runs } = data;
|
||||||
|
const tz = op.defaultTimezone ?? "UTC";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-3xl 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={"/reminders" as any}>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
Back to Reminders
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight leading-tight flex-1 min-w-0">
|
||||||
|
{reminder.name}
|
||||||
|
</h1>
|
||||||
|
<StatusPill status={reminder.status} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<CalendarIcon className="size-3.5 shrink-0" />
|
||||||
|
<span>When: {formatWhen(reminder.scheduledAt, tz)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<SmartphoneIcon className="size-3.5 shrink-0" />
|
||||||
|
<span>Account: {account.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Message body */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-medium tracking-tight flex items-center gap-2">
|
||||||
|
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||||
|
Message
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4 space-y-3">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No message parts defined.</p>
|
||||||
|
) : (
|
||||||
|
messages.map((msg, i) => (
|
||||||
|
<div key={msg.id}>
|
||||||
|
{i > 0 && <Separator className="my-3" />}
|
||||||
|
{msg.kind === "text" && msg.textContent ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{msg.textContent}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-mono text-muted-foreground">
|
||||||
|
[{msg.kind}]{msg.textContent ? ` ${msg.textContent}` : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 italic">
|
||||||
|
Media preview coming soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Target groups */}
|
||||||
|
{targets.length > 0 && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-medium tracking-tight flex items-center gap-2">
|
||||||
|
<UsersIcon className="size-4 text-muted-foreground" />
|
||||||
|
Groups
|
||||||
|
<Badge variant="outline" className="ml-1">
|
||||||
|
{targets.length}
|
||||||
|
</Badge>
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{targets.map((t) => (
|
||||||
|
<Badge key={t.groupId} variant="secondary">
|
||||||
|
{t.groupName}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Run history */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-base font-medium tracking-tight flex items-center gap-2">
|
||||||
|
<ClockIcon className="size-4 text-muted-foreground" />
|
||||||
|
Run history
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{runs.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-10 text-center">
|
||||||
|
<ClockIcon className="size-8 text-muted-foreground/40" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">Has not fired yet.</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Runs will appear here once the reminder fires.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">Error</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{runs.map((run) => (
|
||||||
|
<TableRow key={run.id}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatWhen(run.firedAt, tz)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusPill status={run.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell text-xs text-muted-foreground max-w-xs truncate">
|
||||||
|
{run.errorSummary ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Action footer */}
|
||||||
|
<div className="flex items-center justify-end pt-2 border-t">
|
||||||
|
<DeleteDialog deleteAction={_deleteReminderStub} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
apps/web/src/app/reminders/page.tsx
Normal file
184
apps/web/src/app/reminders/page.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { PlusIcon, BellIcon, CalendarIcon, UsersIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
|
import { listReminders } from "@/lib/queries";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type FilterValue = "all" | "active" | "ended" | "failed";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function formatWhen(date: Date | null, tz: string): string {
|
||||||
|
if (!date) return "—";
|
||||||
|
return new Intl.DateTimeFormat("en-MY", {
|
||||||
|
timeZone: tz,
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status pill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
active:
|
||||||
|
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
||||||
|
ended:
|
||||||
|
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
|
||||||
|
paused:
|
||||||
|
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
||||||
|
failed:
|
||||||
|
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const cls =
|
||||||
|
STATUS_STYLES[status] ??
|
||||||
|
"bg-secondary text-secondary-foreground border-transparent";
|
||||||
|
const label = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className={cls}>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filter tabs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const FILTER_TABS: { value: FilterValue; label: string }[] = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "ended", label: "Ended" },
|
||||||
|
{ value: "failed", label: "Failed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ filter?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RemindersPage({ searchParams }: PageProps) {
|
||||||
|
const { filter: rawFilter } = await searchParams;
|
||||||
|
const filter: FilterValue =
|
||||||
|
rawFilter === "active" || rawFilter === "ended" || rawFilter === "failed"
|
||||||
|
? rawFilter
|
||||||
|
: "all";
|
||||||
|
|
||||||
|
const op = await getSeededOperator();
|
||||||
|
const allReminders = await listReminders(op.id);
|
||||||
|
const tz = op.defaultTimezone ?? "UTC";
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
filter === "all"
|
||||||
|
? allReminders
|
||||||
|
: allReminders.filter((r) => r.status === filter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Reminders</h1>
|
||||||
|
<Button asChild size="sm">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={"/reminders/new" as any}>
|
||||||
|
<PlusIcon />
|
||||||
|
New Reminder
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs — URL-driven, no client state */}
|
||||||
|
<Tabs value={filter}>
|
||||||
|
<TabsList>
|
||||||
|
{FILTER_TABS.map(({ value, label }) => (
|
||||||
|
<TabsTrigger key={value} value={value} asChild>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={(value === "all" ? "/reminders" : `/reminders?filter=${value}`) as any}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Reminder list */}
|
||||||
|
{filtered.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{filtered.map((reminder) => (
|
||||||
|
<Link
|
||||||
|
key={reminder.id}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/reminders/${reminder.id}` as any}
|
||||||
|
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Card className="transition-shadow hover:shadow-md hover:ring-foreground/20">
|
||||||
|
<CardContent className="flex items-center gap-3 py-3 px-4">
|
||||||
|
{/* Status + name */}
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<StatusPill status={reminder.status} />
|
||||||
|
<span className="text-sm font-medium leading-none truncate">
|
||||||
|
{reminder.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{reminder.accountLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* When + group count */}
|
||||||
|
<div className="shrink-0 text-right space-y-1">
|
||||||
|
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||||
|
<CalendarIcon className="size-3 shrink-0" />
|
||||||
|
<span>{formatWhen(reminder.scheduledAt, tz)}</span>
|
||||||
|
</div>
|
||||||
|
{reminder.groupCount > 0 && (
|
||||||
|
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||||
|
<UsersIcon className="size-3 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{reminder.groupCount}{" "}
|
||||||
|
{reminder.groupCount === 1 ? "group" : "groups"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||||
|
<BellIcon className="size-10 text-muted-foreground/40" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">No reminders yet.</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Create a reminder to start sending scheduled WhatsApp messages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild size="sm">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Link href={"/reminders/new" as any}>
|
||||||
|
<PlusIcon />
|
||||||
|
New Reminder
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -84,3 +84,73 @@ export async function getGroup(operatorId: string, groupId: string) {
|
|||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
return { group, account };
|
return { group, account };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listReminders(operatorId: string) {
|
||||||
|
const rows = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
r.id, r.name, r.schedule_kind, r.scheduled_at, r.timezone, r.status,
|
||||||
|
r.created_at, wa.label as account_label,
|
||||||
|
(SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count
|
||||||
|
FROM reminders r
|
||||||
|
JOIN whatsapp_accounts wa ON wa.id = r.account_id
|
||||||
|
WHERE wa.operator_id = ${operatorId}
|
||||||
|
ORDER BY r.scheduled_at DESC NULLS LAST, r.created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`);
|
||||||
|
return (rows.rows as Array<Record<string, unknown>>).map((r) => ({
|
||||||
|
id: r.id as string,
|
||||||
|
name: r.name as string,
|
||||||
|
scheduleKind: r.schedule_kind as string,
|
||||||
|
scheduledAt: r.scheduled_at as Date | null,
|
||||||
|
timezone: r.timezone as string,
|
||||||
|
status: r.status as string,
|
||||||
|
createdAt: r.created_at as Date,
|
||||||
|
accountLabel: r.account_label as string,
|
||||||
|
groupCount: Number(r.group_count),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReminderWithRuns(operatorId: string, reminderId: string) {
|
||||||
|
const reminder = await db.query.reminders.findFirst({
|
||||||
|
where: (r, { eq }) => eq(r.id, reminderId),
|
||||||
|
});
|
||||||
|
if (!reminder) return null;
|
||||||
|
// Verify operator owns the reminder via the account
|
||||||
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
|
where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, operatorId)),
|
||||||
|
});
|
||||||
|
if (!account) return null;
|
||||||
|
const targets = await db.execute(sql`
|
||||||
|
SELECT rt.group_id, wg.name as group_name
|
||||||
|
FROM reminder_targets rt
|
||||||
|
JOIN whatsapp_groups wg ON wg.id = rt.group_id
|
||||||
|
WHERE rt.reminder_id = ${reminderId}
|
||||||
|
ORDER BY rt.position
|
||||||
|
`);
|
||||||
|
const messages = await db.query.reminderMessages.findMany({
|
||||||
|
where: (m, { eq }) => eq(m.reminderId, reminderId),
|
||||||
|
orderBy: (m, { asc }) => [asc(m.position)],
|
||||||
|
});
|
||||||
|
const runs = await db.execute(sql`
|
||||||
|
SELECT id, fired_at, status, error_summary
|
||||||
|
FROM reminder_runs
|
||||||
|
WHERE reminder_id = ${reminderId}
|
||||||
|
ORDER BY fired_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
return {
|
||||||
|
reminder,
|
||||||
|
account,
|
||||||
|
targets: (targets.rows as Array<Record<string, unknown>>).map((r) => ({
|
||||||
|
groupId: r.group_id as string,
|
||||||
|
groupName: r.group_name as string,
|
||||||
|
})),
|
||||||
|
messages,
|
||||||
|
runs: (runs.rows as Array<Record<string, unknown>>).map((r) => ({
|
||||||
|
id: r.id as string,
|
||||||
|
firedAt: r.fired_at as Date,
|
||||||
|
status: r.status as string,
|
||||||
|
errorSummary: r.error_summary as string | null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user