diff --git a/package-lock.json b/package-lock.json
index 13d27c7..e171945 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,10 @@
"name": "site-status",
"version": "0.1.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.96.0",
"next": "^15.2.0",
@@ -54,6 +58,73 @@
"commander": "~13.1.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/modifiers": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
+ "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
diff --git a/package.json b/package.json
index ab5ee8c..77b6118 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,10 @@
"test:e2e": "env-cmd -f .env.test playwright test"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.96.0",
"next": "^15.2.0",
diff --git a/src/__tests__/sites-crud.test.ts b/src/__tests__/sites-crud.test.ts
index 6d61d16..d3e7404 100644
--- a/src/__tests__/sites-crud.test.ts
+++ b/src/__tests__/sites-crud.test.ts
@@ -26,7 +26,7 @@ vi.mock("@/lib/supabase/server", () => ({
}),
}))
-import { addSite, editSite, deleteSite } from "@/app/sites/actions"
+import { addSite, editSite, deleteSite, reorderSites } from "@/app/sites/actions"
describe("addSite action", () => {
beforeEach(() => {
@@ -49,7 +49,17 @@ describe("addSite action", () => {
})
const mockInsert = vi.fn().mockReturnValue({ error: null })
- mockFrom.mockReturnValue({ insert: mockInsert })
+ const mockSingle = vi.fn().mockResolvedValue({ data: { position: 2 } })
+ const mockLimit = vi.fn().mockReturnValue({ single: mockSingle })
+ const mockOrder = vi.fn().mockReturnValue({ limit: mockLimit })
+ const mockSelect = vi.fn().mockReturnValue({ order: mockOrder })
+ mockFrom.mockImplementation((table: string) => {
+ // First call: select max position; second call: insert
+ if (mockFrom.mock.calls.length <= 1) {
+ return { select: mockSelect }
+ }
+ return { insert: mockInsert }
+ })
const formData = new FormData()
formData.set("name", "Test Site")
@@ -60,6 +70,7 @@ describe("addSite action", () => {
expect(mockInsert).toHaveBeenCalledWith({
name: "Test Site",
url: "https://example.com",
+ position: 3,
})
})
@@ -154,3 +165,36 @@ describe("deleteSite action", () => {
expect(mockEq).toHaveBeenCalledWith("id", "site-1")
})
})
+
+describe("reorderSites action", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ test("redirects unauthenticated users to login", async () => {
+ mockGetUser.mockResolvedValue({ data: { user: null } })
+
+ await expect(reorderSites(["a", "b"])).rejects.toThrow(
+ "NEXT_REDIRECT:/login"
+ )
+ })
+
+ test("updates positions for authenticated users", async () => {
+ mockGetUser.mockResolvedValue({
+ data: { user: { id: "user-1" } },
+ })
+
+ const mockEq = vi.fn().mockReturnValue({ error: null })
+ const mockUpdate = vi.fn().mockReturnValue({ eq: mockEq })
+ mockFrom.mockReturnValue({ update: mockUpdate })
+
+ await reorderSites(["site-b", "site-a", "site-c"])
+ expect(mockFrom).toHaveBeenCalledWith("sites")
+ expect(mockUpdate).toHaveBeenCalledWith({ position: 0 })
+ expect(mockUpdate).toHaveBeenCalledWith({ position: 1 })
+ expect(mockUpdate).toHaveBeenCalledWith({ position: 2 })
+ expect(mockEq).toHaveBeenCalledWith("id", "site-b")
+ expect(mockEq).toHaveBeenCalledWith("id", "site-a")
+ expect(mockEq).toHaveBeenCalledWith("id", "site-c")
+ })
+})
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 3873630..4077834 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,9 +1,8 @@
import Link from "next/link"
import { createClient } from "@/lib/supabase/server"
import type { Site, Check, Incident } from "@/lib/supabase/types"
-import { formatTimeAgo, formatDuration } from "@/lib/format"
-import AddSiteCard from "@/components/AddSiteCard"
-import SiteFormDialog from "@/components/SiteFormDialog"
+import { formatDuration } from "@/lib/format"
+import SiteList from "@/components/SiteList"
import RealtimeStatusPage from "@/components/RealtimeStatusPage"
export const revalidate = 0
@@ -17,7 +16,7 @@ async function getStatusData() {
const { data: sites } = await supabase
.from("sites")
.select("*")
- .order("name")
+ .order("position")
const { data: openIncidents } = await supabase
.from("incidents")
@@ -127,103 +126,7 @@ export default async function StatusPage() {
Sites
- {sites.length === 0 && !isAdmin ? (
-
- No sites are being monitored yet.
-
- ) : (
-
- {sites.map((site) => (
-
-
-
-
-
-
- {site.name}
-
-
-
- {site.url}
-
- {isAdmin && (
-
- )}
-
- {site.lastCheck
- ? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}`
- : "No checks yet"}
-
-
-
- ))}
- {isAdmin &&
}
-
- )}
+
)
diff --git a/src/app/sites/actions.ts b/src/app/sites/actions.ts
index c44080c..8e4bb95 100644
--- a/src/app/sites/actions.ts
+++ b/src/app/sites/actions.ts
@@ -17,7 +17,16 @@ export async function addSite(formData: FormData) {
if (!name || !url) return
- await supabase.from("sites").insert({ name, url })
+ const { data: maxRow } = await supabase
+ .from("sites")
+ .select("position")
+ .order("position", { ascending: false })
+ .limit(1)
+ .single()
+
+ const position = (maxRow?.position ?? -1) + 1
+
+ await supabase.from("sites").insert({ name, url, position })
revalidatePath("/")
}
@@ -50,3 +59,21 @@ export async function deleteSite(siteId: string) {
await supabase.from("sites").delete().eq("id", siteId)
revalidatePath("/")
}
+
+export async function reorderSites(orderedIds: string[]) {
+ const supabase = await createClient()
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+
+ if (!user) redirect("/login")
+
+ for (let i = 0; i < orderedIds.length; i++) {
+ await supabase
+ .from("sites")
+ .update({ position: i })
+ .eq("id", orderedIds[i])
+ }
+
+ revalidatePath("/")
+}
diff --git a/src/components/SiteList.tsx b/src/components/SiteList.tsx
new file mode 100644
index 0000000..3445906
--- /dev/null
+++ b/src/components/SiteList.tsx
@@ -0,0 +1,274 @@
+"use client"
+
+import { useRef, useState } from "react"
+import Link from "next/link"
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core"
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
+import type { Site, Check } from "@/lib/supabase/types"
+import { formatTimeAgo } from "@/lib/format"
+import { reorderSites } from "@/app/sites/actions"
+import SiteFormDialog from "@/components/SiteFormDialog"
+import AddSiteCard from "@/components/AddSiteCard"
+
+type SiteWithLastCheck = Site & { lastCheck: Check | null }
+
+function SortableSiteCard({
+ site,
+ isAdmin,
+}: {
+ site: SiteWithLastCheck
+ isAdmin: boolean
+}) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: site.id })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ }
+
+ return (
+
+
+
+
+ )
+}
+
+function SiteCard({
+ site,
+ isAdmin,
+ isWrapped,
+}: {
+ site: SiteWithLastCheck
+ isAdmin: boolean
+ isWrapped?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {site.name}
+
+
+
+ {site.url}
+
+ {isAdmin && (
+
+ )}
+
+ {site.lastCheck
+ ? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}`
+ : "No checks yet"}
+
+
+
+ )
+}
+
+export default function SiteList({
+ sites: initialSites,
+ isAdmin,
+}: {
+ sites: SiteWithLastCheck[]
+ isAdmin: boolean
+}) {
+ const [sites, setSites] = useState(initialSites)
+
+ // Sync local state when server data changes (after add/edit/delete revalidation)
+ const serverKey = initialSites.map((s) => `${s.id}:${s.name}:${s.url}`).join("|")
+ const prevServerKey = useRef(serverKey)
+ if (serverKey !== prevServerKey.current) {
+ prevServerKey.current = serverKey
+ setSites(initialSites)
+ }
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 5 },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ )
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event
+ if (!over || active.id === over.id) return
+
+ const oldIndex = sites.findIndex((s) => s.id === active.id)
+ const newIndex = sites.findIndex((s) => s.id === over.id)
+ const reordered = arrayMove(sites, oldIndex, newIndex)
+
+ setSites(reordered)
+ reorderSites(reordered.map((s) => s.id))
+ }
+
+ if (sites.length === 0 && !isAdmin) {
+ return (
+
+ No sites are being monitored yet.
+
+ )
+ }
+
+ if (!isAdmin) {
+ return (
+
+ {sites.map((site) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ s.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {sites.map((site) => (
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts
index 36625df..8241eb0 100644
--- a/src/lib/supabase/types.ts
+++ b/src/lib/supabase/types.ts
@@ -2,6 +2,7 @@ export type Site = {
id: string
name: string
url: string
+ position: number
created_at: string
}
diff --git a/supabase/migrations/20250222000001_add_site_position.sql b/supabase/migrations/20250222000001_add_site_position.sql
new file mode 100644
index 0000000..41ef7bc
--- /dev/null
+++ b/supabase/migrations/20250222000001_add_site_position.sql
@@ -0,0 +1,10 @@
+-- Add position column for custom ordering
+ALTER TABLE sites ADD COLUMN position integer NOT NULL DEFAULT 0;
+
+-- Backfill existing rows with sequential positions based on alphabetical order
+WITH ranked AS (
+ SELECT id, ROW_NUMBER() OVER (ORDER BY name) - 1 AS pos
+ FROM sites
+)
+UPDATE sites SET position = ranked.pos
+FROM ranked WHERE sites.id = ranked.id;
diff --git a/supabase/seed.sql b/supabase/seed.sql
index 4c4d843..a187c5c 100644
--- a/supabase/seed.sql
+++ b/supabase/seed.sql
@@ -62,12 +62,74 @@ INSERT INTO auth.identities (
);
-- ============================================================================
--- Sample sites
+-- Sample sites (4 sites to demonstrate each status)
-- ============================================================================
-INSERT INTO sites (id, name, url) VALUES
- ('11111111-1111-1111-1111-111111111111', 'Example Site', 'https://example.com'),
- ('22222222-2222-2222-2222-222222222222', 'Anthropic', 'https://www.anthropic.com');
+INSERT INTO sites (id, name, url, position) VALUES
+ ('11111111-1111-1111-1111-111111111111', 'Healthy Site', 'https://example.com', 0),
+ ('22222222-2222-2222-2222-222222222222', 'Flaky Site', 'https://flaky.example.com', 1),
+ ('33333333-3333-3333-3333-333333333333', 'Broken Site', 'https://broken.example.com', 2),
+ ('44444444-4444-4444-4444-444444444444', 'Down Site (Incident)', 'https://down.example.com', 3);
+
+-- ============================================================================
+-- Synthetic checks (last hour) to demonstrate all three site statuses
+-- ============================================================================
+
+-- Site 1: "Healthy Site" — all passing → status: Up (green)
+INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES
+ ('aaaa0001-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '50 minutes'),
+ ('aaaa0001-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '45 minutes'),
+ ('aaaa0001-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '40 minutes'),
+ ('aaaa0001-0000-0000-0000-000000000004', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '35 minutes'),
+ ('aaaa0001-0000-0000-0000-000000000005', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '30 minutes'),
+ ('aaaa0001-0000-0000-0000-000000000006', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '25 minutes'),
+ ('aaaa0001-0000-0000-0000-000000000007', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '20 minutes'),
+ ('aaaa0001-0000-0000-0000-000000000008', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '15 minutes'),
+ ('aaaa0001-0000-0000-0000-000000000009', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '10 minutes'),
+ ('aaaa0001-0000-0000-0000-000000000010', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '5 minutes');
+
+-- Site 2: "Flaky Site" — mostly passing with one 503 → status: Transient Failures (amber)
+INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES
+ ('aaaa0002-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '50 minutes'),
+ ('aaaa0002-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '45 minutes'),
+ ('aaaa0002-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '40 minutes'),
+ ('aaaa0002-0000-0000-0000-000000000004', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '35 minutes'),
+ ('aaaa0002-0000-0000-0000-000000000005', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '30 minutes'),
+ ('aaaa0002-0000-0000-0000-000000000006', '22222222-2222-2222-2222-222222222222', 'failure', 503, 'HTTP 503', now() - interval '25 minutes'),
+ ('aaaa0002-0000-0000-0000-000000000007', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '20 minutes'),
+ ('aaaa0002-0000-0000-0000-000000000008', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '15 minutes'),
+ ('aaaa0002-0000-0000-0000-000000000009', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '10 minutes'),
+ ('aaaa0002-0000-0000-0000-000000000010', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '5 minutes');
+
+-- Site 3: "Broken Site" — has a hard failure (HTTP 500) → status: Failures (red)
+INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES
+ ('aaaa0003-0000-0000-0000-000000000001', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '50 minutes'),
+ ('aaaa0003-0000-0000-0000-000000000002', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '45 minutes'),
+ ('aaaa0003-0000-0000-0000-000000000003', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '40 minutes'),
+ ('aaaa0003-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '35 minutes'),
+ ('aaaa0003-0000-0000-0000-000000000005', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '30 minutes'),
+ ('aaaa0003-0000-0000-0000-000000000006', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '25 minutes'),
+ ('aaaa0003-0000-0000-0000-000000000007', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '20 minutes'),
+ ('aaaa0003-0000-0000-0000-000000000008', '33333333-3333-3333-3333-333333333333', 'failure', 500, 'HTTP 500', now() - interval '15 minutes'),
+ ('aaaa0003-0000-0000-0000-000000000009', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '10 minutes'),
+ ('aaaa0003-0000-0000-0000-000000000010', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '5 minutes');
+
+-- Site 4: "Down Site (Incident)" — 3 soft failures triggered an incident, still failing
+INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES
+ ('aaaa0004-0000-0000-0000-000000000001', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '50 minutes'),
+ ('aaaa0004-0000-0000-0000-000000000002', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '45 minutes'),
+ ('aaaa0004-0000-0000-0000-000000000003', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '40 minutes'),
+ ('aaaa0004-0000-0000-0000-000000000004', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '35 minutes'),
+ ('aaaa0004-0000-0000-0000-000000000005', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '30 minutes'),
+ ('aaaa0004-0000-0000-0000-000000000006', '44444444-4444-4444-4444-444444444444', 'failure', 502, 'HTTP 502', now() - interval '25 minutes'),
+ ('aaaa0004-0000-0000-0000-000000000007', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '20 minutes'),
+ ('aaaa0004-0000-0000-0000-000000000008', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '15 minutes'),
+ ('aaaa0004-0000-0000-0000-000000000009', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '10 minutes'),
+ ('aaaa0004-0000-0000-0000-000000000010', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '5 minutes');
+
+-- Open incident for Site 4 (triggered by the 3rd soft failure)
+INSERT INTO incidents (id, site_id, check_id, status, opened_at) VALUES
+ ('bbbb0001-0000-0000-0000-000000000001', '44444444-4444-4444-4444-444444444444', 'aaaa0004-0000-0000-0000-000000000007', 'open', now() - interval '20 minutes');
-- ============================================================================
-- Test contact