diff --git a/apps/dashboard/app/(main)/monitors/[id]/page.tsx b/apps/dashboard/app/(main)/monitors/[id]/page.tsx
index 13404025c..5de3e1713 100644
--- a/apps/dashboard/app/(main)/monitors/[id]/page.tsx
+++ b/apps/dashboard/app/(main)/monitors/[id]/page.tsx
@@ -3,6 +3,7 @@
import {
ArrowClockwiseIcon,
ArrowLeftIcon,
+ BellIcon,
GlobeIcon,
HeartbeatIcon,
PauseIcon,
@@ -10,7 +11,7 @@ import {
PlayIcon,
TrashIcon,
} from "@phosphor-icons/react";
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@@ -35,8 +36,107 @@ import { orpc } from "@/lib/orpc";
import { fromNow, localDayjs } from "@/lib/time";
import { RecentActivity } from "../../websites/[id]/pulse/_components/recent-activity";
import { UptimeHeatmap } from "../../websites/[id]/pulse/_components/uptime-heatmap";
+import { Switch } from "@/components/ui/switch";
import { PageHeader } from "../_components/page-header";
+interface AlarmSummary {
+ id: string;
+ name: string;
+ enabled: boolean;
+ notificationChannels: string[];
+ triggerType: string;
+}
+
+function MonitorAlarms({
+ organizationId,
+ websiteId,
+}: {
+ organizationId: string;
+ websiteId: string | null;
+}) {
+ const queryClient = useQueryClient();
+
+ const { data: alarmsList } = useQuery({
+ ...orpc.alarms.list.queryOptions({
+ input: {
+ organizationId,
+ websiteId: websiteId ?? undefined,
+ triggerType: "uptime",
+ },
+ }),
+ });
+
+ const updateMutation = useMutation({
+ ...orpc.alarms.update.mutationOptions(),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: orpc.alarms.list.key({
+ input: { organizationId, websiteId: websiteId ?? undefined, triggerType: "uptime" },
+ }),
+ });
+ },
+ });
+
+ const alarms = (alarmsList ?? []) as AlarmSummary[];
+
+ if (alarms.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ Uptime Alarms
+
+
+ {alarms.map((alarm) => (
+
+
+
{alarm.name}
+
+ {alarm.notificationChannels.map((ch) => (
+
+ {ch}
+
+ ))}
+
+
+
+ updateMutation.mutate({
+ id: alarm.id,
+ enabled,
+ })
+ }
+ />
+
+ ))}
+
+
+ );
+}
+
const granularityLabels: Record = {
minute: "Every minute",
five_minutes: "Every 5 minutes",
@@ -398,6 +498,11 @@ export default function MonitorDetailsPage() {
+
+
| null;
+ triggerConditions: Record | null;
+ websiteId: string | null;
+ organizationId: string | null;
+}
+
+interface AlarmDialogProps {
+ alarm: Alarm | null;
+ isOpen: boolean;
+ onCloseAction: () => void;
+}
+
+const TRIGGER_TYPES = [
+ { value: "uptime", label: "Uptime" },
+ { value: "traffic_spike", label: "Traffic Spike" },
+ { value: "error_rate", label: "Error Rate" },
+ { value: "goal", label: "Goal" },
+ { value: "custom", label: "Custom" },
+] as const;
+
+const CHANNELS = [
+ { value: "slack", label: "Slack" },
+ { value: "discord", label: "Discord" },
+ { value: "email", label: "Email" },
+ { value: "webhook", label: "Webhook" },
+] as const;
+
+type TriggerType = (typeof TRIGGER_TYPES)[number]["value"];
+type Channel = (typeof CHANNELS)[number]["value"];
+
+export function AlarmDialog({ alarm, isOpen, onCloseAction }: AlarmDialogProps) {
+ const queryClient = useQueryClient();
+ const activeOrganization = useAtomValue(activeOrganizationAtom);
+ const isEditing = Boolean(alarm);
+
+ const [name, setName] = useState(alarm?.name ?? "");
+ const [description, setDescription] = useState(alarm?.description ?? "");
+ const [enabled, setEnabled] = useState(alarm?.enabled ?? true);
+ const [triggerType, setTriggerType] = useState(
+ (alarm?.triggerType as TriggerType) ?? "uptime"
+ );
+ const [channels, setChannels] = useState(
+ (alarm?.notificationChannels as Channel[]) ?? []
+ );
+ const [slackUrl, setSlackUrl] = useState(alarm?.slackWebhookUrl ?? "");
+ const [discordUrl, setDiscordUrl] = useState(alarm?.discordWebhookUrl ?? "");
+ const [emailAddresses, setEmailAddresses] = useState(
+ alarm?.emailAddresses?.join(", ") ?? ""
+ );
+ const [webhookUrl, setWebhookUrl] = useState(alarm?.webhookUrl ?? "");
+
+ // Reset form state when alarm prop changes (fixes stale data when editing different alarms)
+ useEffect(() => {
+ setName(alarm?.name ?? "");
+ setDescription(alarm?.description ?? "");
+ setEnabled(alarm?.enabled ?? true);
+ setTriggerType((alarm?.triggerType as TriggerType) ?? "uptime");
+ setChannels((alarm?.notificationChannels as Channel[]) ?? []);
+ setSlackUrl(alarm?.slackWebhookUrl ?? "");
+ setDiscordUrl(alarm?.discordWebhookUrl ?? "");
+ setEmailAddresses(alarm?.emailAddresses?.join(", ") ?? "");
+ setWebhookUrl(alarm?.webhookUrl ?? "");
+ }, [alarm]);
+
+ const createMutation = useMutation({
+ ...orpc.alarms.create.mutationOptions(),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: orpc.alarms.list.key({ input: {} }),
+ });
+ toast.success("Alarm created");
+ onCloseAction();
+ },
+ onError: () => {
+ toast.error("Failed to create alarm");
+ },
+ });
+
+ const updateMutation = useMutation({
+ ...orpc.alarms.update.mutationOptions(),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: orpc.alarms.list.key({ input: {} }),
+ });
+ toast.success("Alarm updated");
+ onCloseAction();
+ },
+ onError: () => {
+ toast.error("Failed to update alarm");
+ },
+ });
+
+ const toggleChannel = (channel: Channel) => {
+ setChannels((prev) =>
+ prev.includes(channel)
+ ? prev.filter((c) => c !== channel)
+ : [...prev, channel]
+ );
+ };
+
+ const handleSubmit = () => {
+ if (!name.trim()) {
+ toast.error("Name is required");
+ return;
+ }
+
+ if (channels.length === 0) {
+ toast.error("Select at least one notification channel");
+ return;
+ }
+
+ if (!activeOrganization?.id && !isEditing) {
+ toast.error("No active workspace found");
+ return;
+ }
+
+ const emails = emailAddresses
+ .split(",")
+ .map((e) => e.trim())
+ .filter(Boolean);
+
+ if (isEditing && alarm) {
+ updateMutation.mutate({
+ id: alarm.id,
+ name: name.trim(),
+ description: description.trim() || undefined,
+ enabled,
+ notificationChannels: channels,
+ triggerType,
+ slackWebhookUrl: slackUrl.trim() || null,
+ discordWebhookUrl: discordUrl.trim() || null,
+ emailAddresses: emails.length > 0 ? emails : null,
+ webhookUrl: webhookUrl.trim() || null,
+ });
+ } else {
+ createMutation.mutate({
+ organizationId: activeOrganization?.id ?? "",
+ name: name.trim(),
+ description: description.trim() || undefined,
+ enabled,
+ notificationChannels: channels,
+ triggerType,
+ slackWebhookUrl: slackUrl.trim() || undefined,
+ discordWebhookUrl: discordUrl.trim() || undefined,
+ emailAddresses: emails.length > 0 ? emails : undefined,
+ webhookUrl: webhookUrl.trim() || undefined,
+ });
+ }
+ };
+
+ const isLoading = createMutation.isPending || updateMutation.isPending;
+
+ return (
+
+ );
+}
diff --git a/apps/dashboard/app/(main)/settings/notifications/page.tsx b/apps/dashboard/app/(main)/settings/notifications/page.tsx
index cbc5a65b4..72cb20c10 100644
--- a/apps/dashboard/app/(main)/settings/notifications/page.tsx
+++ b/apps/dashboard/app/(main)/settings/notifications/page.tsx
@@ -1,39 +1,362 @@
"use client";
-import { BellIcon } from "@phosphor-icons/react";
+import {
+ BellIcon,
+ PlusIcon,
+ SpinnerGapIcon,
+ TrashIcon,
+ PencilSimpleIcon,
+ TestTubeIcon,
+ CheckCircleIcon,
+ XCircleIcon,
+} from "@phosphor-icons/react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { toast } from "sonner";
import { RightSidebar } from "@/components/right-sidebar";
-import { ComingSoon } from "../_components/settings-section";
+import { EmptyState } from "@/components/empty-state";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { DeleteDialog } from "@/components/ui/delete-dialog";
+import { Switch } from "@/components/ui/switch";
+import { orpc } from "@/lib/orpc";
+import { cn } from "@/lib/utils";
+import { AlarmDialog } from "./_components/alarm-dialog";
+
+interface Alarm {
+ id: string;
+ name: string;
+ description: string | null;
+ enabled: boolean;
+ notificationChannels: string[];
+ triggerType: string;
+ slackWebhookUrl: string | null;
+ discordWebhookUrl: string | null;
+ emailAddresses: string[] | null;
+ webhookUrl: string | null;
+ webhookHeaders: Record | null;
+ triggerConditions: Record | null;
+ websiteId: string | null;
+ organizationId: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+const TRIGGER_LABELS: Record = {
+ uptime: "Uptime",
+ traffic_spike: "Traffic Spike",
+ error_rate: "Error Rate",
+ goal: "Goal",
+ custom: "Custom",
+};
+
+const CHANNEL_LABELS: Record = {
+ slack: "Slack",
+ discord: "Discord",
+ email: "Email",
+ webhook: "Webhook",
+};
+
+function AlarmCard({
+ alarm,
+ onEditAction,
+ onDeleteAction,
+ onTestAction,
+ onToggleAction,
+ isToggling,
+ isTesting,
+}: {
+ alarm: Alarm;
+ onEditAction: () => void;
+ onDeleteAction: () => void;
+ onTestAction: () => void;
+ onToggleAction: (enabled: boolean) => void;
+ isToggling: boolean;
+ isTesting: boolean;
+}) {
+ return (
+
+
+
+
{alarm.name}
+
+ {TRIGGER_LABELS[alarm.triggerType] ?? alarm.triggerType}
+
+
+ {alarm.description && (
+
+ {alarm.description}
+
+ )}
+
+ {alarm.notificationChannels.map((ch) => (
+
+ {CHANNEL_LABELS[ch] ?? ch}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+}
export default function NotificationsSettingsPage() {
+ const queryClient = useQueryClient();
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [editingAlarm, setEditingAlarm] = useState(null);
+ const [alarmToDelete, setAlarmToDelete] = useState(null);
+ const [togglingId, setTogglingId] = useState(null);
+ const [testingId, setTestingId] = useState(null);
+
+ const { data: alarmsList, isLoading } = useQuery({
+ ...orpc.alarms.list.queryOptions({ input: {} }),
+ });
+
+ const alarms = (alarmsList ?? []) as Alarm[];
+
+ const deleteMutation = useMutation({
+ ...orpc.alarms.delete.mutationOptions(),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: orpc.alarms.list.key({ input: {} }),
+ });
+ toast.success("Alarm deleted");
+ },
+ });
+
+ const updateMutation = useMutation({
+ ...orpc.alarms.update.mutationOptions(),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: orpc.alarms.list.key({ input: {} }),
+ });
+ },
+ });
+
+ const testMutation = useMutation({
+ ...orpc.alarms.test.mutationOptions(),
+ });
+
+ const handleCreate = () => {
+ setEditingAlarm(null);
+ setIsDialogOpen(true);
+ };
+
+ const handleEdit = (alarm: Alarm) => {
+ setEditingAlarm(alarm);
+ setIsDialogOpen(true);
+ };
+
+ const handleToggle = async (alarm: Alarm, enabled: boolean) => {
+ setTogglingId(alarm.id);
+ try {
+ await updateMutation.mutateAsync({
+ id: alarm.id,
+ enabled,
+ });
+ toast.success(`Alarm ${enabled ? "enabled" : "disabled"}`);
+ } catch {
+ toast.error("Failed to update alarm");
+ } finally {
+ setTogglingId(null);
+ }
+ };
+
+ const handleTest = async (alarm: Alarm) => {
+ setTestingId(alarm.id);
+ try {
+ const result = await testMutation.mutateAsync({ id: alarm.id });
+ const typedResult = result as {
+ success: boolean;
+ results: Array<{
+ channel: string;
+ success: boolean;
+ error?: string;
+ }>;
+ };
+ if (typedResult.success) {
+ toast.success("Test notification sent successfully");
+ } else {
+ const failedChannels = typedResult.results
+ .filter((r) => !r.success)
+ .map((r) => `${r.channel}: ${r.error ?? "failed"}`)
+ .join(", ");
+ toast.error(`Some channels failed: ${failedChannels}`);
+ }
+ } catch {
+ toast.error("Failed to send test notification");
+ } finally {
+ setTestingId(null);
+ }
+ };
+
+ const handleConfirmDelete = async () => {
+ if (alarmToDelete) {
+ await deleteMutation.mutateAsync({ id: alarmToDelete.id });
+ setAlarmToDelete(null);
+ }
+ };
+
+ const handleDialogClose = () => {
+ setIsDialogOpen(false);
+ setEditingAlarm(null);
+ };
+
return (
-
+
+
Alarms
+
+ Configure notification alarms for your websites
+
+
+
+
+
+ {isLoading ? (
+
+
- }
- title="Notifications Coming Soon"
- />
+
+ ) : alarms.length === 0 ? (
+
+ }
+ title="No alarms yet"
+ variant="minimal"
+ />
+
+ ) : (
+
+ {alarms.map((alarm) => (
+
setAlarmToDelete(alarm)}
+ onEditAction={() => handleEdit(alarm)}
+ onTestAction={() => handleTest(alarm)}
+ onToggleAction={(enabled) =>
+ handleToggle(alarm, enabled)
+ }
+ />
+ ))}
+
+ )}
-
+
-
• Traffic spike alerts
-
• Goal completion notifications
-
• Error rate warnings
-
• Weekly digest emails
+
+
+ Slack webhooks
+
+
+
+ Discord webhooks
+
+
+
+ Email (coming soon)
+
+
+
+ Custom webhooks
+
-
+
+
+ {isDialogOpen && (
+
+ )}
+
+ setAlarmToDelete(null)}
+ onConfirm={handleConfirmDelete}
+ title="Delete Alarm"
+ />
);
}
diff --git a/apps/uptime/package.json b/apps/uptime/package.json
index 204e96adc..ff19404a5 100644
--- a/apps/uptime/package.json
+++ b/apps/uptime/package.json
@@ -7,6 +7,7 @@
},
"dependencies": {
"@databuddy/db": "workspace:*",
+ "@databuddy/notifications": "workspace:*",
"@databuddy/services": "workspace:*",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.208.0",
diff --git a/apps/uptime/src/index.ts b/apps/uptime/src/index.ts
index f8f136872..d4c57d0cc 100644
--- a/apps/uptime/src/index.ts
+++ b/apps/uptime/src/index.ts
@@ -3,6 +3,7 @@ import { Elysia } from "elysia";
import { z } from "zod";
import { type CheckOptions, checkUptime, lookupSchedule } from "./actions";
import type { JsonParsingConfig } from "./json-parser";
+import { checkAndTriggerAlarms } from "./lib/alarm-trigger";
import { sendUptimeEvent } from "./lib/producer";
import {
captureError,
@@ -192,6 +193,15 @@ const app = new Elysia()
);
}
+ // Trigger alarm notifications (non-blocking)
+ checkAndTriggerAlarms(scheduleId, result.data).catch((error) => {
+ captureError(error, {
+ type: "alarm_trigger_error",
+ scheduleId,
+ monitorId,
+ });
+ });
+
return new Response("Uptime check complete", { status: 200 });
} catch (error) {
captureError(error, { type: "unexpected_error" });
diff --git a/apps/uptime/src/lib/alarm-trigger.test.ts b/apps/uptime/src/lib/alarm-trigger.test.ts
new file mode 100644
index 000000000..76add8d99
--- /dev/null
+++ b/apps/uptime/src/lib/alarm-trigger.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "bun:test";
+import { MonitorStatus } from "../types";
+import { getConsecutiveFailureThreshold } from "./alarm-trigger";
+
+describe("alarm-trigger", () => {
+ describe("getConsecutiveFailureThreshold", () => {
+ it("should default to 3 consecutive failures when no trigger conditions", () => {
+ expect(getConsecutiveFailureThreshold(null)).toBe(3);
+ expect(getConsecutiveFailureThreshold(undefined)).toBe(3);
+ });
+
+ it("should use custom threshold from trigger conditions", () => {
+ expect(getConsecutiveFailureThreshold({ consecutiveFailures: 5 })).toBe(5);
+ expect(getConsecutiveFailureThreshold({ consecutiveFailures: 1 })).toBe(1);
+ expect(getConsecutiveFailureThreshold({ consecutiveFailures: 10 })).toBe(10);
+ });
+
+ it("should default to 3 for invalid threshold values", () => {
+ expect(getConsecutiveFailureThreshold({ consecutiveFailures: -1 })).toBe(3);
+ expect(getConsecutiveFailureThreshold({ consecutiveFailures: 0 })).toBe(3);
+ expect(getConsecutiveFailureThreshold({ consecutiveFailures: "invalid" })).toBe(3);
+ expect(getConsecutiveFailureThreshold({})).toBe(3);
+ });
+
+ it("should default to 3 for non-object trigger conditions", () => {
+ expect(getConsecutiveFailureThreshold("string")).toBe(3);
+ expect(getConsecutiveFailureThreshold(42)).toBe(3);
+ expect(getConsecutiveFailureThreshold(true)).toBe(3);
+ });
+
+ it("should ignore extra fields in trigger conditions", () => {
+ expect(
+ getConsecutiveFailureThreshold({
+ consecutiveFailures: 7,
+ otherField: "ignored",
+ })
+ ).toBe(7);
+ });
+ });
+
+ describe("MonitorStatus values", () => {
+ it("should have correct status enum values", () => {
+ expect(MonitorStatus.UP).toBe(1);
+ expect(MonitorStatus.DOWN).toBe(0);
+ });
+ });
+
+ /**
+ * Note: checkAndTriggerAlarms requires database and notification service
+ * dependencies. Full integration tests for alarm matching, notification
+ * deduplication, and the website/org-level query logic should be added
+ * when a test database fixture is available.
+ *
+ * Key behaviors to verify in integration tests:
+ * - Alarms with websiteId match only their specific monitor + org-level alarms
+ * - Org-level alarms (websiteId IS NULL) fire for all monitors in the org
+ * - Monitors without websiteId only match org-level alarms
+ * - Consecutive failure threshold triggers notification at exactly the threshold
+ * - Recovery notifications fire once when site comes back up
+ * - Duplicate down notifications are suppressed after threshold is reached
+ * - Unsupported notification channels are captured via captureError
+ */
+});
diff --git a/apps/uptime/src/lib/alarm-trigger.ts b/apps/uptime/src/lib/alarm-trigger.ts
new file mode 100644
index 000000000..62aec7452
--- /dev/null
+++ b/apps/uptime/src/lib/alarm-trigger.ts
@@ -0,0 +1,192 @@
+import { alarms, and, db, eq, isNull, or, uptimeSchedules } from "@databuddy/db";
+import {
+ sendDiscordWebhook,
+ sendSlackWebhook,
+ sendWebhook,
+} from "@databuddy/notifications";
+import type { UptimeData } from "../types";
+import { MonitorStatus } from "../types";
+import { captureError } from "./tracing";
+
+interface AlarmRecord {
+ id: string;
+ name: string;
+ enabled: boolean;
+ notificationChannels: string[];
+ slackWebhookUrl: string | null;
+ discordWebhookUrl: string | null;
+ emailAddresses: string[] | null;
+ webhookUrl: string | null;
+ webhookHeaders: unknown;
+ triggerType: string;
+ triggerConditions: unknown;
+}
+
+/**
+ * Track consecutive failures per monitor to avoid duplicate notifications.
+ * Key: scheduleId, Value: { consecutiveFailures, lastNotifiedStatus }
+ */
+const monitorState = new Map<
+ string,
+ { consecutiveFailures: number; lastNotifiedStatus: number }
+>();
+
+export function getConsecutiveFailureThreshold(
+ triggerConditions: unknown
+): number {
+ if (
+ triggerConditions &&
+ typeof triggerConditions === "object" &&
+ "consecutiveFailures" in triggerConditions
+ ) {
+ const threshold = (triggerConditions as { consecutiveFailures: number })
+ .consecutiveFailures;
+ if (typeof threshold === "number" && threshold > 0) {
+ return threshold;
+ }
+ }
+ return 3;
+}
+
+async function sendAlarmNotifications(
+ alarm: AlarmRecord,
+ payload: {
+ title: string;
+ message: string;
+ priority: "low" | "normal" | "high" | "urgent";
+ metadata: Record;
+ }
+): Promise {
+ const channels = alarm.notificationChannels;
+
+ for (const channel of channels) {
+ try {
+ if (channel === "slack" && alarm.slackWebhookUrl) {
+ await sendSlackWebhook(alarm.slackWebhookUrl, payload);
+ } else if (channel === "discord" && alarm.discordWebhookUrl) {
+ await sendDiscordWebhook(alarm.discordWebhookUrl, payload);
+ } else if (channel === "webhook" && alarm.webhookUrl) {
+ await sendWebhook(alarm.webhookUrl, payload, {
+ headers:
+ (alarm.webhookHeaders as Record) ?? undefined,
+ });
+ } else {
+ captureError(new Error(`Unsupported notification channel: ${channel}`), {
+ type: "alarm_notification_error",
+ alarmId: alarm.id,
+ channel,
+ });
+ }
+ } catch (error) {
+ captureError(error, {
+ type: "alarm_notification_error",
+ alarmId: alarm.id,
+ channel,
+ });
+ }
+ }
+}
+
+/**
+ * Check and trigger alarms for a given uptime check result.
+ * Called after each uptime check completes.
+ */
+export async function checkAndTriggerAlarms(
+ scheduleId: string,
+ uptimeData: UptimeData
+): Promise {
+ try {
+ const schedule = await db.query.uptimeSchedules.findFirst({
+ where: eq(uptimeSchedules.id, scheduleId),
+ });
+
+ if (!schedule) {
+ return;
+ }
+
+ const websiteId = schedule.websiteId;
+ const organizationId = schedule.organizationId;
+
+ // Match alarms correctly:
+ // - If schedule has websiteId: match website-specific alarms OR org-level alarms (websiteId IS NULL)
+ // - If schedule has no websiteId: match only org-level alarms (websiteId IS NULL)
+ const websiteCondition = websiteId
+ ? or(eq(alarms.websiteId, websiteId), isNull(alarms.websiteId))
+ : isNull(alarms.websiteId);
+
+ const matchingAlarms = await db.query.alarms.findMany({
+ where: and(
+ eq(alarms.enabled, true),
+ eq(alarms.triggerType, "uptime"),
+ eq(alarms.organizationId, organizationId),
+ websiteCondition,
+ ),
+ });
+
+ if (matchingAlarms.length === 0) {
+ return;
+ }
+
+ const state = monitorState.get(scheduleId) ?? {
+ consecutiveFailures: 0,
+ lastNotifiedStatus: MonitorStatus.UP,
+ };
+
+ const isDown = uptimeData.status === MonitorStatus.DOWN;
+ const wasDown = state.lastNotifiedStatus === MonitorStatus.DOWN;
+
+ if (isDown) {
+ state.consecutiveFailures += 1;
+ } else {
+ state.consecutiveFailures = 0;
+ }
+
+ for (const alarm of matchingAlarms) {
+ const typedAlarm = alarm as AlarmRecord;
+ const threshold = getConsecutiveFailureThreshold(
+ typedAlarm.triggerConditions
+ );
+
+ if (isDown && state.consecutiveFailures === threshold && !wasDown) {
+ await sendAlarmNotifications(typedAlarm, {
+ title: `Site Down: ${uptimeData.url}`,
+ message: `Your website ${uptimeData.url} is not responding after ${threshold} consecutive failures.`,
+ priority: "urgent",
+ metadata: {
+ url: uptimeData.url,
+ status: uptimeData.http_code,
+ error: uptimeData.error,
+ consecutiveFailures: state.consecutiveFailures,
+ detectedAt: new Date().toISOString(),
+ probeRegion: uptimeData.probe_region,
+ },
+ });
+ }
+
+ if (!isDown && wasDown) {
+ await sendAlarmNotifications(typedAlarm, {
+ title: `Site Recovered: ${uptimeData.url}`,
+ message: `Your website ${uptimeData.url} is back online.`,
+ priority: "normal",
+ metadata: {
+ url: uptimeData.url,
+ status: uptimeData.http_code,
+ recoveredAt: new Date().toISOString(),
+ responseTime: uptimeData.total_ms,
+ probeRegion: uptimeData.probe_region,
+ },
+ });
+ }
+ }
+
+ state.lastNotifiedStatus = isDown
+ ? MonitorStatus.DOWN
+ : MonitorStatus.UP;
+ monitorState.set(scheduleId, state);
+ } catch (error) {
+ captureError(error, {
+ type: "alarm_trigger_error",
+ scheduleId,
+ });
+ }
+}
diff --git a/bun.lock b/bun.lock
index 8616b471e..75df09e8c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -114,7 +114,7 @@
"name": "@databuddy/dashboard",
"version": "0.1.0",
"dependencies": {
- "@ai-sdk/react": "^3.0.0",
+ "@ai-sdk/react": "^3.0.118",
"@cossistant/next": "^0.0.29",
"@cossistant/react": "^0.0.29",
"@databuddy/api-keys": "workspace:*",
@@ -128,8 +128,8 @@
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^5.2.2",
"@json-render/react": "^0.2.0",
- "@orpc/client": "^1.13.0",
- "@orpc/tanstack-query": "^1.13.0",
+ "@orpc/client": "^1.13.9",
+ "@orpc/tanstack-query": "^1.13.9",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -144,79 +144,79 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
- "@tanstack/react-pacer": "^0.19.2",
- "@tanstack/react-query": "^5.90.12",
+ "@tanstack/react-pacer": "^0.19.4",
+ "@tanstack/react-query": "^5.91.2",
"@tanstack/react-table": "^8.21.3",
"@types/d3-scale": "^4.0.9",
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.21",
"@types/react-grid-layout": "^2.1.0",
- "@xyflow/react": "^12.10.0",
- "ai": "^6.0.0",
- "autumn-js": "^0.1.63",
+ "@xyflow/react": "^12.10.1",
+ "ai": "^6.0.116",
+ "autumn-js": "^0.1.85",
"babel-plugin-react-compiler": "^19.1.0-rc.1-rc-af1b7da-20250421",
- "better-auth": "^1.4.9",
+ "better-auth": "^1.5.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-scale": "^4.0.2",
- "dayjs": "^1.11.19",
+ "dayjs": "^1.11.20",
"embla-carousel-react": "^8.6.0",
"flag-icons": "^7.5.0",
- "framer-motion": "^12.23.26",
+ "framer-motion": "^12.38.0",
"idb": "^8.0.3",
"input-otp": "^1.4.2",
- "jotai": "^2.16.0",
+ "jotai": "^2.18.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
- "maplibre-gl": "^5.15.0",
- "motion": "^12.23.26",
- "nanoid": "^5.1.6",
- "next": "^16.1.1",
+ "maplibre-gl": "^5.20.2",
+ "motion": "^12.38.0",
+ "nanoid": "^5.1.7",
+ "next": "^16.2.0",
"next-themes": "^0.4.6",
- "nuqs": "^2.8.6",
+ "nuqs": "^2.8.9",
"ogl": "^1.0.11",
- "pg": "^8.16.3",
+ "pg": "^8.20.0",
"qrcode.react": "^4.2.0",
"radix-ui": "latest",
"react": "catalog:",
- "react-day-picker": "^9.13.0",
+ "react-day-picker": "^9.14.0",
"react-dom": "catalog:",
"react-grid-layout": "^2.2.2",
- "react-hook-form": "^7.69.0",
- "react-hotkeys-hook": "^5.2.1",
+ "react-hook-form": "^7.71.2",
+ "react-hotkeys-hook": "^5.2.4",
"react-image-crop": "^11.0.10",
"react-leaflet": "^5.0.0",
"react-qrcode-logo": "^4.0.0",
"react-resizable-panels": "^3.0.6",
"react-textarea-autosize": "^8.5.9",
"recharts": "^2.15.4",
- "shiki": "^3.20.0",
- "simple-icons": "^16.2.0",
+ "shiki": "^3.23.0",
+ "simple-icons": "^16.12.0",
"sonner": "^2.0.7",
- "streamdown": "^2.1.0",
- "tailwind-merge": "^3.4.0",
+ "streamdown": "^2.5.0",
+ "tailwind-merge": "^3.5.0",
"tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0",
- "use-stick-to-bottom": "^1.1.1",
+ "use-stick-to-bottom": "^1.1.3",
"vaul": "^1.1.2",
"zod": "catalog:",
},
"devDependencies": {
"@biomejs/biome": "catalog:",
- "@orpc/server": "^1.13.0",
- "@tailwindcss/postcss": "^4.1.18",
- "@tanstack/react-query-devtools": "^5.91.2",
+ "@orpc/server": "^1.13.9",
+ "@tailwindcss/postcss": "^4.2.2",
+ "@tanstack/react-query-devtools": "^5.91.3",
"@types/d3-geo": "^3.1.0",
- "@types/node": "^22.19.3",
- "@types/pg": "^8.16.0",
+ "@types/node": "^22.19.15",
+ "@types/pg": "^8.18.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react-simple-maps": "^3.0.6",
"@types/topojson-client": "^3.1.5",
"husky": "^9.1.7",
- "lint-staged": "^16.2.7",
- "tailwindcss": "^4.1.18",
+ "lint-staged": "^16.4.0",
+ "tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"ultracite": "catalog:",
},
@@ -345,6 +345,7 @@
"version": "1.0.0",
"dependencies": {
"@databuddy/db": "workspace:*",
+ "@databuddy/notifications": "workspace:*",
"@databuddy/services": "workspace:*",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.208.0",
@@ -767,7 +768,7 @@
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
- "@better-auth/core": ["@better-auth/core@1.4.10", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.7", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-AThrfb6CpG80wqkanfrbN2/fGOYzhGladHFf3JhaWt/3/Vtf4h084T6PJLrDE7M/vCCGYvDI1DkvP3P1OB2HAg=="],
+ "@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-HAi9xAP40oDt48QZeYBFTcmg3vt1Jik90GwoRIfangd7VGbxesIIDBJSnvwMbZ52GBIc6+V4FRw9lasNiNrPfw=="],
@@ -781,9 +782,9 @@
"@better-auth/sso": ["@better-auth/sso@1.4.10", "", { "dependencies": { "@better-fetch/fetch": "1.1.21", "fast-xml-parser": "^5.2.5", "jose": "^6.1.0", "samlify": "^2.10.1", "zod": "^4.1.12" }, "peerDependencies": { "better-auth": "1.4.10" } }, "sha512-td8Mg32JHpyFRIwJ6sfqZ0NDa9Easf+sXw5wnWLLgmnd7/osg4xTKTFsMLEvr5j4n/1mzSFVU/RBthOV2lCD+A=="],
- "@better-auth/telemetry": ["@better-auth/telemetry@1.4.10", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.10" } }, "sha512-Dq4XJX6EKsUu0h3jpRagX739p/VMOTcnJYWRrLtDYkqtZFg+sFiFsSWVcfapZoWpRSUGYX9iKwl6nDHn6Ju2oQ=="],
+ "@better-auth/telemetry": ["@better-auth/telemetry@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.5" } }, "sha512-1+lklxArn4IMHuU503RcPdXrSG2tlXt4jnGG3omolmspQ7tktg/Y9XO/yAkYDurtvMn1xJ8X1Ov01Ji/r5s9BQ=="],
- "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
+ "@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
@@ -2027,7 +2028,7 @@
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
- "better-auth": ["better-auth@1.4.10", "", { "dependencies": { "@better-auth/core": "1.4.10", "@better-auth/telemetry": "1.4.10", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.7", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.12" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-0kqwEBJLe8eyFzbUspRG/htOriCf9uMLlnpe34dlIJGdmDfPuQISd4shShvUrvIVhPxsY1dSTXdXPLpqISYOYg=="],
+ "better-auth": ["better-auth@1.5.5", "", { "dependencies": { "@better-auth/core": "1.5.5", "@better-auth/drizzle-adapter": "1.5.5", "@better-auth/kysely-adapter": "1.5.5", "@better-auth/memory-adapter": "1.5.5", "@better-auth/mongo-adapter": "1.5.5", "@better-auth/prisma-adapter": "1.5.5", "@better-auth/telemetry": "1.5.5", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg=="],
"better-auth-harmony": ["better-auth-harmony@1.2.5", "", { "dependencies": { "libphonenumber-js": "^1.12.8", "mailchecker": "^6.0.17", "validator": "^13.15.15" }, "peerDependencies": { "better-auth": "^1.0.3" } }, "sha512-4YaAK5vrLnB6heImYJB8Pf524BPFrOYmUy1IFTHk6btGDCbgh3xT/hBCM6Ougwv/drURxtfZlB/FPktIjKLMtg=="],
@@ -3075,7 +3076,7 @@
"nanoid": ["nanoid@5.1.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="],
- "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
+ "nanostores": ["nanostores@1.2.0", "", {}, "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
@@ -3999,17 +4000,11 @@
"@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
- "@better-auth/core/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
-
- "@better-auth/drizzle-adapter/@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
-
- "@better-auth/kysely-adapter/@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
+ "@better-auth/core/better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
- "@better-auth/memory-adapter/@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
+ "@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
- "@better-auth/mongo-adapter/@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
-
- "@better-auth/prisma-adapter/@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
+ "@better-auth/sso/better-auth": ["better-auth@1.4.10", "", { "dependencies": { "@better-auth/core": "1.4.10", "@better-auth/telemetry": "1.4.10", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.7", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.12" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-0kqwEBJLe8eyFzbUspRG/htOriCf9uMLlnpe34dlIJGdmDfPuQISd4shShvUrvIVhPxsY1dSTXdXPLpqISYOYg=="],
"@better-auth/sso/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
@@ -4043,8 +4038,6 @@
"@databuddy/dashboard/autumn-js": ["autumn-js@0.1.85", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.17", "better-call": "^1.0.12", "convex": "^1.25.4" }, "optionalPeers": ["better-auth", "better-call", "convex"] }, "sha512-PDud/t8z5bDJcD7ptyHzTaoJ0A8zkxvQ4TYcJ48RtgKDdOkVY36D1T6udVLwLDnWw4J5KXwJgEuGxHdd+cuABw=="],
- "@databuddy/dashboard/better-auth": ["better-auth@1.5.5", "", { "dependencies": { "@better-auth/core": "1.5.5", "@better-auth/drizzle-adapter": "1.5.5", "@better-auth/kysely-adapter": "1.5.5", "@better-auth/memory-adapter": "1.5.5", "@better-auth/mongo-adapter": "1.5.5", "@better-auth/prisma-adapter": "1.5.5", "@better-auth/telemetry": "1.5.5", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg=="],
-
"@databuddy/dashboard/dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"@databuddy/dashboard/tokenlens": ["tokenlens@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0", "@tokenlens/helpers": "1.3.1", "@tokenlens/models": "1.3.0" } }, "sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA=="],
@@ -4087,7 +4080,7 @@
"@databuddy/uptime/@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.208.0", "@opentelemetry/exporter-logs-otlp-http": "0.208.0", "@opentelemetry/exporter-logs-otlp-proto": "0.208.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.208.0", "@opentelemetry/exporter-metrics-otlp-http": "0.208.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.208.0", "@opentelemetry/exporter-prometheus": "0.208.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.208.0", "@opentelemetry/exporter-trace-otlp-http": "0.208.0", "@opentelemetry/exporter-trace-otlp-proto": "0.208.0", "@opentelemetry/exporter-zipkin": "2.2.0", "@opentelemetry/instrumentation": "0.208.0", "@opentelemetry/propagator-b3": "2.2.0", "@opentelemetry/propagator-jaeger": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.208.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "@opentelemetry/sdk-trace-node": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-pbAqpZ7zTMFuTf3YecYsecsto/mheuvnK2a/jgstsE5ynWotBjgF5bnz5500W9Xl2LeUfg04WMt63TWtAgzRMw=="],
- "@databuddy/uptime/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
+ "@databuddy/uptime/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@elysiajs/opentelemetry/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg=="],
@@ -4563,10 +4556,16 @@
"babel-plugin-react-compiler/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
- "better-auth/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
+ "better-auth/better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
+
+ "better-auth/kysely": ["kysely@0.28.13", "", {}, "sha512-jCkYDvlfzOyHaVsrvR4vnNZxG30oNv2jbbFBjTQAUG8n0h07HW0sZJHk4KAQIRyu9ay+Rg+L8qGa3lwt8Gve9w=="],
+
+ "better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"better-auth-harmony/libphonenumber-js": ["libphonenumber-js@1.12.35", "", {}, "sha512-T/Cz6iLcsZdb5jDncDcUNhSAJ0VlSC9TnsqtBNdpkaAmy24/R1RhErtNWVWBrcUZKs9hSgaVsBkc7HxYnazIfw=="],
+ "better-call/@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
+
"better-call/rou3": ["rou3@0.7.11", "", {}, "sha512-ELguG3ENDw5NKNmWHO3OGEjcgdxkCNvnMR22gKHEgRXuwiriap5RIYdummOaOiqUNcC5yU5txGCHWNm7KlHuAA=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
@@ -4843,45 +4842,15 @@
"@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="],
- "@better-auth/drizzle-adapter/@better-auth/core/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
-
- "@better-auth/drizzle-adapter/@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
- "@better-auth/drizzle-adapter/@better-auth/core/better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
-
- "@better-auth/drizzle-adapter/@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
-
- "@better-auth/kysely-adapter/@better-auth/core/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
-
- "@better-auth/kysely-adapter/@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
- "@better-auth/kysely-adapter/@better-auth/core/better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
+ "@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
- "@better-auth/kysely-adapter/@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+ "@better-auth/sso/better-auth/@better-auth/core": ["@better-auth/core@1.4.10", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.7", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-AThrfb6CpG80wqkanfrbN2/fGOYzhGladHFf3JhaWt/3/Vtf4h084T6PJLrDE7M/vCCGYvDI1DkvP3P1OB2HAg=="],
- "@better-auth/memory-adapter/@better-auth/core/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
+ "@better-auth/sso/better-auth/@better-auth/telemetry": ["@better-auth/telemetry@1.4.10", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.10" } }, "sha512-Dq4XJX6EKsUu0h3jpRagX739p/VMOTcnJYWRrLtDYkqtZFg+sFiFsSWVcfapZoWpRSUGYX9iKwl6nDHn6Ju2oQ=="],
- "@better-auth/memory-adapter/@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@better-auth/sso/better-auth/@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
- "@better-auth/memory-adapter/@better-auth/core/better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
-
- "@better-auth/memory-adapter/@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
-
- "@better-auth/mongo-adapter/@better-auth/core/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
-
- "@better-auth/mongo-adapter/@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
- "@better-auth/mongo-adapter/@better-auth/core/better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
-
- "@better-auth/mongo-adapter/@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
-
- "@better-auth/prisma-adapter/@better-auth/core/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
-
- "@better-auth/prisma-adapter/@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
- "@better-auth/prisma-adapter/@better-auth/core/better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
-
- "@better-auth/prisma-adapter/@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+ "@better-auth/sso/better-auth/nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
"@databuddy/ai/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -5019,20 +4988,6 @@
"@databuddy/dashboard/autumn-js/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
- "@databuddy/dashboard/better-auth/@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
-
- "@databuddy/dashboard/better-auth/@better-auth/telemetry": ["@better-auth/telemetry@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.5" } }, "sha512-1+lklxArn4IMHuU503RcPdXrSG2tlXt4jnGG3omolmspQ7tktg/Y9XO/yAkYDurtvMn1xJ8X1Ov01Ji/r5s9BQ=="],
-
- "@databuddy/dashboard/better-auth/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
-
- "@databuddy/dashboard/better-auth/better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
-
- "@databuddy/dashboard/better-auth/kysely": ["kysely@0.28.13", "", {}, "sha512-jCkYDvlfzOyHaVsrvR4vnNZxG30oNv2jbbFBjTQAUG8n0h07HW0sZJHk4KAQIRyu9ay+Rg+L8qGa3lwt8Gve9w=="],
-
- "@databuddy/dashboard/better-auth/nanostores": ["nanostores@1.2.0", "", {}, "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg=="],
-
- "@databuddy/dashboard/better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
-
"@databuddy/dashboard/tokenlens/@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
"@databuddy/dashboard/tokenlens/@tokenlens/fetch": ["@tokenlens/fetch@1.3.0", "", { "dependencies": { "@tokenlens/core": "1.3.0" } }, "sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ=="],
@@ -5525,6 +5480,8 @@
"@xyflow/system/@types/d3-interpolate/@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
+ "better-auth/better-call/set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
+
"bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -5823,15 +5780,7 @@
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "@better-auth/drizzle-adapter/@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
-
- "@better-auth/kysely-adapter/@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
-
- "@better-auth/memory-adapter/@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
-
- "@better-auth/mongo-adapter/@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
-
- "@better-auth/prisma-adapter/@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
+ "@better-auth/sso/better-auth/@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@databuddy/api/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.208.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fGvAg3zb8fC0oJAzfz7PQppADI2HYB7TSt/XoCaBJFi1mSquNUjtHXEoviMgObLAa1NRIgOC1lsV1OUKi+9+lQ=="],
@@ -5855,10 +5804,6 @@
"@databuddy/dashboard/ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
- "@databuddy/dashboard/better-auth/@better-auth/core/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
- "@databuddy/dashboard/better-auth/better-call/set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
-
"@databuddy/dashboard/ultracite/trpc-cli/@trpc/server": ["@trpc/server@11.7.2", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-AgB26PXY69sckherIhCacKLY49rxE2XP5h38vr/KMZTbLCL1p8IuIoKPjALTcugC2kbyQ7Lbqo2JDVfRSmPmfQ=="],
"@databuddy/dashboard/ultracite/trpc-cli/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
diff --git a/packages/db/src/drizzle/relations.ts b/packages/db/src/drizzle/relations.ts
index c7cd62913..be0708ac4 100644
--- a/packages/db/src/drizzle/relations.ts
+++ b/packages/db/src/drizzle/relations.ts
@@ -1,6 +1,7 @@
import { relations } from "drizzle-orm/relations";
import {
account,
+ alarms,
apikey,
flags,
flagsToTargetGroups,
@@ -208,3 +209,18 @@ export const revenueConfigRelations = relations(revenueConfig, ({ one }) => ({
references: [websites.id],
}),
}));
+
+export const alarmsRelations = relations(alarms, ({ one }) => ({
+ user: one(user, {
+ fields: [alarms.userId],
+ references: [user.id],
+ }),
+ organization: one(organization, {
+ fields: [alarms.organizationId],
+ references: [organization.id],
+ }),
+ website: one(websites, {
+ fields: [alarms.websiteId],
+ references: [websites.id],
+ }),
+}));
diff --git a/packages/db/src/drizzle/schema.ts b/packages/db/src/drizzle/schema.ts
index 214ed8384..eae4f03e4 100644
--- a/packages/db/src/drizzle/schema.ts
+++ b/packages/db/src/drizzle/schema.ts
@@ -1094,6 +1094,72 @@ export const feedbackRedemptions = pgTable(
]
);
+export const alarmTriggerType = pgEnum("alarm_trigger_type", [
+ "uptime",
+ "traffic_spike",
+ "error_rate",
+ "goal",
+ "custom",
+]);
+
+export const alarms = pgTable(
+ "alarms",
+ {
+ id: text().primaryKey().notNull(),
+ userId: text("user_id"),
+ organizationId: text("organization_id"),
+ websiteId: text("website_id"),
+ name: text().notNull(),
+ description: text(),
+ enabled: boolean().default(true).notNull(),
+ notificationChannels: text("notification_channels").array().notNull(),
+ slackWebhookUrl: text("slack_webhook_url"),
+ discordWebhookUrl: text("discord_webhook_url"),
+ emailAddresses: text("email_addresses").array(),
+ webhookUrl: text("webhook_url"),
+ webhookHeaders: jsonb("webhook_headers"),
+ triggerType: alarmTriggerType("trigger_type").notNull(),
+ triggerConditions: jsonb("trigger_conditions"),
+ createdAt: timestamp("created_at", { precision: 3 }).defaultNow().notNull(),
+ updatedAt: timestamp("updated_at", { precision: 3 }).defaultNow().notNull(),
+ },
+ (table) => [
+ index("alarms_user_id_idx").using(
+ "btree",
+ table.userId.asc().nullsLast().op("text_ops")
+ ),
+ index("alarms_organization_id_idx").using(
+ "btree",
+ table.organizationId.asc().nullsLast().op("text_ops")
+ ),
+ index("alarms_website_id_idx").using(
+ "btree",
+ table.websiteId.asc().nullsLast().op("text_ops")
+ ),
+ index("alarms_enabled_idx").using(
+ "btree",
+ table.enabled.asc().nullsLast()
+ ),
+ foreignKey({
+ columns: [table.userId],
+ foreignColumns: [user.id],
+ name: "alarms_user_id_fkey",
+ }).onDelete("cascade"),
+ foreignKey({
+ columns: [table.organizationId],
+ foreignColumns: [organization.id],
+ name: "alarms_organization_id_fkey",
+ }).onDelete("cascade"),
+ foreignKey({
+ columns: [table.websiteId],
+ foreignColumns: [websites.id],
+ name: "alarms_website_id_fkey",
+ })
+ .onUpdate("cascade")
+ .onDelete("cascade"),
+ ]
+);
+
export type Website = typeof websites.$inferSelect;
export type WebsiteInsert = typeof websites.$inferInsert;
export type Organization = typeof organization.$inferSelect;
@@ -1116,3 +1182,5 @@ export type Feedback = typeof feedback.$inferSelect;
export type FeedbackInsert = typeof feedback.$inferInsert;
export type FeedbackRedemption = typeof feedbackRedemptions.$inferSelect;
export type FeedbackRedemptionInsert = typeof feedbackRedemptions.$inferInsert;
+export type Alarm = typeof alarms.$inferSelect;
+export type AlarmInsert = typeof alarms.$inferInsert;
diff --git a/packages/rpc/src/root.ts b/packages/rpc/src/root.ts
index 2af2eeda0..c3be07b16 100644
--- a/packages/rpc/src/root.ts
+++ b/packages/rpc/src/root.ts
@@ -1,3 +1,5 @@
+import { agentRouter } from "./routers/agent";
+import { alarmsRouter } from "./routers/alarms";
import { annotationsRouter } from "./routers/annotations";
import { apikeysRouter } from "./routers/apikeys";
import { autocompleteRouter } from "./routers/autocomplete";
@@ -15,6 +17,7 @@ import { uptimeRouter } from "./routers/uptime";
import { websitesRouter } from "./routers/websites";
export const appRouter = {
+ alarms: alarmsRouter,
annotations: annotationsRouter,
websites: websitesRouter,
funnels: funnelsRouter,
diff --git a/packages/rpc/src/routers/alarms.test.ts b/packages/rpc/src/routers/alarms.test.ts
new file mode 100644
index 000000000..9400ce815
--- /dev/null
+++ b/packages/rpc/src/routers/alarms.test.ts
@@ -0,0 +1,226 @@
+import { describe, expect, it } from "bun:test";
+import { z } from "zod";
+
+const notificationChannelSchema = z.enum([
+ "slack",
+ "discord",
+ "email",
+ "webhook",
+]);
+
+const triggerTypeSchema = z.enum([
+ "uptime",
+ "traffic_spike",
+ "error_rate",
+ "goal",
+ "custom",
+]);
+
+const createAlarmSchema = z.object({
+ organizationId: z.string(),
+ websiteId: z.string().optional(),
+ name: z.string().min(1).max(200),
+ description: z.string().max(1000).optional(),
+ enabled: z.boolean().optional(),
+ notificationChannels: z.array(notificationChannelSchema).min(1),
+ slackWebhookUrl: z.string().url().optional(),
+ discordWebhookUrl: z.string().url().optional(),
+ emailAddresses: z.array(z.string().email()).optional(),
+ webhookUrl: z.string().url().optional(),
+ webhookHeaders: z.record(z.string(), z.string()).optional(),
+ triggerType: triggerTypeSchema,
+ triggerConditions: z.record(z.string(), z.unknown()).optional(),
+});
+
+const updateAlarmSchema = z.object({
+ id: z.string(),
+ name: z.string().min(1).max(200).optional(),
+ description: z.string().max(1000).optional(),
+ enabled: z.boolean().optional(),
+ websiteId: z.string().nullish(),
+ notificationChannels: z.array(notificationChannelSchema).min(1).optional(),
+ slackWebhookUrl: z.string().url().nullish(),
+ discordWebhookUrl: z.string().url().nullish(),
+ emailAddresses: z.array(z.string().email()).nullish(),
+ webhookUrl: z.string().url().nullish(),
+ webhookHeaders: z.record(z.string(), z.string()).nullish(),
+ triggerType: triggerTypeSchema.optional(),
+ triggerConditions: z.record(z.string(), z.unknown()).nullish(),
+});
+
+describe("Alarm validation schemas", () => {
+ describe("createAlarmSchema", () => {
+ it("should validate a valid alarm creation payload", () => {
+ const valid = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ name: "Site Down Alert",
+ notificationChannels: ["slack"],
+ slackWebhookUrl: "https://hooks.slack.com/services/T/B/X",
+ triggerType: "uptime",
+ triggerConditions: { consecutiveFailures: 3 },
+ });
+ expect(valid.success).toBe(true);
+ });
+
+ it("should reject empty name", () => {
+ const invalid = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ name: "",
+ notificationChannels: ["slack"],
+ triggerType: "uptime",
+ });
+ expect(invalid.success).toBe(false);
+ });
+
+ it("should reject name exceeding max length", () => {
+ const invalid = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ name: "a".repeat(201),
+ notificationChannels: ["slack"],
+ triggerType: "uptime",
+ });
+ expect(invalid.success).toBe(false);
+ });
+
+ it("should reject empty notification channels", () => {
+ const invalid = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ name: "Test Alarm",
+ notificationChannels: [],
+ triggerType: "uptime",
+ });
+ expect(invalid.success).toBe(false);
+ });
+
+ it("should reject invalid notification channel", () => {
+ const invalid = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ name: "Test Alarm",
+ notificationChannels: ["sms"],
+ triggerType: "uptime",
+ });
+ expect(invalid.success).toBe(false);
+ });
+
+ it("should reject invalid trigger type", () => {
+ const invalid = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ name: "Test Alarm",
+ notificationChannels: ["slack"],
+ triggerType: "invalid_type",
+ });
+ expect(invalid.success).toBe(false);
+ });
+
+ it("should reject invalid webhook URLs", () => {
+ const invalid = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ name: "Test Alarm",
+ notificationChannels: ["slack"],
+ slackWebhookUrl: "not-a-url",
+ triggerType: "uptime",
+ });
+ expect(invalid.success).toBe(false);
+ });
+
+ it("should reject invalid email addresses", () => {
+ const invalid = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ name: "Test Alarm",
+ notificationChannels: ["email"],
+ emailAddresses: ["not-an-email"],
+ triggerType: "uptime",
+ });
+ expect(invalid.success).toBe(false);
+ });
+
+ it("should accept multiple notification channels", () => {
+ const valid = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ name: "Multi-Channel Alert",
+ notificationChannels: ["slack", "discord", "email", "webhook"],
+ slackWebhookUrl: "https://hooks.slack.com/services/T/B/X",
+ discordWebhookUrl: "https://discord.com/api/webhooks/123/abc",
+ emailAddresses: ["admin@example.com"],
+ webhookUrl: "https://example.com/webhook",
+ triggerType: "traffic_spike",
+ });
+ expect(valid.success).toBe(true);
+ });
+
+ it("should accept all trigger types", () => {
+ const triggerTypes = [
+ "uptime",
+ "traffic_spike",
+ "error_rate",
+ "goal",
+ "custom",
+ ] as const;
+
+ for (const triggerType of triggerTypes) {
+ const result = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ name: `${triggerType} alarm`,
+ notificationChannels: ["slack"],
+ triggerType,
+ });
+ expect(result.success).toBe(true);
+ }
+ });
+
+ it("should accept optional websiteId", () => {
+ const valid = createAlarmSchema.safeParse({
+ organizationId: "org-123",
+ websiteId: "site-456",
+ name: "Site Alert",
+ notificationChannels: ["slack"],
+ triggerType: "uptime",
+ });
+ expect(valid.success).toBe(true);
+ });
+ });
+
+ describe("updateAlarmSchema", () => {
+ it("should validate a valid update payload", () => {
+ const valid = updateAlarmSchema.safeParse({
+ id: "alarm-123",
+ name: "Updated Alert Name",
+ enabled: false,
+ });
+ expect(valid.success).toBe(true);
+ });
+
+ it("should require the id field", () => {
+ const invalid = updateAlarmSchema.safeParse({
+ name: "Updated Alert Name",
+ });
+ expect(invalid.success).toBe(false);
+ });
+
+ it("should allow partial updates", () => {
+ const valid = updateAlarmSchema.safeParse({
+ id: "alarm-123",
+ enabled: false,
+ });
+ expect(valid.success).toBe(true);
+ });
+
+ it("should allow setting fields to null", () => {
+ const valid = updateAlarmSchema.safeParse({
+ id: "alarm-123",
+ slackWebhookUrl: null,
+ discordWebhookUrl: null,
+ webhookUrl: null,
+ });
+ expect(valid.success).toBe(true);
+ });
+
+ it("should reject invalid webhook URL on update", () => {
+ const invalid = updateAlarmSchema.safeParse({
+ id: "alarm-123",
+ slackWebhookUrl: "not-a-url",
+ });
+ expect(invalid.success).toBe(false);
+ });
+ });
+});
diff --git a/packages/rpc/src/routers/alarms.ts b/packages/rpc/src/routers/alarms.ts
new file mode 100644
index 000000000..1c1bd9e3e
--- /dev/null
+++ b/packages/rpc/src/routers/alarms.ts
@@ -0,0 +1,395 @@
+import { alarms, and, db, eq, member } from "@databuddy/db";
+import {
+ sendDiscordWebhook,
+ sendSlackWebhook,
+ sendWebhook,
+} from "@databuddy/notifications";
+import { logger } from "@databuddy/shared/logger";
+import { ORPCError } from "@orpc/server";
+import { randomUUIDv7 } from "bun";
+import { z } from "zod";
+import { protectedProcedure, requireUserId } from "../orpc";
+import { checkOrgPermission } from "../utils/auth";
+
+const notificationChannelSchema = z.enum([
+ "slack",
+ "discord",
+ "email",
+ "webhook",
+]);
+
+const triggerTypeSchema = z.enum([
+ "uptime",
+ "traffic_spike",
+ "error_rate",
+ "goal",
+ "custom",
+]);
+
+const alarmOutputSchema = z.record(z.string(), z.unknown());
+
+const createAlarmSchema = z.object({
+ organizationId: z.string(),
+ websiteId: z.string().optional(),
+ name: z.string().min(1).max(200),
+ description: z.string().max(1000).optional(),
+ enabled: z.boolean().optional(),
+ notificationChannels: z.array(notificationChannelSchema).min(1),
+ slackWebhookUrl: z.string().url().optional(),
+ discordWebhookUrl: z.string().url().optional(),
+ emailAddresses: z.array(z.string().email()).optional(),
+ webhookUrl: z.string().url().optional(),
+ webhookHeaders: z.record(z.string(), z.string()).optional(),
+ triggerType: triggerTypeSchema,
+ triggerConditions: z.record(z.string(), z.unknown()).optional(),
+});
+
+const updateAlarmSchema = z.object({
+ id: z.string(),
+ name: z.string().min(1).max(200).optional(),
+ description: z.string().max(1000).optional(),
+ enabled: z.boolean().optional(),
+ websiteId: z.string().nullish(),
+ notificationChannels: z.array(notificationChannelSchema).min(1).optional(),
+ slackWebhookUrl: z.string().url().nullish(),
+ discordWebhookUrl: z.string().url().nullish(),
+ emailAddresses: z.array(z.string().email()).nullish(),
+ webhookUrl: z.string().url().nullish(),
+ webhookHeaders: z.record(z.string(), z.string()).nullish(),
+ triggerType: triggerTypeSchema.optional(),
+ triggerConditions: z.record(z.string(), z.unknown()).nullish(),
+});
+
+async function getAlarmAndAuthorize(
+ alarmId: string,
+ context: { headers: Headers }
+) {
+ const alarm = await db.query.alarms.findFirst({
+ where: eq(alarms.id, alarmId),
+ });
+
+ if (!alarm) {
+ throw new ORPCError("NOT_FOUND", { message: "Alarm not found" });
+ }
+
+ if (alarm.organizationId) {
+ await checkOrgPermission(
+ context,
+ alarm.organizationId,
+ "website",
+ "read",
+ "Missing workspace permissions."
+ );
+ }
+
+ return alarm;
+}
+
+export const alarmsRouter = {
+ list: protectedProcedure
+ .route({
+ description:
+ "Returns all alarms for the user or organization.",
+ method: "POST",
+ path: "/alarms/list",
+ summary: "List alarms",
+ tags: ["Alarms"],
+ })
+ .input(
+ z
+ .object({
+ organizationId: z.string().optional(),
+ websiteId: z.string().optional(),
+ triggerType: triggerTypeSchema.optional(),
+ })
+ .default({})
+ )
+ .output(z.array(alarmOutputSchema))
+ .handler(async ({ context, input }) => {
+ if (input.organizationId) {
+ await checkOrgPermission(
+ context,
+ input.organizationId,
+ "website",
+ "read",
+ "Missing workspace permissions."
+ );
+
+ const conditions = [
+ eq(alarms.organizationId, input.organizationId),
+ ];
+ if (input.websiteId) {
+ conditions.push(eq(alarms.websiteId, input.websiteId));
+ }
+ if (input.triggerType) {
+ conditions.push(
+ eq(alarms.triggerType, input.triggerType)
+ );
+ }
+
+ return await db.query.alarms.findMany({
+ where: and(...conditions),
+ orderBy: (table, { desc }) => [desc(table.createdAt)],
+ });
+ }
+
+ const userId = requireUserId(context);
+ const userMemberships = await db.query.member.findMany({
+ where: eq(member.userId, userId),
+ columns: { organizationId: true },
+ });
+
+ if (userMemberships.length === 0) {
+ return [];
+ }
+
+ const { inArray } = await import("@databuddy/db");
+ const orgIds = userMemberships.map((m) => m.organizationId);
+
+ return await db.query.alarms.findMany({
+ where: inArray(alarms.organizationId, orgIds),
+ orderBy: (table, { desc }) => [desc(table.createdAt)],
+ });
+ }),
+
+ get: protectedProcedure
+ .route({
+ description: "Returns a single alarm by ID.",
+ method: "POST",
+ path: "/alarms/get",
+ summary: "Get alarm",
+ tags: ["Alarms"],
+ })
+ .input(z.object({ id: z.string() }))
+ .output(alarmOutputSchema)
+ .handler(async ({ context, input }) => {
+ const alarm = await getAlarmAndAuthorize(input.id, context);
+ return alarm;
+ }),
+
+ create: protectedProcedure
+ .route({
+ description: "Creates a new alarm with notification channels.",
+ method: "POST",
+ path: "/alarms/create",
+ summary: "Create alarm",
+ tags: ["Alarms"],
+ })
+ .input(createAlarmSchema)
+ .output(alarmOutputSchema)
+ .handler(async ({ context, input }) => {
+ const userId = requireUserId(context);
+
+ await checkOrgPermission(
+ context,
+ input.organizationId,
+ "website",
+ "update",
+ "Missing workspace permissions."
+ );
+
+ const alarmId = randomUUIDv7();
+
+ const [created] = await db
+ .insert(alarms)
+ .values({
+ id: alarmId,
+ userId,
+ organizationId: input.organizationId,
+ websiteId: input.websiteId ?? null,
+ name: input.name,
+ description: input.description ?? null,
+ enabled: input.enabled ?? true,
+ notificationChannels: input.notificationChannels,
+ slackWebhookUrl: input.slackWebhookUrl ?? null,
+ discordWebhookUrl: input.discordWebhookUrl ?? null,
+ emailAddresses: input.emailAddresses ?? null,
+ webhookUrl: input.webhookUrl ?? null,
+ webhookHeaders: input.webhookHeaders ?? null,
+ triggerType: input.triggerType,
+ triggerConditions: input.triggerConditions ?? null,
+ })
+ .returning();
+
+ logger.info({ alarmId, name: input.name }, "Alarm created");
+ return created;
+ }),
+
+ update: protectedProcedure
+ .route({
+ description: "Updates an existing alarm.",
+ method: "POST",
+ path: "/alarms/update",
+ summary: "Update alarm",
+ tags: ["Alarms"],
+ })
+ .input(updateAlarmSchema)
+ .output(alarmOutputSchema)
+ .handler(async ({ context, input }) => {
+ const existing = await getAlarmAndAuthorize(input.id, context);
+
+ if (existing.organizationId) {
+ await checkOrgPermission(
+ context,
+ existing.organizationId,
+ "website",
+ "update",
+ "Missing workspace permissions."
+ );
+ }
+
+ const { id, ...updates } = input;
+
+ const updateData: Record = {
+ updatedAt: new Date(),
+ };
+
+ if (updates.name !== undefined) updateData.name = updates.name;
+ if (updates.description !== undefined)
+ updateData.description = updates.description;
+ if (updates.enabled !== undefined) updateData.enabled = updates.enabled;
+ if (updates.websiteId !== undefined)
+ updateData.websiteId = updates.websiteId;
+ if (updates.notificationChannels !== undefined)
+ updateData.notificationChannels = updates.notificationChannels;
+ if (updates.slackWebhookUrl !== undefined)
+ updateData.slackWebhookUrl = updates.slackWebhookUrl;
+ if (updates.discordWebhookUrl !== undefined)
+ updateData.discordWebhookUrl = updates.discordWebhookUrl;
+ if (updates.emailAddresses !== undefined)
+ updateData.emailAddresses = updates.emailAddresses;
+ if (updates.webhookUrl !== undefined)
+ updateData.webhookUrl = updates.webhookUrl;
+ if (updates.webhookHeaders !== undefined)
+ updateData.webhookHeaders = updates.webhookHeaders;
+ if (updates.triggerType !== undefined)
+ updateData.triggerType = updates.triggerType;
+ if (updates.triggerConditions !== undefined)
+ updateData.triggerConditions = updates.triggerConditions;
+
+ const [updated] = await db
+ .update(alarms)
+ .set(updateData)
+ .where(eq(alarms.id, id))
+ .returning();
+
+ logger.info({ alarmId: id }, "Alarm updated");
+ return updated;
+ }),
+
+ delete: protectedProcedure
+ .route({
+ description: "Deletes an alarm.",
+ method: "POST",
+ path: "/alarms/delete",
+ summary: "Delete alarm",
+ tags: ["Alarms"],
+ })
+ .input(z.object({ id: z.string() }))
+ .output(z.object({ success: z.literal(true) }))
+ .handler(async ({ context, input }) => {
+ const existing = await getAlarmAndAuthorize(input.id, context);
+
+ if (existing.organizationId) {
+ await checkOrgPermission(
+ context,
+ existing.organizationId,
+ "website",
+ "update",
+ "Missing workspace permissions."
+ );
+ }
+
+ await db.delete(alarms).where(eq(alarms.id, input.id));
+
+ logger.info({ alarmId: input.id }, "Alarm deleted");
+ return { success: true };
+ }),
+
+ test: protectedProcedure
+ .route({
+ description:
+ "Sends a test notification to all configured channels on an alarm.",
+ method: "POST",
+ path: "/alarms/test",
+ summary: "Test alarm",
+ tags: ["Alarms"],
+ })
+ .input(z.object({ id: z.string() }))
+ .output(
+ z.object({
+ success: z.boolean(),
+ results: z.array(
+ z.object({
+ channel: z.string(),
+ success: z.boolean(),
+ error: z.string().optional(),
+ })
+ ),
+ })
+ )
+ .handler(async ({ context, input }) => {
+ const alarm = await getAlarmAndAuthorize(input.id, context);
+
+ const channels = alarm.notificationChannels as string[];
+ const results: Array<{
+ channel: string;
+ success: boolean;
+ error?: string;
+ }> = [];
+
+ const testPayload = {
+ title: `Test Alarm: ${alarm.name}`,
+ message:
+ "This is a test notification from Databuddy. Your alarm is configured correctly.",
+ priority: "normal" as const,
+ metadata: {
+ alarmId: alarm.id,
+ alarmName: alarm.name,
+ test: true,
+ },
+ };
+
+ for (const channel of channels) {
+ try {
+ if (channel === "slack" && alarm.slackWebhookUrl) {
+ await sendSlackWebhook(alarm.slackWebhookUrl, testPayload);
+ results.push({ channel: "slack", success: true });
+ } else if (channel === "discord" && alarm.discordWebhookUrl) {
+ await sendDiscordWebhook(
+ alarm.discordWebhookUrl,
+ testPayload
+ );
+ results.push({ channel: "discord", success: true });
+ } else if (channel === "webhook" && alarm.webhookUrl) {
+ await sendWebhook(alarm.webhookUrl, testPayload, {
+ headers: (alarm.webhookHeaders as Record) ?? undefined,
+ });
+ results.push({ channel: "webhook", success: true });
+ } else if (channel === "email") {
+ results.push({
+ channel: "email",
+ success: false,
+ error: "Email notifications are not yet configured",
+ });
+ } else {
+ results.push({
+ channel,
+ success: false,
+ error: `Channel ${channel} is not configured`,
+ });
+ }
+ } catch (error) {
+ results.push({
+ channel,
+ success: false,
+ error:
+ error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ }
+
+ const allSucceeded = results.every((r) => r.success);
+ return { success: allSucceeded, results };
+ }),
+};