diff --git a/admin/vite.config.ts b/admin/vite.config.ts index 829f8c9e8..6639a7c28 100644 --- a/admin/vite.config.ts +++ b/admin/vite.config.ts @@ -1,9 +1,12 @@ -import { defineConfig } from "vite"; +if (!process.env.VITE_SENTRY_RELEASE && process.env.CF_PAGES_COMMIT_SHA) process.env.VITE_SENTRY_RELEASE = process.env.CF_PAGES_COMMIT_SHA;import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import path from "path"; import tailwindcss from "@tailwindcss/vite"; import { sentryVitePlugin } from "@sentry/vite-plugin"; +// A Cloudflare Pages build sets no release, so its events are tagged with the commit it built. +if (!process.env.VITE_SENTRY_RELEASE && process.env.CF_PAGES_COMMIT_SHA) process.env.VITE_SENTRY_RELEASE = process.env.CF_PAGES_COMMIT_SHA; + // Source maps, and nothing else, is what this section decides. // // Uploading them is never a required build step: a fork, a self-host build or a diff --git a/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx b/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx index f72b8c86f..afae01c6c 100644 --- a/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx +++ b/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx @@ -545,7 +545,7 @@ function Legend({ color, label }: { color: string; label: string }) { ); } -function fmtRelative(iso: string): string { +function fmtRelative(iso: string | Date): string { try { const d = new Date(iso); const diff = Date.now() - d.getTime(); @@ -563,7 +563,7 @@ function fmtRelative(iso: string): string { } } -function fmtFull(iso: string): string { +function fmtFull(iso: string | Date): string { try { const d = new Date(iso); return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" }); @@ -574,7 +574,7 @@ function fmtFull(iso: string): string { // Compact HH:MM for the mobile activity log, where the full timestamp // would crowd out the endpoint path. -function fmtTime(iso: string): string { +function fmtTime(iso: string | Date): string { try { const d = new Date(iso); return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); diff --git a/web/src/app/app/api-keys/_components/Sparkline.tsx b/web/src/app/app/api-keys/_components/Sparkline.tsx index 78f162e86..53c46f2fe 100644 --- a/web/src/app/app/api-keys/_components/Sparkline.tsx +++ b/web/src/app/app/api-keys/_components/Sparkline.tsx @@ -59,7 +59,7 @@ export function Sparkline({ } export interface BucketLike { - bucket: string; + bucket: Date; success: number; client_errors: number; server_errors: number; @@ -90,7 +90,7 @@ export function StackedBars({
({ - key: b.bucket, + key: b.bucket.toISOString(), parts: [b.success, b.client_errors, b.server_errors], }))} tones={["emerald", "amber", "rose"]} @@ -107,7 +107,7 @@ export function StackedBars({ ); } -function labelTick(iso: string | undefined): string { +function labelTick(iso: Date | undefined): string { if (!iso) return ""; try { const d = new Date(iso); diff --git a/web/src/app/app/api-keys/page.tsx b/web/src/app/app/api-keys/page.tsx index b07fe13ca..d8a67484c 100644 --- a/web/src/app/app/api-keys/page.tsx +++ b/web/src/app/app/api-keys/page.tsx @@ -332,7 +332,7 @@ function Legend({ color, label }: { color: string; label: string }) { ); } -function fmtRelative(iso: string): string { +function fmtRelative(iso: string | Date): string { try { const d = new Date(iso); const diff = Date.now() - d.getTime(); @@ -350,7 +350,7 @@ function fmtRelative(iso: string): string { } } -function fmtFull(iso: string): string { +function fmtFull(iso: string | Date): string { try { return new Date(iso).toLocaleString("en-US", { month: "short", diff --git a/web/src/app/app/audit/page.tsx b/web/src/app/app/audit/page.tsx index ef5bce7fe..c860ae53e 100644 --- a/web/src/app/app/audit/page.tsx +++ b/web/src/app/app/audit/page.tsx @@ -412,7 +412,7 @@ function actorLabel(log: AuditLog): string { return "System"; } -function fmt(d: string) { +function fmt(d: string | Date) { if (!d) return "—"; try { const dt = new Date(d); diff --git a/web/src/app/app/crm/deals/page.tsx b/web/src/app/app/crm/deals/page.tsx index f5cfba8d9..757cc4e63 100644 --- a/web/src/app/app/crm/deals/page.tsx +++ b/web/src/app/app/crm/deals/page.tsx @@ -56,6 +56,7 @@ import { useLivePatch } from "@/hooks/useLivePatch"; import { cursorColor } from "@/hooks/useLiveCursors"; import ResourceViewers from "@/components/app/presence/ResourceViewers"; import type Deal from "@/lib/api/models/app/crm/Deal"; +import type { DealWrite } from "@/lib/api/models/app/crm/Deal"; import type { Stage } from "@/lib/api/models/app/crm/Pipeline"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -159,7 +160,7 @@ export default function DealsPage() { async function moveDeal(dealId: string, newStageId: string) { try { await toast.promise( - updateDeal.mutateAsync({ id: dealId, data: { stage_id: newStageId } as Partial }), + updateDeal.mutateAsync({ id: dealId, data: { stage_id: newStageId } as DealWrite }), { loading: "Moving…", success: "Moved", error: (e: AppError) => buildError(e) }, ); // Nudge teammates on the same board to update now (the audit refetch is @@ -694,7 +695,7 @@ function DealDialog({ toast.error("Pick a stage"); return; } - const data: Partial = { + const data: DealWrite = { pipeline_id: pipelineId, stage_id: stageId, name: name.trim(), @@ -949,7 +950,7 @@ function formatMoney(n: number | undefined, currency = "USD") { } } -function fmtDate(d: string | undefined) { +function fmtDate(d: string | Date | undefined) { if (!d) return "—"; try { return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric" }); diff --git a/web/src/app/app/crm/meetings/page.tsx b/web/src/app/app/crm/meetings/page.tsx index cba0d48f1..7480352c2 100644 --- a/web/src/app/app/crm/meetings/page.tsx +++ b/web/src/app/app/crm/meetings/page.tsx @@ -71,7 +71,7 @@ const STATUS_STYLE: Record = { no_show: { label: "No-show", cls: "bg-red-50 text-red-700 border-red-200" }, }; -function formatWhen(iso?: string): { date: string; time: string; rel: string } { +function formatWhen(iso?: string | Date): { date: string; time: string; rel: string } { if (!iso) return { date: "No time set", time: "", rel: "" }; const d = new Date(iso); if (isNaN(d.getTime())) return { date: "No time set", time: "", rel: "" }; @@ -87,7 +87,7 @@ function formatWhen(iso?: string): { date: string; time: string; rel: string } { // --- Add to calendar (no API: a Google template link + a downloadable .ics) --- -function gcalStamp(iso: string): string { +function gcalStamp(iso: string | Date): string { return new Date(iso).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); } diff --git a/web/src/app/app/crm/tasks/page.tsx b/web/src/app/app/crm/tasks/page.tsx index 05e1480e0..84375f174 100644 --- a/web/src/app/app/crm/tasks/page.tsx +++ b/web/src/app/app/crm/tasks/page.tsx @@ -80,6 +80,7 @@ import { useSuppressGlobalCursors } from "@/components/app/presence/GlobalCursor import { useLivePatch } from "@/hooks/useLivePatch"; import ResourceViewers from "@/components/app/presence/ResourceViewers"; import type CRMTask from "@/lib/api/models/app/crm/CRMTask"; +import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask"; import type { CRMTaskPriority, CRMTaskStatus } from "@/lib/api/models/app/crm/CRMTask"; import type SearchTasks from "@/lib/api/models/app/crm/SearchTasks"; import type { TaskSortBy } from "@/lib/api/models/app/crm/SearchTasks"; @@ -146,7 +147,7 @@ const TONE = { muted: { dot: "bg-slate-300", label: "text-slate-500" }, } as const; -function bucketize(due: string | undefined): Bucket { +function bucketize(due: string | Date | undefined): Bucket { if (!due) return "no_due"; const d = new Date(due); if (Number.isNaN(d.getTime())) return "no_due"; @@ -739,7 +740,7 @@ function FlatRow({ try { await update.mutateAsync({ id: task.id, - data: { status: done ? "completed" : "pending" } as Partial, + data: { status: done ? "completed" : "pending" } as CRMTaskWrite, }); } catch (err) { toast.error(buildError(err as AppError)); @@ -1012,7 +1013,7 @@ function GroupedRow({ e.stopPropagation(); const next: CRMTaskStatus = isDone ? "pending" : "completed"; try { - await update.mutateAsync({ id: task.id, data: { status: next } as Partial }); + await update.mutateAsync({ id: task.id, data: { status: next } as CRMTaskWrite }); } catch (err) { toast.error(buildError(err as AppError)); } @@ -1210,7 +1211,7 @@ function TaskTypeTag({ ); } -function DueCell({ due, overdue }: { due: string | undefined; overdue: boolean }) { +function DueCell({ due, overdue }: { due: string | Date | undefined; overdue: boolean }) { if (!due) return —; if (overdue) { return ( @@ -1823,7 +1824,7 @@ function TaskDialog({ toast.error("Title required"); return; } - const data: Partial = { + const data: CRMTaskWrite = { title: title.trim(), priority, type, @@ -2210,7 +2211,7 @@ function hasAnyFilter(f: SearchTasks): boolean { ); } -function fmtDue(d: string) { +function fmtDue(d: string | Date) { try { const dt = new Date(d); const now = new Date(); diff --git a/web/src/app/app/settings/billing/CreditsCard.tsx b/web/src/app/app/settings/billing/CreditsCard.tsx index e36c53830..45da41820 100644 --- a/web/src/app/app/settings/billing/CreditsCard.tsx +++ b/web/src/app/app/settings/billing/CreditsCard.tsx @@ -285,14 +285,14 @@ function describeReason(reason: string): string { return map[reason] ?? reason.replace(/_/g, " "); } -function formatReset(value: string | null | undefined): string { +function formatReset(value: string | Date | null | undefined): string { if (!value) return "on renewal"; const d = new Date(value); if (Number.isNaN(d.getTime())) return "on renewal"; return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } -function formatWhen(value: string): string { +function formatWhen(value: string | Date): string { const d = new Date(value); if (Number.isNaN(d.getTime())) return "—"; return d.toLocaleDateString("en-US", { diff --git a/web/src/app/app/settings/billing/page.tsx b/web/src/app/app/settings/billing/page.tsx index 963ab075c..7a6f1d7f2 100644 --- a/web/src/app/app/settings/billing/page.tsx +++ b/web/src/app/app/settings/billing/page.tsx @@ -517,7 +517,7 @@ function describeRedemption(d: DiscountRedemption): string { return d.type || "Discount"; } -function fmtDate(value?: string | null): string { +function fmtDate(value?: string | Date | null): string { if (!value) return "—"; const d = new Date(value); if (Number.isNaN(d.getTime())) return "—"; diff --git a/web/src/app/app/settings/referral/page.tsx b/web/src/app/app/settings/referral/page.tsx index 72984889d..0dd6838a2 100644 --- a/web/src/app/app/settings/referral/page.tsx +++ b/web/src/app/app/settings/referral/page.tsx @@ -488,7 +488,7 @@ function formatMoney(cents: number, currency: string): string { } } -function formatDate(value?: string): string { +function formatDate(value?: string | Date): string { if (!value) return "—"; const d = new Date(value); if (Number.isNaN(d.getTime())) return "—"; diff --git a/web/src/app/app/settings/security/TwoFactorManager.tsx b/web/src/app/app/settings/security/TwoFactorManager.tsx index a128ac3f9..b1ec16f4c 100644 --- a/web/src/app/app/settings/security/TwoFactorManager.tsx +++ b/web/src/app/app/settings/security/TwoFactorManager.tsx @@ -19,7 +19,7 @@ type Dialog = "enroll" | "regenerate" | "disable" | null; // Below this many unused codes the row turns amber and asks for a new set. const LOW_CODES = 3; -const fmt = (d: string) => +const fmt = (d: string | Date) => new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); export default function TwoFactorManager() { diff --git a/web/src/components/app/agent/AgentPanel.tsx b/web/src/components/app/agent/AgentPanel.tsx index e6cc37cf2..c01920de4 100644 --- a/web/src/components/app/agent/AgentPanel.tsx +++ b/web/src/components/app/agent/AgentPanel.tsx @@ -1166,7 +1166,7 @@ function DockBar({ // ── History rail ──────────────────────────────────────────────────── // historyBucket labels a session's recency group for the sidebar sections. -function historyBucket(iso: string): string { +function historyBucket(iso: string | Date): string { const t = new Date(iso).getTime(); const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); diff --git a/web/src/components/app/analytics/DirectMailSection.tsx b/web/src/components/app/analytics/DirectMailSection.tsx index d550e378e..e361c32a6 100644 --- a/web/src/components/app/analytics/DirectMailSection.tsx +++ b/web/src/components/app/analytics/DirectMailSection.tsx @@ -16,6 +16,7 @@ import { EyeIcon, InboxIcon, MailIcon, ReplyIcon } from "lucide-react"; import { EmptyBlock, SectionBar, Stat, StatStrip } from "@/components/layout/Page"; import { MultiTrend, type TrendSeries } from "@/components/ui/charts"; +import { utcDay } from "@/lib/campaignPeriod"; import useDirectMail from "@/lib/api/hooks/app/analytics/useDirectMail"; import type DirectMailAnalytics from "@/lib/api/models/app/analytics/DirectMailAnalytics"; @@ -60,7 +61,7 @@ export default function DirectMailSection({ period }: { period: string }) { { key: "sent", label: "Sent", tone: "sky", values: (d?.daily_trend ?? []).map((x) => x.sent) }, { key: "received", label: "Received", tone: "emerald", values: (d?.daily_trend ?? []).map((x) => x.received) }, ]; - const labels = (d?.daily_trend ?? []).map((x) => x.date); + const labels = (d?.daily_trend ?? []).map((x) => utcDay(x.date)); if (q.isError) { return ( diff --git a/web/src/components/app/campaigns/LaunchCampaignDialog.tsx b/web/src/components/app/campaigns/LaunchCampaignDialog.tsx index 6960aef78..23d4e725f 100644 --- a/web/src/components/app/campaigns/LaunchCampaignDialog.tsx +++ b/web/src/components/app/campaigns/LaunchCampaignDialog.tsx @@ -133,7 +133,8 @@ export default function LaunchCampaignDialog({ setRiskBlocked(false); setWaiting(false); } - }, [campaign]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the id: a launch changes the campaign itself + }, [campaign?.id]); React.useEffect(() => { return () => { diff --git a/web/src/components/app/campaigns/SendPlanCard.tsx b/web/src/components/app/campaigns/SendPlanCard.tsx index 8a0a4663e..0e3be9cfe 100644 --- a/web/src/components/app/campaigns/SendPlanCard.tsx +++ b/web/src/components/app/campaigns/SendPlanCard.tsx @@ -123,7 +123,7 @@ const LIMITED_BY: Record = { workspace_risk: "workspace posture", }; -function fmtTime(iso: string | undefined, tz: string): string { +function fmtTime(iso: string | Date | undefined, tz: string): string { if (!iso) return ""; try { return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", timeZone: tz }); @@ -132,7 +132,7 @@ function fmtTime(iso: string | undefined, tz: string): string { } } -function fmtDay(iso: string | undefined, tz: string): string { +function fmtDay(iso: string | Date | undefined, tz: string): string { if (!iso) return ""; const d = new Date(iso); const today = new Date(); diff --git a/web/src/components/app/contacts/ImportWizard.tsx b/web/src/components/app/contacts/ImportWizard.tsx index de6b43ee7..e87c5af0b 100644 --- a/web/src/components/app/contacts/ImportWizard.tsx +++ b/web/src/components/app/contacts/ImportWizard.tsx @@ -1219,7 +1219,7 @@ function StatCard({ ); } -function durationText(start: string, end: string): string { +function durationText(start: string | Date, end: string | Date): string { const s = new Date(start).getTime(); const e = new Date(end).getTime(); if (Number.isNaN(s) || Number.isNaN(e)) return "—"; diff --git a/web/src/components/app/contacts/VerificationBadge.test.ts b/web/src/components/app/contacts/VerificationBadge.test.ts index b3fa54b04..035e5ea90 100644 --- a/web/src/components/app/contacts/VerificationBadge.test.ts +++ b/web/src/components/app/contacts/VerificationBadge.test.ts @@ -23,7 +23,7 @@ describe("verification provider attribution", () => { verification_status: "invalid", verification_source: "provider", verification_provider: "millionverifier", - verification_requested_at: "2026-09-22T10:00:00Z", + verification_requested_at: new Date("2026-09-22T10:00:00Z"), })).toBe("Undeliverable · verified with MillionVerifier · re-check queued"); }); diff --git a/web/src/components/app/contacts/columns.tsx b/web/src/components/app/contacts/columns.tsx index 620f02f93..7c6278d2d 100644 --- a/web/src/components/app/contacts/columns.tsx +++ b/web/src/components/app/contacts/columns.tsx @@ -42,9 +42,9 @@ export interface ContactRow { verification_sub_status?: string; verification_source?: VerificationSource; verification_provider?: string; - verification_checked_at?: string | null; + verification_checked_at?: Date | null; verification_confidence?: number; - verification_requested_at?: string | null; + verification_requested_at?: Date | null; mail_host?: string; created_at: Date; updated_at?: Date; diff --git a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx index c841b12d1..94dc25930 100644 --- a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx +++ b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx @@ -365,7 +365,7 @@ function LatestRow({ icon, }: { label: string; - ts?: string | null; + ts?: Date | string | null; icon: React.ReactNode; }) { return ( diff --git a/web/src/components/app/crm/DealsTable.tsx b/web/src/components/app/crm/DealsTable.tsx index 61c8be4b7..e63f652d1 100644 --- a/web/src/components/app/crm/DealsTable.tsx +++ b/web/src/components/app/crm/DealsTable.tsx @@ -570,7 +570,7 @@ function money(n: number | undefined, currency = "USD") { } } -function fmtDate(d: string | undefined) { +function fmtDate(d: string | Date | undefined) { if (!d) return "—"; try { return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric" }); diff --git a/web/src/components/app/emails/SyncStatusCard.tsx b/web/src/components/app/emails/SyncStatusCard.tsx index a3bbd0b36..74575c482 100644 --- a/web/src/components/app/emails/SyncStatusCard.tsx +++ b/web/src/components/app/emails/SyncStatusCard.tsx @@ -25,7 +25,7 @@ const REASON_COPY: Record = { priority_daily: "the daily limit for replies was reached", }; -function relative(iso: string): string { +function relative(iso: string | Date): string { const diff = Date.now() - new Date(iso).getTime(); const m = Math.round(diff / 60_000); if (m < 1) return "just now"; @@ -35,7 +35,7 @@ function relative(iso: string): string { return new Date(iso).toLocaleDateString(); } -function until(iso: string): string { +function until(iso: string | Date): string { const d = new Date(iso); const sameDay = d.toDateString() === new Date().toDateString(); return sameDay diff --git a/web/src/components/app/unibox/ContactContextPanel.tsx b/web/src/components/app/unibox/ContactContextPanel.tsx index f2aeccd97..b8494d97d 100644 --- a/web/src/components/app/unibox/ContactContextPanel.tsx +++ b/web/src/components/app/unibox/ContactContextPanel.tsx @@ -61,7 +61,9 @@ import useUpdateDeal from "@/lib/api/hooks/app/crm/deals/useUpdateDeal"; import usePipelines from "@/lib/api/hooks/app/crm/pipelines/usePipelines"; import type { Stage } from "@/lib/api/models/app/crm/Pipeline"; import type Deal from "@/lib/api/models/app/crm/Deal"; +import type { DealWrite } from "@/lib/api/models/app/crm/Deal"; import type CRMTask from "@/lib/api/models/app/crm/CRMTask"; +import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -369,7 +371,7 @@ function campaignLine(s: ContactCampaignState): { text: string; title?: string } return { text, title: text }; } -function shortDate(iso: string): string { +function shortDate(iso: string | Date): string { return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", @@ -424,7 +426,7 @@ function DealsSection({ toast.error("Deal name required"); return; } - const data: Partial = { + const data: DealWrite = { pipeline_id: pipelineId, stage_id: stageId, name: name.trim(), @@ -453,7 +455,7 @@ function DealsSection({ async function moveDeal(dealId: string, newStageId: string) { try { await toast.promise( - updateDeal.mutateAsync({ id: dealId, data: { stage_id: newStageId } as Partial }), + updateDeal.mutateAsync({ id: dealId, data: { stage_id: newStageId } as DealWrite }), { loading: "Moving…", success: "Moved", error: (e: AppError) => buildError(e) }, ); } catch { @@ -639,7 +641,7 @@ function TasksSection({ toast.error("Task title required"); return; } - const data: Partial = { + const data: CRMTaskWrite = { title: title.trim(), contact_id: contactId, priority, diff --git a/web/src/components/app/unibox/ScheduledList.tsx b/web/src/components/app/unibox/ScheduledList.tsx index 4a8fa6ce6..6d06c0729 100644 --- a/web/src/components/app/unibox/ScheduledList.tsx +++ b/web/src/components/app/unibox/ScheduledList.tsx @@ -26,7 +26,7 @@ import type UniboxScheduledItem from "@/lib/api/models/app/unibox/UniboxSchedule import { cn } from "@/lib/utils"; import ComposeButton from "@/components/app/unibox/compose/ComposeButton"; -function formatWhen(iso: string): { absolute: string; relative: string } { +function formatWhen(iso: string | Date): { absolute: string; relative: string } { const d = new Date(iso); if (Number.isNaN(d.getTime())) return { absolute: "—", relative: "" }; diff --git a/web/src/components/app/unibox/ThreadView.tsx b/web/src/components/app/unibox/ThreadView.tsx index 1c724b25d..bdffafff9 100644 --- a/web/src/components/app/unibox/ThreadView.tsx +++ b/web/src/components/app/unibox/ThreadView.tsx @@ -777,9 +777,9 @@ function IconAction({ // Friendly relative-or-absolute time used for scheduled cards. // Examples: "in 12 min", "in 3 h", "tomorrow, 09:00", "Mar 5, 17:00". -function formatScheduled(iso: string): string { +function formatScheduled(iso: string | Date): string { const d = new Date(iso); - if (!Number.isFinite(d.getTime())) return iso; + if (!Number.isFinite(d.getTime())) return String(iso); const now = new Date(); const diffMs = d.getTime() - now.getTime(); const diffMin = Math.round(diffMs / 60_000); diff --git a/web/src/components/app/unibox/compose/ComposeDraftsItem.tsx b/web/src/components/app/unibox/compose/ComposeDraftsItem.tsx index f12dd2206..c778bdab9 100644 --- a/web/src/components/app/unibox/compose/ComposeDraftsItem.tsx +++ b/web/src/components/app/unibox/compose/ComposeDraftsItem.tsx @@ -15,7 +15,7 @@ function draftTitle(d: ComposeDraft): string { return d.subject.trim() || d.to[0] || "(no subject)"; } -function formatWhen(iso: string): string { +function formatWhen(iso: string | Date): string { const d = new Date(iso); const now = new Date(); const sameDay = diff --git a/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx b/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx index a03617e36..2b9a40076 100644 --- a/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx +++ b/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx @@ -30,7 +30,7 @@ interface ComposeHistoryPanelProps { affinityLine?: string; } -function formatWhen(iso: string): string { +function formatWhen(iso: string | Date): string { const d = new Date(iso); const now = new Date(); const sameYear = d.getFullYear() === now.getFullYear(); diff --git a/web/src/components/app/unibox/compose/ComposeWindow.tsx b/web/src/components/app/unibox/compose/ComposeWindow.tsx index 93b1426d2..8a350f899 100644 --- a/web/src/components/app/unibox/compose/ComposeWindow.tsx +++ b/web/src/components/app/unibox/compose/ComposeWindow.tsx @@ -367,7 +367,7 @@ function ComposeWindowInner({ // Undo window: the send is queued a few seconds out. No // "sent" toast; the header pill counts down and can cancel, // reopening the composer from this seed. - const nowIso = new Date().toISOString(); + const now = new Date(); addOutbox({ taskId: res.task_id, scheduledAt: resolveSendAt(res.scheduled_at, user.undo_send_seconds || 30), @@ -382,8 +382,8 @@ function ComposeWindowInner({ bcc, subject: subject.trim(), body: trimmedBody, - updated_at: nowIso, - created_at: nowIso, + updated_at: now, + created_at: now, }, }); } else { diff --git a/web/src/components/layout/CreditsMeter.tsx b/web/src/components/layout/CreditsMeter.tsx index 12f2790a0..baf059190 100644 --- a/web/src/components/layout/CreditsMeter.tsx +++ b/web/src/components/layout/CreditsMeter.tsx @@ -321,7 +321,7 @@ function formatCredits(n: number): string { } // resetLabel turns next_reset_at into a short relative note for the panel. -function resetLabel(iso: string | null): string | null { +function resetLabel(iso: string | Date | null): string | null { if (!iso) return null; const days = Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000); if (days <= 0) return "Resets soon"; diff --git a/web/src/components/layout/ErrorBoundary.tsx b/web/src/components/layout/ErrorBoundary.tsx index 045a91c41..10d023458 100644 --- a/web/src/components/layout/ErrorBoundary.tsx +++ b/web/src/components/layout/ErrorBoundary.tsx @@ -13,6 +13,7 @@ import React from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { AlertTriangleIcon, RefreshCcwIcon } from "lucide-react"; import { captureException } from "@/lib/observability"; +import { SENTRY_RELEASE } from "@/lib/information"; interface State { error: Error | null; @@ -108,6 +109,7 @@ function BoundaryFallback({ error, info, reset }: { error: Error; info: React.Er Stack
+                                {SENTRY_RELEASE ? `Build: ${SENTRY_RELEASE}\n\n` : ""}
                                 {error.stack || ""}
                                 {info?.componentStack ? `\n\nComponent stack:${info.componentStack}` : ""}
                             
diff --git a/web/src/components/layout/NotificationBell.tsx b/web/src/components/layout/NotificationBell.tsx index b9ed8d5c8..517817f71 100644 --- a/web/src/components/layout/NotificationBell.tsx +++ b/web/src/components/layout/NotificationBell.tsx @@ -73,7 +73,7 @@ function bucketFor(d: Date): Bucket { } // Compact relative timestamp: "now", "2m", "3h", "Yesterday", then a short date. -function relTime(iso: string): string { +function relTime(iso: string | Date): string { const d = new Date(iso); const s = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000)); if (s < 60) return "now"; diff --git a/web/src/lib/api/client/app/campaigns/estimateCampaign.ts b/web/src/lib/api/client/app/campaigns/estimateCampaign.ts index 0b4509ecb..0457f5b78 100644 --- a/web/src/lib/api/client/app/campaigns/estimateCampaign.ts +++ b/web/src/lib/api/client/app/campaigns/estimateCampaign.ts @@ -22,7 +22,7 @@ export interface CampaignEstimateResult { remaining_today: number; // Null when the pool has no capacity or the audience is empty. sending_days: number | null; - estimated_finish_at: string | null; + estimated_finish_at: Date | null; } export default async function estimateCampaign(input: CampaignEstimateInput): Promise { diff --git a/web/src/lib/api/client/app/campaigns/getCampaignLogs.ts b/web/src/lib/api/client/app/campaigns/getCampaignLogs.ts index 4115a40b1..baa631c55 100644 --- a/web/src/lib/api/client/app/campaigns/getCampaignLogs.ts +++ b/web/src/lib/api/client/app/campaigns/getCampaignLogs.ts @@ -6,7 +6,7 @@ interface CampaignLogRow { event_type: string; message: string; metadata?: Record | null; - created_at: string; + created_at: Date; } export interface CampaignLogItem { diff --git a/web/src/lib/api/client/app/contacts/importContacts.ts b/web/src/lib/api/client/app/contacts/importContacts.ts index ab8561471..72ec39fd2 100644 --- a/web/src/lib/api/client/app/contacts/importContacts.ts +++ b/web/src/lib/api/client/app/contacts/importContacts.ts @@ -66,8 +66,9 @@ export interface ImportResult { updated: number; skipped: number; failed: number; - started_at: string; - ended_at: string; + // A Date when revived through Request (a lead-sync run); a string from the direct import upload. + started_at: Date | string; + ended_at: Date | string; errors?: ImportRowError[]; // Set when more rows failed than the API reports back; `errors` then holds // the first slice of them and `failed` is the true count. diff --git a/web/src/lib/api/client/app/crm/deals/createDeal.ts b/web/src/lib/api/client/app/crm/deals/createDeal.ts index 83a9b5d26..965fe71e3 100644 --- a/web/src/lib/api/client/app/crm/deals/createDeal.ts +++ b/web/src/lib/api/client/app/crm/deals/createDeal.ts @@ -1,7 +1,8 @@ import type Deal from "@/lib/api/models/app/crm/Deal"; +import type { DealWrite } from "@/lib/api/models/app/crm/Deal"; import Request from "../../../Request"; -export default async function createDeal(data: Partial): Promise { +export default async function createDeal(data: DealWrite): Promise { return await Request({ method: "POST", url: `/crm/deals`, diff --git a/web/src/lib/api/client/app/crm/deals/updateDeal.ts b/web/src/lib/api/client/app/crm/deals/updateDeal.ts index 9163db0b5..a8876ad54 100644 --- a/web/src/lib/api/client/app/crm/deals/updateDeal.ts +++ b/web/src/lib/api/client/app/crm/deals/updateDeal.ts @@ -1,7 +1,8 @@ import type Deal from "@/lib/api/models/app/crm/Deal"; +import type { DealWrite } from "@/lib/api/models/app/crm/Deal"; import Request from "../../../Request"; -export default async function updateDeal(id: string, data: Partial): Promise { +export default async function updateDeal(id: string, data: DealWrite): Promise { return await Request({ method: "PATCH", url: `/crm/deals/${id}`, diff --git a/web/src/lib/api/client/app/crm/tasks/createCRMTask.ts b/web/src/lib/api/client/app/crm/tasks/createCRMTask.ts index a58c143db..1cc10ede5 100644 --- a/web/src/lib/api/client/app/crm/tasks/createCRMTask.ts +++ b/web/src/lib/api/client/app/crm/tasks/createCRMTask.ts @@ -1,7 +1,8 @@ import type CRMTask from "@/lib/api/models/app/crm/CRMTask"; +import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask"; import Request from "../../../Request"; -export default async function createCRMTask(data: Partial): Promise { +export default async function createCRMTask(data: CRMTaskWrite): Promise { return await Request({ method: "POST", url: `/crm/tasks`, diff --git a/web/src/lib/api/client/app/crm/tasks/updateCRMTask.ts b/web/src/lib/api/client/app/crm/tasks/updateCRMTask.ts index 3ae407c64..108c04796 100644 --- a/web/src/lib/api/client/app/crm/tasks/updateCRMTask.ts +++ b/web/src/lib/api/client/app/crm/tasks/updateCRMTask.ts @@ -1,7 +1,8 @@ import type CRMTask from "@/lib/api/models/app/crm/CRMTask"; +import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask"; import Request from "../../../Request"; -export default async function updateCRMTask(id: string, data: Partial): Promise { +export default async function updateCRMTask(id: string, data: CRMTaskWrite): Promise { return await Request({ method: "PATCH", url: `/crm/tasks/${id}`, diff --git a/web/src/lib/api/client/app/organizations/getOrganizations.ts b/web/src/lib/api/client/app/organizations/getOrganizations.ts index d27bc5e21..3e7a7762e 100644 --- a/web/src/lib/api/client/app/organizations/getOrganizations.ts +++ b/web/src/lib/api/client/app/organizations/getOrganizations.ts @@ -15,7 +15,7 @@ interface RawMembership { avatar?: string; avatar_url?: string | null; plan?: string; - created_at: string; + created_at: Date; }; } diff --git a/web/src/lib/api/client/app/unibox/agentDrafts.ts b/web/src/lib/api/client/app/unibox/agentDrafts.ts index e19b77ca2..b6624efec 100644 --- a/web/src/lib/api/client/app/unibox/agentDrafts.ts +++ b/web/src/lib/api/client/app/unibox/agentDrafts.ts @@ -19,8 +19,8 @@ export interface AIThreadDraft { confidence: number model: string status: "pending" | "approved" | "discarded" - created_at: string - updated_at: string + created_at: Date + updated_at: Date } // listAgentDrafts returns the org's pending inbox-agent drafts, newest first. diff --git a/web/src/lib/api/client/app/unibox/composeDrafts.ts b/web/src/lib/api/client/app/unibox/composeDrafts.ts index 008bc99ea..d46d439c0 100644 --- a/web/src/lib/api/client/app/unibox/composeDrafts.ts +++ b/web/src/lib/api/client/app/unibox/composeDrafts.ts @@ -12,8 +12,8 @@ export interface ComposeDraft { bcc: string[]; subject: string; body: string; - updated_at: string; - created_at: string; + updated_at: Date; + created_at: Date; } export interface ComposeDraftSaveInput { diff --git a/web/src/lib/api/client/app/unibox/searchIncoming.ts b/web/src/lib/api/client/app/unibox/searchIncoming.ts index 3bf346c3b..713d8bb00 100644 --- a/web/src/lib/api/client/app/unibox/searchIncoming.ts +++ b/web/src/lib/api/client/app/unibox/searchIncoming.ts @@ -14,7 +14,7 @@ export interface UniboxListRow { to_addr: string[]; subject: string; snippet: string; - internal_date: string; + internal_date: Date; seen: boolean; /** Messages in the conversation behind this row (1 = singleton). */ message_count: number; diff --git a/web/src/lib/api/client/app/unibox/snoozeThread.ts b/web/src/lib/api/client/app/unibox/snoozeThread.ts index e6142008f..6fadef253 100644 --- a/web/src/lib/api/client/app/unibox/snoozeThread.ts +++ b/web/src/lib/api/client/app/unibox/snoozeThread.ts @@ -10,7 +10,7 @@ export interface SnoozeRequest { export interface SnoozeResponse { id: string; thread_id: string; - snoozed_until: string; + snoozed_until: Date; } export async function snoozeThread(req: SnoozeRequest): Promise { diff --git a/web/src/lib/api/client/auth/twoFactor.ts b/web/src/lib/api/client/auth/twoFactor.ts index 918500469..08b3f6c4a 100644 --- a/web/src/lib/api/client/auth/twoFactor.ts +++ b/web/src/lib/api/client/auth/twoFactor.ts @@ -4,7 +4,7 @@ import Request from "../Request"; export interface TwoFactorStatus { enabled: boolean; /** When the authenticator was confirmed. Absent while 2FA is off. */ - confirmed_at?: string; + confirmed_at?: Date; recovery_codes_remaining: number; recovery_codes_total: number; } diff --git a/web/src/lib/api/hooks/app/crm/deals/useCreateDeal.ts b/web/src/lib/api/hooks/app/crm/deals/useCreateDeal.ts index 231c60434..cf801702c 100644 --- a/web/src/lib/api/hooks/app/crm/deals/useCreateDeal.ts +++ b/web/src/lib/api/hooks/app/crm/deals/useCreateDeal.ts @@ -1,12 +1,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type Deal from "@/lib/api/models/app/crm/Deal"; +import type { DealWrite } from "@/lib/api/models/app/crm/Deal"; import createDeal from "@/lib/api/client/app/crm/deals/createDeal"; export default function useCreateDeal() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: Partial) => createDeal(data), + mutationFn: (data: DealWrite) => createDeal(data), onSuccess: (_result, variables) => { // Broad prefix so the board list, the cross-pipeline search table, // and the summary aggregates all refresh. diff --git a/web/src/lib/api/hooks/app/crm/deals/useUpdateDeal.ts b/web/src/lib/api/hooks/app/crm/deals/useUpdateDeal.ts index 1ceccbcff..d82269109 100644 --- a/web/src/lib/api/hooks/app/crm/deals/useUpdateDeal.ts +++ b/web/src/lib/api/hooks/app/crm/deals/useUpdateDeal.ts @@ -1,12 +1,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type Deal from "@/lib/api/models/app/crm/Deal"; +import type { DealWrite } from "@/lib/api/models/app/crm/Deal"; import updateDeal from "@/lib/api/client/app/crm/deals/updateDeal"; export default function useUpdateDeal() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, data }: { id: string; data: Partial }) => updateDeal(id, data), + mutationFn: ({ id, data }: { id: string; data: DealWrite }) => updateDeal(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["crm", "deals"], diff --git a/web/src/lib/api/hooks/app/crm/tasks/useCreateCRMTask.ts b/web/src/lib/api/hooks/app/crm/tasks/useCreateCRMTask.ts index 2323dd46c..e5960f56f 100644 --- a/web/src/lib/api/hooks/app/crm/tasks/useCreateCRMTask.ts +++ b/web/src/lib/api/hooks/app/crm/tasks/useCreateCRMTask.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type CRMTask from "@/lib/api/models/app/crm/CRMTask"; +import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask"; import createCRMTask from "@/lib/api/client/app/crm/tasks/createCRMTask"; import { useLivePatch } from "@/hooks/useLivePatch"; @@ -8,7 +8,7 @@ export default function useCreateCRMTask() { const { pushPatch } = useLivePatch("crm_tasks"); return useMutation({ - mutationFn: (data: Partial) => createCRMTask(data), + mutationFn: (data: CRMTaskWrite) => createCRMTask(data), onSuccess: () => { // Broad prefix so the server-driven search list, the summary totals, // and the sidebar overdue indicator all refresh on create. diff --git a/web/src/lib/api/hooks/app/crm/tasks/useUpdateCRMTask.ts b/web/src/lib/api/hooks/app/crm/tasks/useUpdateCRMTask.ts index bc69a79ca..353e9a727 100644 --- a/web/src/lib/api/hooks/app/crm/tasks/useUpdateCRMTask.ts +++ b/web/src/lib/api/hooks/app/crm/tasks/useUpdateCRMTask.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type CRMTask from "@/lib/api/models/app/crm/CRMTask"; +import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask"; import updateCRMTask from "@/lib/api/client/app/crm/tasks/updateCRMTask"; import { useLivePatch } from "@/hooks/useLivePatch"; @@ -10,7 +10,7 @@ export default function useUpdateCRMTask() { const { pushPatch } = useLivePatch("crm_tasks"); return useMutation({ - mutationFn: ({ id, data }: { id: string; data: Partial }) => updateCRMTask(id, data), + mutationFn: ({ id, data }: { id: string; data: CRMTaskWrite }) => updateCRMTask(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["crm", "tasks"], diff --git a/web/src/lib/api/models/app/advisor/Advisor.ts b/web/src/lib/api/models/app/advisor/Advisor.ts index b17776f0b..03cdabdf5 100644 --- a/web/src/lib/api/models/app/advisor/Advisor.ts +++ b/web/src/lib/api/models/app/advisor/Advisor.ts @@ -107,10 +107,10 @@ export interface AdvisorFinding { evidence?: Record; action?: AdvisorAction; - first_seen_at: string; - last_seen_at: string; - snoozed_until?: string; - applied_at?: string; + first_seen_at: Date; + last_seen_at: Date; + snoozed_until?: Date; + applied_at?: Date; applied_result?: string; } @@ -129,7 +129,7 @@ export interface AdvisorSummary { medium: number; low: number; surfaces: AdvisorSurfaceCount[]; - last_run_at?: string; + last_run_at?: Date; } export interface AdvisorSettings { @@ -142,7 +142,7 @@ export interface AdvisorSettings { // it on. autopilot: boolean; autopilot_actor_id?: string; - updated_at: string; + updated_at: Date; } export interface AdvisorFindingsQuery { diff --git a/web/src/lib/api/models/app/agent/Agent.ts b/web/src/lib/api/models/app/agent/Agent.ts index 9d28598e0..f6c628289 100644 --- a/web/src/lib/api/models/app/agent/Agent.ts +++ b/web/src/lib/api/models/app/agent/Agent.ts @@ -14,8 +14,8 @@ export interface AgentSession { model?: string; pending?: PendingAgentTool | null; }; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } export interface PendingAgentTool { diff --git a/web/src/lib/api/models/app/analytics/AccountStatus.ts b/web/src/lib/api/models/app/analytics/AccountStatus.ts index 8fd55d04d..0c4e9f6c0 100644 --- a/web/src/lib/api/models/app/analytics/AccountStatus.ts +++ b/web/src/lib/api/models/app/analytics/AccountStatus.ts @@ -17,7 +17,7 @@ export interface AccountError { title: string; message: string; action_required?: string; - created_at: string; + created_at: Date; } export interface AccountDailyUsage { @@ -31,8 +31,8 @@ export interface AccountDailyUsage { export interface WarmupStatusInfo { enabled: boolean; paused: boolean; - paused_at?: string | null; - started_at: string; + paused_at?: Date | null; + started_at: Date; current_volume: number; target_volume: number; max_volume: number; @@ -58,7 +58,7 @@ export interface WarmupRampHold { placements: number; sends: number; volume_cut: boolean; - resumes_at: string; + resumes_at: Date; } // Warmup-pool reputation for this mailbox. Folded into health.score and also @@ -71,8 +71,8 @@ export interface WarmupHealthInfo { /** @deprecated Always 0 since the warmup spam score was retired; read score and reason. */ spam_score: number; reason?: string; - blocked_until?: string | null; - evaluated_at?: string | null; + blocked_until?: Date | null; + evaluated_at?: Date | null; /** Distinct warmup partners over the last 7 days: mailboxes, their domains, and the workspaces behind them. */ partner_mailboxes_7d: number; partner_domains_7d: number; @@ -87,7 +87,7 @@ export default interface AccountStatus { email: string; provider: string; status: string; - last_synced_at: string | null; + last_synced_at: Date | null; health: AccountHealth; errors: AccountError[]; daily_usage: AccountDailyUsage; @@ -117,6 +117,6 @@ export type SendLifecycle = "active" | "resting" | "reserve"; // Why a mailbox is not being offered cold sends. export interface SendLifecycleState { state: SendLifecycle; - since?: string; + since?: Date; reason?: string; } diff --git a/web/src/lib/api/models/app/analytics/DashboardOverview.ts b/web/src/lib/api/models/app/analytics/DashboardOverview.ts index 67e1cf9cf..0295c8ae5 100644 --- a/web/src/lib/api/models/app/analytics/DashboardOverview.ts +++ b/web/src/lib/api/models/app/analytics/DashboardOverview.ts @@ -29,7 +29,7 @@ export interface RecentActivityItem { campaign_name: string contact_email: string contact_id?: string - timestamp: string + timestamp: Date link?: string // Client, device and location of a person's open or click, when logged. origin?: EngagementOrigin diff --git a/web/src/lib/api/models/app/analytics/Deliverability.ts b/web/src/lib/api/models/app/analytics/Deliverability.ts index c3c56476c..7f86a6d09 100644 --- a/web/src/lib/api/models/app/analytics/Deliverability.ts +++ b/web/src/lib/api/models/app/analytics/Deliverability.ts @@ -64,8 +64,8 @@ export interface WarmupHostPlacement { } export default interface DeliverabilityDashboard { - from: string; - to: string; + from: Date; + to: Date; events_total: number; bounce_count: number; complaint_count: number; diff --git a/web/src/lib/api/models/app/analytics/DirectMailAnalytics.ts b/web/src/lib/api/models/app/analytics/DirectMailAnalytics.ts index 6ad19a4ae..634ed22c7 100644 --- a/web/src/lib/api/models/app/analytics/DirectMailAnalytics.ts +++ b/web/src/lib/api/models/app/analytics/DirectMailAnalytics.ts @@ -41,7 +41,7 @@ export interface DirectMailTracking { } export interface DirectMailDailyStats { - date: string; + date: Date; sent: number; received: number; } @@ -58,7 +58,7 @@ export interface DirectMailContact { email: string; sent: number; received: number; - last_at: string; + last_at: Date; } export default interface DirectMailAnalytics { diff --git a/web/src/lib/api/models/app/apikeys/APIKey.ts b/web/src/lib/api/models/app/apikeys/APIKey.ts index 261e95f11..83d484223 100644 --- a/web/src/lib/api/models/app/apikeys/APIKey.ts +++ b/web/src/lib/api/models/app/apikeys/APIKey.ts @@ -23,14 +23,14 @@ export default interface APIKey { rate_limit_per_minute: number; status: APIKeyStatus; - last_used_at?: string | null; + last_used_at?: Date | null; last_request_ip?: string | null; - expires_at?: string | null; - revoked_at?: string | null; + expires_at?: Date | null; + revoked_at?: Date | null; revoked_reason?: string | null; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } // The `status` column only ever holds "active" or "revoked": expiry is applied diff --git a/web/src/lib/api/models/app/apikeys/APIKeyAnalytics.ts b/web/src/lib/api/models/app/apikeys/APIKeyAnalytics.ts index eb0681177..0425f539c 100644 --- a/web/src/lib/api/models/app/apikeys/APIKeyAnalytics.ts +++ b/web/src/lib/api/models/app/apikeys/APIKeyAnalytics.ts @@ -5,11 +5,11 @@ export interface APIKeyUsageSummary { requests_24h: number; errors_24h: number; avg_latency_ms_24h: number; - last_call_at?: string | null; + last_call_at?: Date | null; } export interface APIKeyUsageBucket { - bucket: string; + bucket: Date; total: number; success: number; client_errors: number; @@ -27,8 +27,8 @@ export interface APIKeyEndpointStat { export interface APIKeyAnalytics { api_key_id: string; - from: string; - to: string; + from: Date; + to: Date; interval: "minute" | "hour" | "day"; buckets: APIKeyUsageBucket[]; endpoints: APIKeyEndpointStat[]; @@ -45,7 +45,7 @@ export interface APIKeyUsageLog { user_agent: string; response_code: number; response_time_ms: number; - created_at: string; + created_at: Date; } export interface APIKeyUsageLogsResult { diff --git a/web/src/lib/api/models/app/audit/AuditLog.ts b/web/src/lib/api/models/app/audit/AuditLog.ts index b6e0f5917..ab337176b 100644 --- a/web/src/lib/api/models/app/audit/AuditLog.ts +++ b/web/src/lib/api/models/app/audit/AuditLog.ts @@ -82,7 +82,7 @@ export default interface AuditLog { org_id: string; user_id: string; // actor id (kept for backwards compatibility) actor?: AuditActor; - action_date: string; + action_date: Date; action: AuditAction; entity_type: AuditEntityType; entity_id?: string; @@ -90,7 +90,7 @@ export default interface AuditLog { user_agent: string; changes?: Record; metadata?: Record; - timestamp: string; + timestamp: Date; } export interface AuditLogsResult { diff --git a/web/src/lib/api/models/app/automations/Automation.ts b/web/src/lib/api/models/app/automations/Automation.ts index 12663a7d9..53db5c337 100644 --- a/web/src/lib/api/models/app/automations/Automation.ts +++ b/web/src/lib/api/models/app/automations/Automation.ts @@ -104,8 +104,8 @@ export interface Automation { trigger_event: string; filter?: Record; graph: AutomationGraph; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; // Public POST path that fires this automation, set only when its trigger is // the inbound webhook. Append to the API origin for the full URL. inbound_url?: string; @@ -139,8 +139,8 @@ export interface AutomationRun { status: string; // running | success | error node_results: AutomationNodeResult[]; error_detail?: string; - started_at: string; - finished_at?: string | null; + started_at: Date; + finished_at?: Date | null; } // Dry-run (test) response: the trace of the walk + the sample data used. diff --git a/web/src/lib/api/models/app/campaigns/Campaign.ts b/web/src/lib/api/models/app/campaigns/Campaign.ts index 2249dd256..6bb4790cc 100644 --- a/web/src/lib/api/models/app/campaigns/Campaign.ts +++ b/web/src/lib/api/models/app/campaigns/Campaign.ts @@ -61,7 +61,7 @@ export default interface Campaign { ramp_increment: number; ramp_ceiling: number; ramp_level: number; - ramp_level_date?: string | null; + ramp_level_date?: Date | null; // Match the sending mailbox provider to the recipient's provider. esp_match_mode: 'off' | 'prefer' | 'strict'; @@ -77,7 +77,7 @@ export default interface Campaign { // Keep running for new leads: out of leads, the campaign waits (idle_since // set) instead of finishing. Linking a segment turns it on. continuous: boolean; - idle_since?: string | null; + idle_since?: Date | null; // Auto-pause guardrails. Bounce and complaint rates are ceilings (pause at // or above); the reply rate is a floor (pause below). A rate of 0 turns its @@ -88,13 +88,13 @@ export default interface Campaign { guardrail_reply_rate_min: number; guardrail_min_sample: number; guardrail_window_days: number; - guardrail_tripped_at?: string | null; + guardrail_tripped_at?: Date | null; guardrail_reason?: string; // Campaign-scoped tracking-domain override (honored only when verified). tracking_domain: string; tracking_domain_verified: boolean; - tracking_domain_verified_at?: string | null; + tracking_domain_verified_at?: Date | null; // Automatic UTM tagging of every link. Empty source/medium/campaign keep // the defaults (warmbly / email / the campaign name); utm_content is @@ -120,7 +120,7 @@ export interface ScheduleInterval { export interface CampaignSender { email_account_id: string; weight: number; - last_sent_at?: string | null; + last_sent_at?: Date | null; enabled: boolean; } diff --git a/web/src/lib/api/models/app/campaigns/EmailImage.ts b/web/src/lib/api/models/app/campaigns/EmailImage.ts index bf66f02ee..03a00adba 100644 --- a/web/src/lib/api/models/app/campaigns/EmailImage.ts +++ b/web/src/lib/api/models/app/campaigns/EmailImage.ts @@ -10,7 +10,7 @@ export default interface EmailImage { width: number; height: number; url: string; - created_at: string; + created_at: Date; } // One keyset page of the library, newest first. diff --git a/web/src/lib/api/models/app/campaigns/Preflight.ts b/web/src/lib/api/models/app/campaigns/Preflight.ts index decd8c46d..5ed8e7199 100644 --- a/web/src/lib/api/models/app/campaigns/Preflight.ts +++ b/web/src/lib/api/models/app/campaigns/Preflight.ts @@ -17,7 +17,7 @@ export interface PreflightReport { score: number; checks: PreflightCheckResult[]; recommendations: string[]; - created_at: string; + created_at: Date; } // Only failures are worth a user's attention; a passing check is noise in a diff --git a/web/src/lib/api/models/app/campaigns/SendPlan.ts b/web/src/lib/api/models/app/campaigns/SendPlan.ts index 723fcf5cf..de7ba220b 100644 --- a/web/src/lib/api/models/app/campaigns/SendPlan.ts +++ b/web/src/lib/api/models/app/campaigns/SendPlan.ts @@ -43,11 +43,11 @@ export interface SendLimit { export interface SendWindow { sending_day: boolean; open_now: boolean; - opens_at?: string; - closes_at?: string; + opens_at?: Date; + closes_at?: Date; minutes_left: number; - starts_at?: string; - ends_at?: string; + starts_at?: Date; + ends_at?: Date; } export interface LeadSupply { @@ -60,7 +60,7 @@ export interface LeadSupply { waiting_on_sender: number; new_leads_started_today: number; max_new_leads_per_day: number; - next_due_at?: string; + next_due_at?: Date; } export interface ColdRampInfo { @@ -81,7 +81,7 @@ export interface MailboxPlan { sent_by_other_campaigns: number; expected_remaining: number; state: MailboxPlanState; - reopens_at?: string; + reopens_at?: Date; health?: string; min_gap_seconds: number; graduation?: ColdRampInfo; @@ -98,7 +98,7 @@ export default interface SendPlan { status: string; day: string; timezone: string; - computed_at: string; + computed_at: Date; configured_ceiling: number; projected_today: number; sent_today: number; @@ -109,7 +109,7 @@ export default interface SendPlan { leads: LeadSupply; mailboxes: MailboxPlan[]; organization?: OrgAllowance; - next_wake_at?: string; + next_wake_at?: Date; } // What the workspace's mailboxes can send today between them, on the diff --git a/web/src/lib/api/models/app/cliauth/CLIAuth.ts b/web/src/lib/api/models/app/cliauth/CLIAuth.ts index bce72f178..ae9de4df3 100644 --- a/web/src/lib/api/models/app/cliauth/CLIAuth.ts +++ b/web/src/lib/api/models/app/cliauth/CLIAuth.ts @@ -13,6 +13,6 @@ export interface CLIAuthCode { status: CLIAuthCodeStatus; organization_id?: string; api_key_id?: string; - expires_at: string; - created_at: string; + expires_at: Date; + created_at: Date; } diff --git a/web/src/lib/api/models/app/contacts/Contact.ts b/web/src/lib/api/models/app/contacts/Contact.ts index 3c26fa531..d3f523688 100644 --- a/web/src/lib/api/models/app/contacts/Contact.ts +++ b/web/src/lib/api/models/app/contacts/Contact.ts @@ -24,8 +24,8 @@ export type LeadStatus = // when an auto-reply parked it and "manual" when a member did; `until` absent // means the hold has no end and only a resume lifts it. export interface LeadHold { - since: string; - until?: string | null; + since: Date; + until?: Date | null; reason?: string; source: "manual" | "out_of_office" | string; } @@ -70,7 +70,7 @@ export interface ContactCampaignProgress { clicked: number; replied: number; bounced: number; - last_activity_at?: string | null; + last_activity_at?: Date | null; // Label of the step the lead is on now (latest step sent). Empty when the // lead hasn't been contacted yet. current_step?: string; @@ -114,13 +114,13 @@ export default interface Contact { verification_sub_status?: string; verification_source?: VerificationSource; verification_provider?: string; - verification_checked_at?: string | null; + verification_checked_at?: Date | null; // How sure the platform is of the status, 0 to 100, scored from the last // check plus what real mail to the address showed. verification_confidence?: number; // Set while a re-check a member asked for waits to run; the verdict // above stands until it lands. - verification_requested_at?: string | null; + verification_requested_at?: Date | null; is_catch_all?: boolean; // Who hosts the contact's inbox, read from its domain's MX; "" until diff --git a/web/src/lib/api/models/app/contacts/ContactCampaignState.ts b/web/src/lib/api/models/app/contacts/ContactCampaignState.ts index aa946d9af..f926737a1 100644 --- a/web/src/lib/api/models/app/contacts/ContactCampaignState.ts +++ b/web/src/lib/api/models/app/contacts/ContactCampaignState.ts @@ -10,12 +10,12 @@ export interface ContactCampaignStep { position: number; subject?: string; - sent_at?: string | null; - opened_at?: string | null; - clicked_at?: string | null; - replied_at?: string | null; - bounced_at?: string | null; - failed_at?: string | null; + sent_at?: Date | null; + opened_at?: Date | null; + clicked_at?: Date | null; + replied_at?: Date | null; + bounced_at?: Date | null; + failed_at?: Date | null; attempts?: number; in_flight?: boolean; } @@ -38,8 +38,8 @@ export interface ContactNextAction { // Only when due now, and then it is the campaign chain's own next wakeup, // not a slot reserved for this contact: leads queued ahead can still push // this step to a later pass. Absent while the chain is being re-seeded. - scheduled_at?: string | null; - not_before?: string | null; + scheduled_at?: Date | null; + not_before?: Date | null; constraint?: string; } @@ -62,7 +62,7 @@ export default interface ContactCampaignState { current_step?: ContactCampaignStep | null; last_action?: string; - last_action_at?: string | null; + last_action_at?: Date | null; // The live per-lead hold: an out-of-office auto-reply parked the contact, // or a member paused them. Absent when the lead is not held. diff --git a/web/src/lib/api/models/app/contacts/ContactDetail.ts b/web/src/lib/api/models/app/contacts/ContactDetail.ts index 2e2fc9cc9..03320a7cd 100644 --- a/web/src/lib/api/models/app/contacts/ContactDetail.ts +++ b/web/src/lib/api/models/app/contacts/ContactDetail.ts @@ -8,11 +8,11 @@ export interface ContactEngagement { total_bounced: number; total_complained: number; - last_sent_at?: string | null; - last_opened_at?: string | null; - last_clicked_at?: string | null; - last_replied_at?: string | null; - last_bounced_at?: string | null; + last_sent_at?: Date | null; + last_opened_at?: Date | null; + last_clicked_at?: Date | null; + last_replied_at?: Date | null; + last_bounced_at?: Date | null; // How the contact reads your mail: each client and device a person's // opens came from, most recent first. @@ -27,7 +27,7 @@ export interface ContactReadingOrigin { os?: string; browser?: string; opens: number; - last_opened_at: string; + last_opened_at: Date; } export interface ContactSuppression { @@ -38,8 +38,8 @@ export interface ContactSuppression { value: string; reason: string; source: "bounce" | "complaint" | "unsubscribe" | "manual" | "import" | string; - expires_at?: string | null; - created_at: string; + expires_at?: Date | null; + created_at: Date; } // Where a contact first came from. Mirrors the backend CHECK; "unknown" is @@ -69,7 +69,7 @@ export type VerificationEvidenceKind = export interface ContactVerificationEvidence { kind: VerificationEvidenceKind; detail?: string; - observed_at: string; + observed_at: Date; } export interface ContactVerificationDetail { @@ -87,9 +87,9 @@ export interface ContactVerificationDetail { provider_label?: string; // What that check said, before real mail was weighed against it. check_status: "" | "valid" | "risky" | "invalid" | "unknown"; - checked_at?: string | null; + checked_at?: Date | null; // Set while a re-check a member asked for waits to run. - requested_at?: string | null; + requested_at?: Date | null; } export default interface ContactDetail extends Contact { @@ -100,5 +100,5 @@ export default interface ContactDetail extends Contact { // First-touch attribution; never changes after creation. source: ContactSource; source_detail: string; - first_seen_at: string; + first_seen_at: Date; } diff --git a/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts b/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts index 0d64f2cb1..25e34fbd8 100644 --- a/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts +++ b/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts @@ -27,7 +27,7 @@ export interface ContactPageHit { id: string; visitor_id: string; session_key: string; - occurred_at: string; + occurred_at: Date; url: string; path: string; title: string; @@ -86,7 +86,7 @@ export interface EngagementOrigin { export default interface ContactTimelineEvent { type: ContactTimelineEventType; - at: string; + at: Date; // Engagement classification (email_opened / email_clicked): true when an // automated fetcher (mail privacy proxy, security gateway) did it rather @@ -120,7 +120,7 @@ export default interface ContactTimelineEvent { content?: string | null; // Meeting events (meeting_booked / rescheduled / canceled). - scheduled_for?: string | null; + scheduled_for?: Date | null; join_url?: string | null; meeting_state?: string | null; diff --git a/web/src/lib/api/models/app/contacts/Research.ts b/web/src/lib/api/models/app/contacts/Research.ts index 71c8b2fb5..15544730c 100644 --- a/web/src/lib/api/models/app/contacts/Research.ts +++ b/web/src/lib/api/models/app/contacts/Research.ts @@ -60,6 +60,6 @@ export interface ContactResearchRun { credits_charged: number; model_used: string; tokens_used: number; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } diff --git a/web/src/lib/api/models/app/crm/CRMTask.ts b/web/src/lib/api/models/app/crm/CRMTask.ts index accd14c8c..f8d103e81 100644 --- a/web/src/lib/api/models/app/crm/CRMTask.ts +++ b/web/src/lib/api/models/app/crm/CRMTask.ts @@ -11,16 +11,19 @@ export default interface CRMTask { created_by: string; title: string; description?: string; - due_date?: string; + due_date?: Date; priority: CRMTaskPriority; // The task type's name (user-managed; empty = no type). type: string; status: CRMTaskStatus; - completed_at?: string; - created_at: string; - updated_at: string; + completed_at?: Date; + created_at: Date; + updated_at: Date; } +// Create/update body: due_date goes out as the RFC3339 string the form builds. +export type CRMTaskWrite = Partial> & { due_date?: string }; + export interface CRMTasksResult { data: CRMTask[]; pagination: { diff --git a/web/src/lib/api/models/app/crm/Deal.ts b/web/src/lib/api/models/app/crm/Deal.ts index 5e40aa8d0..5a25ba01d 100644 --- a/web/src/lib/api/models/app/crm/Deal.ts +++ b/web/src/lib/api/models/app/crm/Deal.ts @@ -10,17 +10,17 @@ export default interface Deal { value?: number; currency: string; status: DealStatus; - expected_close_date?: string; - won_at?: string; - lost_at?: string; + expected_close_date?: Date; + won_at?: Date; + lost_at?: Date; lost_reason?: string; assigned_to?: string; // Attribution: the campaign + sender mailbox that produced the originating // reply. Nullable; editable. Lets won revenue trace back to outreach. campaign_id?: string; source_mailbox_id?: string; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; // Optional joined fields the API may return contact?: { @@ -39,6 +39,9 @@ export default interface Deal { campaign_name?: string; } +// Create/update body: expected_close_date goes out as the RFC3339 string the form builds. +export type DealWrite = Partial> & { expected_close_date?: string }; + export interface DealsResult { data: Deal[]; pagination: { diff --git a/web/src/lib/api/models/app/crm/Pipeline.ts b/web/src/lib/api/models/app/crm/Pipeline.ts index 434187a08..05511da7d 100644 --- a/web/src/lib/api/models/app/crm/Pipeline.ts +++ b/web/src/lib/api/models/app/crm/Pipeline.ts @@ -5,8 +5,8 @@ export interface Stage { color: string; position: number; deal_count?: number; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } export default interface Pipeline { @@ -15,6 +15,6 @@ export default interface Pipeline { name: string; position: number; stages: Stage[]; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } diff --git a/web/src/lib/api/models/app/crm/TaskType.ts b/web/src/lib/api/models/app/crm/TaskType.ts index 8963e92fe..c7df852ed 100644 --- a/web/src/lib/api/models/app/crm/TaskType.ts +++ b/web/src/lib/api/models/app/crm/TaskType.ts @@ -6,6 +6,6 @@ export default interface TaskType { name: string; color: string; position: number; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } diff --git a/web/src/lib/api/models/app/emails/SendIdentity.ts b/web/src/lib/api/models/app/emails/SendIdentity.ts index fb7f45b2d..020d74653 100644 --- a/web/src/lib/api/models/app/emails/SendIdentity.ts +++ b/web/src/lib/api/models/app/emails/SendIdentity.ts @@ -22,7 +22,7 @@ export default interface SendIdentity { /** Empty means the mailbox's own address. */ send_as_email: string; identities: SendAsIdentity[]; - synced_at?: string | null; + synced_at?: Date | null; signature_source: "manual" | "provider"; - signature_imported_at?: string | null; + signature_imported_at?: Date | null; } diff --git a/web/src/lib/api/models/app/emails/SendingBehavior.ts b/web/src/lib/api/models/app/emails/SendingBehavior.ts index d3dd56518..eb4c52632 100644 --- a/web/src/lib/api/models/app/emails/SendingBehavior.ts +++ b/web/src/lib/api/models/app/emails/SendingBehavior.ts @@ -33,8 +33,8 @@ export default interface SendingBehavior { timezone?: string; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } export type SendingBehaviorPatch = Partial>; @@ -52,7 +52,7 @@ export interface DailyPlan { lunch_end_minute: number | null; gap_min_seconds: number; gap_max_seconds: number; - created_at: string; + created_at: Date; } export interface DailyPlanView extends DailyPlan { diff --git a/web/src/lib/api/models/app/emails/SyncState.ts b/web/src/lib/api/models/app/emails/SyncState.ts index fd0accda5..9c7e76b70 100644 --- a/web/src/lib/api/models/app/emails/SyncState.ts +++ b/web/src/lib/api/models/app/emails/SyncState.ts @@ -13,10 +13,10 @@ export type SyncThrottleReason = export interface SyncState { backfill_status: SyncBackfillStatus; backfill_synced: number; - backfill_since?: string; - backfill_started_at?: string; - backfill_completed_at?: string; - throttled_until?: string; + backfill_since?: Date; + backfill_started_at?: Date; + backfill_completed_at?: Date; + throttled_until?: Date; throttle_reason?: SyncThrottleReason | ""; // Live messages seen on the server but waiting on budget. deferred: number; @@ -24,7 +24,7 @@ export interface SyncState { folders_skipped_cap?: number; /** Folders left out because the mail server listed their name more than once. */ folders_skipped_conflict?: number; - last_synced_at?: string; + last_synced_at?: Date; } export interface SyncPolicy { diff --git a/web/src/lib/api/models/app/emails/WarmupBanStatus.ts b/web/src/lib/api/models/app/emails/WarmupBanStatus.ts index 4a470a500..054e24249 100644 --- a/web/src/lib/api/models/app/emails/WarmupBanStatus.ts +++ b/web/src/lib/api/models/app/emails/WarmupBanStatus.ts @@ -7,8 +7,8 @@ export default interface WarmupBanStatus { blocked: boolean; health_state: string; reason: string; - blocked_at: string | null; - blocked_until: string | null; + blocked_at: Date | null; + blocked_until: Date | null; can_appeal: boolean; pending_appeal: boolean; } diff --git a/web/src/lib/api/models/app/forms/FormStats.ts b/web/src/lib/api/models/app/forms/FormStats.ts index b70930b80..d6e1682f0 100644 --- a/web/src/lib/api/models/app/forms/FormStats.ts +++ b/web/src/lib/api/models/app/forms/FormStats.ts @@ -36,7 +36,7 @@ export interface FormIdentifiedVisitor { contact_id: string; name: string; email: string; - last_seen: string; + last_seen: Date; furthest_page: number; completed: boolean; /** Campaign whose email brought this contact here, when known. */ diff --git a/web/src/lib/api/models/app/forms/FormsDomain.ts b/web/src/lib/api/models/app/forms/FormsDomain.ts index 514b8c47a..9bbd93afe 100644 --- a/web/src/lib/api/models/app/forms/FormsDomain.ts +++ b/web/src/lib/api/models/app/forms/FormsDomain.ts @@ -4,7 +4,7 @@ export default interface FormsDomainStatus { forms_domain: string; forms_domain_verified: boolean; - forms_domain_verified_at?: string; + forms_domain_verified_at?: Date; /** The value to put in the CNAME: this install's forms host. */ cname_target: string; /** verified | unset | no_target | not_found | wrong_target | lookup_error | pending */ diff --git a/web/src/lib/api/models/app/inboxtag/InboxTagReview.ts b/web/src/lib/api/models/app/inboxtag/InboxTagReview.ts index d05f95337..8f8310500 100644 --- a/web/src/lib/api/models/app/inboxtag/InboxTagReview.ts +++ b/web/src/lib/api/models/app/inboxtag/InboxTagReview.ts @@ -30,7 +30,7 @@ export interface InboxTagRow { answers: Record; model: string; input_tokens: number; - created_at: string; + created_at: Date; } export default interface InboxTagReview { diff --git a/web/src/lib/api/models/app/integrations/Integration.ts b/web/src/lib/api/models/app/integrations/Integration.ts index 158ff7e07..405f9b8e3 100644 --- a/web/src/lib/api/models/app/integrations/Integration.ts +++ b/web/src/lib/api/models/app/integrations/Integration.ts @@ -121,7 +121,7 @@ export interface IntegrationFieldMapping { transform: string; static_value: string; is_default: boolean; - created_at: string; + created_at: Date; } export interface IntegrationConnection { @@ -136,15 +136,15 @@ export interface IntegrationConnection { external_account_id?: string; external_account_name?: string; granted_scopes?: string[]; - token_expires_at?: string | null; + token_expires_at?: Date | null; health: IntegrationHealth; health_detail?: string | null; - health_checked_at?: string | null; - last_synced_at?: string | null; + health_checked_at?: Date | null; + last_synced_at?: Date | null; last_error?: string | null; - last_error_at?: string | null; - created_at: string; - updated_at: string; + last_error_at?: Date | null; + created_at: Date; + updated_at: Date; /** Per-connection onboarding/capability snapshot (selected use-cases, picker * selections, scheduling_url for meeting providers). */ @@ -187,8 +187,8 @@ export interface IntegrationEventSubscription { action: IntegrationAction; config: Record; enabled: boolean; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } export interface IntegrationSyncRun { @@ -199,8 +199,8 @@ export interface IntegrationSyncRun { status: "running" | "success" | "error"; detail: string; records_processed: number; - started_at: string; - finished_at?: string | null; + started_at: Date; + finished_at?: Date | null; } export interface IntegrationOAuthStartResponse { @@ -226,8 +226,8 @@ export interface MeetingBooking { invitee_name: string; event_name: string; event_type?: string; - scheduled_for?: string; - end_time?: string; + scheduled_for?: Date; + end_time?: Date; join_url?: string; location?: string; cancel_url?: string; @@ -236,8 +236,8 @@ export interface MeetingBooking { contact_id?: string; campaign_id?: string; contact_name?: string; - created_at: string; - updated_at?: string; + created_at: Date; + updated_at?: Date; } export interface MeetingsSummary { diff --git a/web/src/lib/api/models/app/leadsync/LeadSync.ts b/web/src/lib/api/models/app/leadsync/LeadSync.ts index 558a22d65..c36066281 100644 --- a/web/src/lib/api/models/app/leadsync/LeadSync.ts +++ b/web/src/lib/api/models/app/leadsync/LeadSync.ts @@ -48,11 +48,11 @@ export interface LeadSyncSource { subscribed_default: boolean; label?: string; status: LeadSyncStatus; - last_synced_at?: string; + last_synced_at?: Date; last_result?: ImportResult; last_error?: string; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } // Result of POST /sources/:id/sync. diff --git a/web/src/lib/api/models/app/mcp/MCPServer.ts b/web/src/lib/api/models/app/mcp/MCPServer.ts index b7a37ec1b..b21180c96 100644 --- a/web/src/lib/api/models/app/mcp/MCPServer.ts +++ b/web/src/lib/api/models/app/mcp/MCPServer.ts @@ -13,8 +13,8 @@ export interface MCPServer { enabled: boolean; discovered_tools: MCPTool[]; last_error?: string; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } export interface CreateMCPServer { diff --git a/web/src/lib/api/models/app/notifications/Notification.ts b/web/src/lib/api/models/app/notifications/Notification.ts index 1f72e0898..bd9266eb9 100644 --- a/web/src/lib/api/models/app/notifications/Notification.ts +++ b/web/src/lib/api/models/app/notifications/Notification.ts @@ -97,6 +97,6 @@ export interface AppNotification { body?: string; link?: string; metadata?: Record; - read_at?: string | null; - created_at: string; + read_at?: Date | null; + created_at: Date; } diff --git a/web/src/lib/api/models/app/oauth/OAuthApp.ts b/web/src/lib/api/models/app/oauth/OAuthApp.ts index b90296e9d..a65d7ee9e 100644 --- a/web/src/lib/api/models/app/oauth/OAuthApp.ts +++ b/web/src/lib/api/models/app/oauth/OAuthApp.ts @@ -24,8 +24,8 @@ export interface OAuthApplication { // Bitmask of the API permissions this app may request (same bits as API keys). scopes: number; status: OAuthAppStatus; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } // Returned once on create / secret rotation; client_secret is shown a single time. @@ -68,8 +68,8 @@ export interface OAuthAuthorizedApp { logo_url: string; website_url: string; scopes: number; - authorized_at: string; - last_used_at?: string; + authorized_at: Date; + last_used_at?: Date; } export interface OAuthAuthorizedAppsResult { diff --git a/web/src/lib/api/models/app/organizations/LimitIncreaseRequest.ts b/web/src/lib/api/models/app/organizations/LimitIncreaseRequest.ts index ef9111639..4f683ebbf 100644 --- a/web/src/lib/api/models/app/organizations/LimitIncreaseRequest.ts +++ b/web/src/lib/api/models/app/organizations/LimitIncreaseRequest.ts @@ -21,9 +21,9 @@ export default interface LimitIncreaseRequest { reason: string; status: LimitRequestStatus; submitted_by: string; - submitted_at: string; + submitted_at: Date; reviewed_by?: string | null; - reviewed_at?: string | null; + reviewed_at?: Date | null; review_notes: string; } diff --git a/web/src/lib/api/models/app/organizations/OrganizationRole.ts b/web/src/lib/api/models/app/organizations/OrganizationRole.ts index 888314354..90f5a51a6 100644 --- a/web/src/lib/api/models/app/organizations/OrganizationRole.ts +++ b/web/src/lib/api/models/app/organizations/OrganizationRole.ts @@ -8,6 +8,6 @@ export default interface OrganizationRole { color: string; permissions: number; member_count: number; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } diff --git a/web/src/lib/api/models/app/segments/Segment.ts b/web/src/lib/api/models/app/segments/Segment.ts index 06558792e..70b97b901 100644 --- a/web/src/lib/api/models/app/segments/Segment.ts +++ b/web/src/lib/api/models/app/segments/Segment.ts @@ -96,7 +96,7 @@ export interface CampaignSegmentLink { contact_count: number; lead_count: number; held_out_count: number; - linked_at: string; + linked_at: Date; } // Operators per field kind, mirrored from the backend catalog. diff --git a/web/src/lib/api/models/app/skills/Skill.ts b/web/src/lib/api/models/app/skills/Skill.ts index cbd36d926..20e61fe90 100644 --- a/web/src/lib/api/models/app/skills/Skill.ts +++ b/web/src/lib/api/models/app/skills/Skill.ts @@ -5,8 +5,8 @@ export interface AISkill { description: string; content: string; enabled: boolean; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } export interface CreateAISkill { diff --git a/web/src/lib/api/models/app/subscription/Credits.ts b/web/src/lib/api/models/app/subscription/Credits.ts index 73f6ddedc..82e877a3e 100644 --- a/web/src/lib/api/models/app/subscription/Credits.ts +++ b/web/src/lib/api/models/app/subscription/Credits.ts @@ -21,9 +21,9 @@ export interface CreditBalance { // Lifetime purchased credits. total_purchased: number; // When the monthly pool was last reset. - monthly_reset_at: string; + monthly_reset_at: Date; // When the monthly pool next resets (subscription period end), if any. - next_reset_at: string | null; + next_reset_at: Date | null; packs: CreditPack[]; } @@ -43,7 +43,7 @@ export interface CreditTransaction { // what ran (campaign/step/contact, automation/node/run, thread, session). actor_user_id?: string | null; context?: CreditContext; - created_at: string; + created_at: Date; } export interface CreditTransactionsPage { @@ -66,7 +66,7 @@ export interface AISpendSettings { member_limit_weekly: number | null; member_limit_monthly: number | null; low_balance_threshold: number; - low_balance_notified_at?: string | null; + low_balance_notified_at?: Date | null; auto_topup_enabled: boolean; auto_topup_pack: string; auto_topup_threshold: number; diff --git a/web/src/lib/api/models/app/subscription/DiscountRedemption.ts b/web/src/lib/api/models/app/subscription/DiscountRedemption.ts index 2de68e808..988d429f0 100644 --- a/web/src/lib/api/models/app/subscription/DiscountRedemption.ts +++ b/web/src/lib/api/models/app/subscription/DiscountRedemption.ts @@ -9,8 +9,8 @@ export interface DiscountRedemption { currency?: string | null; trial_extension_days?: number | null; status: string; - redeemed_at: string; - applied_at?: string | null; + redeemed_at: Date; + applied_at?: Date | null; } export interface DiscountRedemptionsResult { diff --git a/web/src/lib/api/models/app/subscription/Referral.ts b/web/src/lib/api/models/app/subscription/Referral.ts index b62887c39..899e84ac0 100644 --- a/web/src/lib/api/models/app/subscription/Referral.ts +++ b/web/src/lib/api/models/app/subscription/Referral.ts @@ -24,8 +24,8 @@ export interface ReferralCode { owner_user_id: string; owner_org_id: string; discount_code_id: string; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } export type ReferralAttributionStatus = "pending" | "qualified" | "rewarded" | "void"; @@ -36,9 +36,9 @@ export interface ReferralAttribution { reward_cents: number; reward_currency: string; invitee_org_id: string; - qualified_at?: string; - rewarded_at?: string; - created_at: string; + qualified_at?: Date; + rewarded_at?: Date; + created_at: Date; } // Positive amount = reward, negative = clawback. @@ -48,7 +48,7 @@ export interface ReferralEarningsTransaction { currency: string; reason: string; balance_after_cents: number; - created_at: string; + created_at: Date; } export interface ReferralPagination { diff --git a/web/src/lib/api/models/app/suppressions/Suppression.ts b/web/src/lib/api/models/app/suppressions/Suppression.ts index e67c3fbd1..497eea493 100644 --- a/web/src/lib/api/models/app/suppressions/Suppression.ts +++ b/web/src/lib/api/models/app/suppressions/Suppression.ts @@ -11,10 +11,10 @@ export default interface Suppression { reason: string; source: SuppressionSource | string; campaign_id?: string | null; - expires_at?: string | null; + expires_at?: Date | null; metadata?: Record; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } export interface SuppressionListResult { diff --git a/web/src/lib/api/models/app/teams/Team.ts b/web/src/lib/api/models/app/teams/Team.ts index ddd8947b6..e55504786 100644 --- a/web/src/lib/api/models/app/teams/Team.ts +++ b/web/src/lib/api/models/app/teams/Team.ts @@ -5,7 +5,7 @@ export interface TeamMember { user_id: string; email: string; name: string; - added_at: string; + added_at: Date; } export default interface Team { @@ -13,7 +13,7 @@ export default interface Team { organization_id: string; name: string; color: string; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; members: TeamMember[]; } diff --git a/web/src/lib/api/models/app/unibox/Compose.ts b/web/src/lib/api/models/app/unibox/Compose.ts index 20eb2f782..2bb1f5d7b 100644 --- a/web/src/lib/api/models/app/unibox/Compose.ts +++ b/web/src/lib/api/models/app/unibox/Compose.ts @@ -17,7 +17,7 @@ export interface ComposeCandidate { /** Prior messages exchanged with the recipient from this mailbox. */ history_messages: number; /** RFC3339; omitted when this mailbox never contacted the recipient. */ - last_contact_at?: string | null; + last_contact_at?: Date | null; score: number; /** Human-readable scoring signals ("has history", "low load today", ...). */ reasons: string[]; @@ -55,7 +55,7 @@ export interface ComposeSendInput { export interface ComposeSendResponse { task_id: string; - scheduled_at: string; + scheduled_at: Date; send_mode: string; account_id: string; account_email: string; diff --git a/web/src/lib/api/models/app/unibox/UniboxOverview.ts b/web/src/lib/api/models/app/unibox/UniboxOverview.ts index a52994ab2..b4d47446a 100644 --- a/web/src/lib/api/models/app/unibox/UniboxOverview.ts +++ b/web/src/lib/api/models/app/unibox/UniboxOverview.ts @@ -60,7 +60,7 @@ export default interface UniboxOverview { mailboxes: UniboxMailboxOverview[]; tags: UniboxTagOverview[]; categories: UniboxCategoryOverview[]; - generated_at: string; - window_today_start: string; - window_week_start: string; + generated_at: Date; + window_today_start: Date; + window_week_start: Date; } diff --git a/web/src/lib/api/models/app/unibox/UniboxScheduled.ts b/web/src/lib/api/models/app/unibox/UniboxScheduled.ts index 184a1d3c4..e30e13072 100644 --- a/web/src/lib/api/models/app/unibox/UniboxScheduled.ts +++ b/web/src/lib/api/models/app/unibox/UniboxScheduled.ts @@ -3,8 +3,8 @@ export default interface UniboxScheduledItem { task_id: string - scheduled_at: string - created_at: string + scheduled_at: Date + created_at: Date account_id: string account_email: string diff --git a/web/src/lib/api/models/app/unibox/UniboxThread.ts b/web/src/lib/api/models/app/unibox/UniboxThread.ts index d3230e29f..0de62af37 100644 --- a/web/src/lib/api/models/app/unibox/UniboxThread.ts +++ b/web/src/lib/api/models/app/unibox/UniboxThread.ts @@ -12,7 +12,7 @@ export interface UniboxThreadMessage { to_addr: string[] subject: string snippet: string - internal_date: string + internal_date: Date seen: boolean } diff --git a/web/src/lib/api/models/app/views/ViewPreferences.ts b/web/src/lib/api/models/app/views/ViewPreferences.ts index ae3e48527..f3dd516e4 100644 --- a/web/src/lib/api/models/app/views/ViewPreferences.ts +++ b/web/src/lib/api/models/app/views/ViewPreferences.ts @@ -15,7 +15,7 @@ export interface ViewPreferences { columns: string[]; // Null or absent means the list's default sort. sort?: ViewSort | null; - updated_at?: string | null; + updated_at?: Date | null; } export interface ViewPreferencesEnvelope { diff --git a/web/src/lib/api/models/app/webhooks/Webhook.ts b/web/src/lib/api/models/app/webhooks/Webhook.ts index 4ef82a748..a1b7f79af 100644 --- a/web/src/lib/api/models/app/webhooks/Webhook.ts +++ b/web/src/lib/api/models/app/webhooks/Webhook.ts @@ -18,18 +18,18 @@ export interface WebhookEndpoint { description: string; event_types: string[]; enabled: boolean; - last_success_at?: string; - last_failure_at?: string; + last_success_at?: Date; + last_failure_at?: Date; last_failure_reason?: string; consecutive_failures: number; oauth_application_id?: string; created_by?: string; - verified_at?: string; + verified_at?: Date; ownership_confirmed: boolean; - auto_disabled_at?: string; + auto_disabled_at?: Date; disabled_reason?: string; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } // Returned once on create; secret is shown a single time and never again. @@ -56,21 +56,21 @@ export interface WebhookDelivery { status: WebhookDeliveryStatus; attempt_count: number; max_attempts: number; - next_attempt_at: string; - last_attempt_at?: string; + next_attempt_at: Date; + last_attempt_at?: Date; response_status?: number; response_body_excerpt?: string; error_reason?: string; - created_at: string; - updated_at: string; + created_at: Date; + updated_at: Date; } // A high-volume event family that was throttled (dropped windows) for a day. export interface WebhookEventDrop { event_type: string; - day: string; + day: Date; dropped_windows: number; - last_dropped_at: string; + last_dropped_at: Date; } // GET /webhooks diff --git a/web/src/lib/api/models/app/websitetracking/WebsiteTrackingSettings.ts b/web/src/lib/api/models/app/websitetracking/WebsiteTrackingSettings.ts index 12650853d..ef75a5465 100644 --- a/web/src/lib/api/models/app/websitetracking/WebsiteTrackingSettings.ts +++ b/web/src/lib/api/models/app/websitetracking/WebsiteTrackingSettings.ts @@ -9,7 +9,7 @@ export interface WebsiteTrackingSettings { location_precision: WebsiteLocationPrecision; allowed_hosts: string[]; retention_days: number; - updated_at: string; + updated_at: Date; // The deployment's tracking host; empty when the install has none. tracking_host: string; } diff --git a/web/src/lib/helper/dueDate.ts b/web/src/lib/helper/dueDate.ts index 01c5fcb62..3232ea97d 100644 --- a/web/src/lib/helper/dueDate.ts +++ b/web/src/lib/helper/dueDate.ts @@ -12,7 +12,7 @@ export function dueInDaysToISO(days: number): string { // Convert an existing absolute due date back into a whole-day offset from // today (used to seed the control when editing a task). Negative = overdue. -export function isoToDueInDays(iso?: string): number | null { +export function isoToDueInDays(iso?: string | Date): number | null { if (!iso) return null; const due = new Date(iso); if (Number.isNaN(due.getTime())) return null; diff --git a/web/src/lib/helper/reviveDates.test.ts b/web/src/lib/helper/reviveDates.test.ts new file mode 100644 index 000000000..4b139a958 --- /dev/null +++ b/web/src/lib/helper/reviveDates.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import reviveDates from "./reviveDates"; + +describe("reviveDates", () => { + it("revives the timestamps Go writes, with and without fractions or an offset", () => { + const r = reviveDates({ + a: "2026-09-27T11:53:26Z", + b: "2026-09-27T11:53:26.123456789Z", + c: "2026-09-27T13:53:26+02:00", + }) as unknown as Record; + expect(r.a).toBeInstanceOf(Date); + expect(r.b.toISOString()).toBe("2026-09-27T11:53:26.123Z"); + expect(r.c.toISOString()).toBe("2026-09-27T11:53:26.000Z"); + }); + + it("leaves text that only starts like a timestamp as text", () => { + const subject = "2026-09-27T03:00Z nightly backup failed"; + const r = reviveDates({ subject, snippet: "2026-09-27T03:00:00Z: disk full", local: "2026-09-27T10:00" }); + expect(r).toEqual({ subject, snippet: "2026-09-27T03:00:00Z: disk full", local: "2026-09-27T10:00" }); + }); + + it("keeps a day string and a custom field value as the string they are", () => { + const r = reviveDates({ date: "2026-09-27", custom_fields: { renewal: "2026-09-27T00:00:00Z" } }); + expect(r).toEqual({ date: "2026-09-27", custom_fields: { renewal: "2026-09-27T00:00:00Z" } }); + }); + + it("revives inside arrays and nested objects", () => { + const r = reviveDates({ data: [{ created_at: "2026-09-27T00:00:00Z" }] }) as unknown as { data: { created_at: Date }[] }; + expect(r.data[0].created_at).toBeInstanceOf(Date); + }); +}); diff --git a/web/src/lib/helper/reviveDates.ts b/web/src/lib/helper/reviveDates.ts index 15c0d77aa..e1b5e1505 100644 --- a/web/src/lib/helper/reviveDates.ts +++ b/web/src/lib/helper/reviveDates.ts @@ -1,7 +1,14 @@ +// A whole RFC3339 timestamp, the shape Go writes a time.Time as. Anchored at both +// ends so free text that merely starts with a date (a subject, a snippet) stays text. +const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/ + +// User-authored string maps: a value that looks like a timestamp is still the text someone typed. +const VERBATIM_KEYS = new Set(["custom_fields"]) + export default function reviveDates(obj: T): T { if (obj === null || obj === undefined) return obj - if (typeof obj === "string" && /^\d{4}-\d{2}-\d{2}T/.test(obj)) { + if (typeof obj === "string" && RFC3339.test(obj)) { return new Date(obj) as unknown as T } @@ -15,7 +22,7 @@ export default function reviveDates(obj: T): T { if (prototype !== Object.prototype && prototype !== null) return obj const entries = Object.entries(obj as Record).map( - ([key, value]) => [key, reviveDates(value)] + ([key, value]) => [key, VERBATIM_KEYS.has(key) ? value : reviveDates(value)] ) return Object.fromEntries(entries) as T } diff --git a/web/src/lib/helper/shareDeep.test.ts b/web/src/lib/helper/shareDeep.test.ts new file mode 100644 index 000000000..a97600874 --- /dev/null +++ b/web/src/lib/helper/shareDeep.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import reviveDates from "./reviveDates"; +import shareDeep from "./shareDeep"; + +const wire = () => reviveDates({ id: "c1", name: "Q4", updated_at: "2026-09-27T11:53:26Z", tags: ["a"], range: { from: "2026-09-01T00:00:00Z" } }); + +describe("shareDeep", () => { + it("keeps the previous object when a refetch returns the same data", () => { + const prev = wire(); + expect(shareDeep(prev, wire())).toBe(prev); + }); + + it("gives a changed value a new object and keeps the unchanged branches", () => { + const prev = wire(); + const next = { ...wire(), name: "Q1" }; + const shared = shareDeep(prev, next) as typeof prev; + expect(shared).not.toBe(prev); + expect(shared.name).toBe("Q1"); + expect(shared.updated_at).toBe(prev.updated_at); + expect(shared.tags).toBe(prev.tags); + expect(shared.range).toBe(prev.range); + }); + + it("hands out the new Date when the time moved", () => { + const prev = wire(); + const next = reviveDates({ ...prev, updated_at: "2026-09-27T12:00:00Z" }); + const shared = shareDeep(prev, next) as typeof prev; + expect(shared).not.toBe(prev); + expect(shared.updated_at).toBe(next.updated_at); + }); + + it("notices a key that was added or removed", () => { + expect(shareDeep({ a: 1 }, { a: 1, b: undefined })).toEqual({ a: 1, b: undefined }); + expect(shareDeep({ a: 1, b: 2 }, { a: 1 })).toEqual({ a: 1 }); + expect(shareDeep([1, 2], [1])).toEqual([1]); + }); +}); diff --git a/web/src/lib/helper/shareDeep.ts b/web/src/lib/helper/shareDeep.ts new file mode 100644 index 000000000..c0081c373 --- /dev/null +++ b/web/src/lib/helper/shareDeep.ts @@ -0,0 +1,32 @@ +// React Query's structural sharing, extended to Dates: the default never reuses one, so every +// refetch gave unchanged data a new identity and reset the effects keyed on it. +export default function shareDeep(prev: unknown, next: unknown): unknown { + if (prev === next) return prev; + + if (prev instanceof Date && next instanceof Date) { + return Object.is(prev.getTime(), next.getTime()) ? prev : next; + } + + const arrays = Array.isArray(prev) && Array.isArray(next); + if (!arrays && !(isPlain(prev) && isPlain(next))) return next; + + const before = prev as Record; + const after = next as Record; + const keys = Object.keys(after); + const out: Record = arrays ? ([] as unknown as Record) : {}; + let same = Object.keys(before).length === keys.length; + + for (const key of keys) { + const value = shareDeep(before[key], after[key]); + out[key] = value; + if (value !== before[key] || !(key in before)) same = false; + } + + return same ? prev : out; +} + +function isPlain(value: unknown): value is Record { + if (value === null || typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/web/src/lib/leadHold.test.ts b/web/src/lib/leadHold.test.ts index cf87da835..99a0d8026 100644 --- a/web/src/lib/leadHold.test.ts +++ b/web/src/lib/leadHold.test.ts @@ -34,7 +34,7 @@ describe("leadCanBePaused", () => { }); it("refuses a lead that is already held", () => { expect( - leadCanBePaused(state({ lead_status: "paused", hold: { since: "2026-09-01T00:00:00Z", source: "manual" } })), + leadCanBePaused(state({ lead_status: "paused", hold: { since: new Date("2026-09-01T00:00:00Z"), source: "manual" } })), ).toBe(false); }); it("refuses ended leads, finished campaigns and an unknown next step", () => { diff --git a/web/src/main.tsx b/web/src/main.tsx index 57447bdd4..23d576665 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -102,6 +102,7 @@ import CloudOAuthDonePage from './app/cloud-oauth/done/page'; import WarmblyCloudSettingsPage from './app/app/settings/warmbly-cloud/page'; import SetupPage from './app/setup/page'; import SSOCallbackPage from './app/auth/sso/page'; +import shareDeep from './lib/helper/shareDeep'; // React-Query defaults tuned for a dashboard. The library's // out-of-the-box behaviour treats every query as immediately stale @@ -119,6 +120,8 @@ import SSOCallbackPage from './app/auth/sso/page'; // comes back, do refresh once. // - retry: 1 — react-query's default of 3 turns a 500ms backend // hiccup into ~5s of stacked retries on the user. +// - structuralSharing: shareDeep — keeps equal revived Dates, so +// unchanged data keeps its identity across a refetch. const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -127,6 +130,7 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: false, refetchOnReconnect: "always", retry: 1, + structuralSharing: shareDeep, }, mutations: { retry: 0, diff --git a/web/vite.config.ts b/web/vite.config.ts index 53a7458b7..08bbe8471 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,9 +1,12 @@ -import { defineConfig } from "vite"; +if (!process.env.VITE_SENTRY_RELEASE && process.env.CF_PAGES_COMMIT_SHA) process.env.VITE_SENTRY_RELEASE = process.env.CF_PAGES_COMMIT_SHA;import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import path from "path"; import tailwindcss from "@tailwindcss/vite"; import { sentryVitePlugin } from "@sentry/vite-plugin"; +// A Cloudflare Pages build sets no release, so its events are tagged with the commit it built. +if (!process.env.VITE_SENTRY_RELEASE && process.env.CF_PAGES_COMMIT_SHA) process.env.VITE_SENTRY_RELEASE = process.env.CF_PAGES_COMMIT_SHA; + // Source maps, and nothing else, is what this section decides. // // Uploading them is never a required build step: a fork, a self-host build or a