diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b340c96..1f9bc2a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,6 +8,7 @@ import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; import { appointmentsRouter } from "./routes/appointments.js"; import { waitlistRouter } from "./routes/waitlist.js"; +import { conversationsRouter } from "./routes/conversations.js"; import { portalRouter } from "./routes/portal.js"; import { staffRouter } from "./routes/staff.js"; import { invoicesRouter } from "./routes/invoices.js"; @@ -264,6 +265,7 @@ api.route("/pets", petsRouter); api.route("/services", servicesRouter); api.route("/appointments", appointmentsRouter); api.route("/waitlist", waitlistRouter); +api.route("/conversations", conversationsRouter); api.route("/staff", staffRouter); api.route("/invoices", invoicesRouter); api.route("/reports", reportsRouter); diff --git a/apps/api/src/routes/conversations.ts b/apps/api/src/routes/conversations.ts index 0b8eddb..7b0cb4f 100644 --- a/apps/api/src/routes/conversations.ts +++ b/apps/api/src/routes/conversations.ts @@ -1,273 +1,214 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { - and, - eq, - desc, - lt, - sql, - getDb, - conversations, - messages, - clients, - businessSettings, -} from "@groombook/db"; +import { and, eq, desc, lt, isNull, sql, count } from "@groombook/db"; +import { getDb, conversations, messages, clients } from "@groombook/db"; +import { resolveStaffMiddleware } from "../middleware/rbac.js"; import type { AppEnv } from "../middleware/rbac.js"; -import { sendMessage } from "../services/messaging/outbound.js"; export const conversationsRouter = new Hono(); -const sendMessageSchema = z.object({ - body: z.string().min(1).max(1600), -}); +conversationsRouter.use("/*", resolveStaffMiddleware); -// GET /api/conversations — List conversations +// GET /api/conversations — list all conversations for staff's business conversationsRouter.get("/", async (c) => { const db = getDb(); - const staffRow = c.get("staff"); - if (!staffRow) return c.json({ error: "Unauthorized" }, 401); + const businessId = c.get("staff").businessId; - const [settings] = await db - .select({ id: businessSettings.id }) - .from(businessSettings) - .limit(1); - if (!settings) return c.json({ error: "Business not found" }, 404); - - const cursor = c.req.query("cursor") || undefined; - const limit = Math.min(Number(c.req.query("limit") || "20"), 50); - - let baseQuery = db + const rows = await db .select({ id: conversations.id, + businessId: conversations.businessId, clientId: conversations.clientId, + channel: conversations.channel, + externalNumber: conversations.externalNumber, + businessNumber: conversations.businessNumber, lastMessageAt: conversations.lastMessageAt, status: conversations.status, + createdAt: conversations.createdAt, staffReadAt: conversations.staffReadAt, - clientName: clients.name, - clientPhone: clients.phone, - channel: conversations.channel, }) .from(conversations) - .innerJoin(clients, eq(conversations.clientId, clients.id)) - .where(eq(conversations.businessId, settings.id)) + .where(eq(conversations.businessId, businessId)) .orderBy(desc(conversations.lastMessageAt)) - .limit(limit + 1); + .limit(20); - if (cursor) { - const [cursorRow] = await db - .select({ lastMessageAt: conversations.lastMessageAt }) - .from(conversations) - .where(eq(conversations.id, cursor)) - .limit(1); - if (cursorRow?.lastMessageAt) { - baseQuery = db - .select({ - id: conversations.id, - clientId: conversations.clientId, - lastMessageAt: conversations.lastMessageAt, - status: conversations.status, - staffReadAt: conversations.staffReadAt, - clientName: clients.name, - clientPhone: clients.phone, - channel: conversations.channel, - }) - .from(conversations) - .innerJoin(clients, eq(conversations.clientId, clients.id)) - .where( - and( - eq(conversations.businessId, settings.id), - lt(conversations.lastMessageAt, cursorRow.lastMessageAt) - ) - ) - .orderBy(desc(conversations.lastMessageAt)) - .limit(limit + 1); - } - } - - const rows = await baseQuery; - - const hasMore = rows.length > limit; - if (hasMore) rows.pop(); - - const items = await Promise.all( + // For each conversation, fetch client name and count unread messages + const enriched = await Promise.all( rows.map(async (row) => { - const [unreadRow] = await db - .select({ count: sql`count(*)` }) + const [client] = await db + .select({ name: clients.name }) + .from(clients) + .where(eq(clients.id, row.clientId)) + .limit(1); + + // Count messages where direction = 'inbound' AND readByClientAt IS NULL + const [{ count: unreadCount }] = await db + .select({ count: count() }) .from(messages) .where( and( eq(messages.conversationId, row.id), eq(messages.direction, "inbound"), - sql`${messages.createdAt} > COALESCE(${row.staffReadAt}, '1970-01-01'::timestamp)` + isNull(messages.readByClientAt) ) - ) - .limit(1); + ); + // Fetch last message body for preview const [lastMsg] = await db - .select({ - body: messages.body, - direction: messages.direction, - createdAt: messages.createdAt, - }) + .select({ body: messages.body, createdAt: messages.createdAt }) .from(messages) .where(eq(messages.conversationId, row.id)) .orderBy(desc(messages.createdAt)) .limit(1); return { - id: row.id, - clientId: row.clientId, - clientName: row.clientName, - clientPhone: row.clientPhone, - channel: row.channel, - lastMessageAt: row.lastMessageAt, - status: row.status, - unreadCount: Number(unreadRow?.count ?? 0), - lastMessage: lastMsg ?? null, + ...row, + clientName: client?.name ?? "Unknown", + lastMessageBody: lastMsg?.body ?? null, + unreadCount: Number(unreadCount), }; }) ); - const lastRow = rows[rows.length - 1]; - const nextCursor = hasMore && lastRow ? lastRow.id : null; - return c.json({ items, nextCursor }); + return c.json(enriched); }); -// GET /api/conversations/:id/messages — List messages for a conversation +// GET /api/conversations/:id — get a single conversation +conversationsRouter.get("/:id", async (c) => { + const db = getDb(); + const businessId = c.get("staff").businessId; + const conversationId = c.req.param("id"); + + const [row] = await db + .select() + .from(conversations) + .where(and(eq(conversations.id, conversationId), eq(conversations.businessId, businessId))) + .limit(1); + + if (!row) { + return c.json({ error: "Not found" }, 404); + } + + const [client] = await db + .select({ name: clients.name }) + .from(clients) + .where(eq(clients.id, row.clientId)) + .limit(1); + + return c.json({ ...row, clientName: client?.name ?? "Unknown" }); +}); + +// GET /api/conversations/:id/messages — get messages for a conversation conversationsRouter.get("/:id/messages", async (c) => { const db = getDb(); - const staffRow = c.get("staff"); - if (!staffRow) return c.json({ error: "Unauthorized" }, 401); - + const businessId = c.get("staff").businessId; const conversationId = c.req.param("id"); - const cursor = c.req.query("cursor") || undefined; - const limit = Math.min(Number(c.req.query("limit") || "50"), 100); + const limit = parseInt(c.req.query("limit") ?? "50", 10); + const cursor = c.req.query("cursor"); - const [settings] = await db - .select({ id: businessSettings.id }) - .from(businessSettings) - .limit(1); - if (!settings) return c.json({ error: "Business not found" }, 404); - - const [conv] = await db + // Verify staff owns this conversation + const [conversation] = await db .select({ id: conversations.id }) .from(conversations) - .where( - and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id)) - ) + .where(and(eq(conversations.id, conversationId), eq(conversations.businessId, businessId))) .limit(1); - if (!conv) return c.json({ error: "Not found" }, 404); + if (!conversation) { + return c.json({ error: "Not found" }, 404); + } + + // Mark conversation as read by staff await db .update(conversations) .set({ staffReadAt: new Date() }) .where(eq(conversations.id, conversationId)); - let query = db - .select({ - id: messages.id, - direction: messages.direction, - body: messages.body, - status: messages.status, - sentByStaffId: messages.sentByStaffId, - createdAt: messages.createdAt, - deliveredAt: messages.deliveredAt, - }) - .from(messages) - .where(eq(messages.conversationId, conversationId)) - .orderBy(desc(messages.createdAt)) - .limit(limit + 1); - if (cursor) { - const [cursorRow] = await db + const [cursorMsg] = await db .select({ createdAt: messages.createdAt }) .from(messages) .where(eq(messages.id, cursor)) .limit(1); - if (cursorRow?.createdAt) { - query = db - .select({ - id: messages.id, - direction: messages.direction, - body: messages.body, - status: messages.status, - sentByStaffId: messages.sentByStaffId, - createdAt: messages.createdAt, - deliveredAt: messages.deliveredAt, - }) + + if (cursorMsg) { + const rows = await db + .select() .from(messages) - .where( - and( - eq(messages.conversationId, conversationId), - lt(messages.createdAt, cursorRow.createdAt) - ) - ) + .where(and(eq(messages.conversationId, conversationId), lt(messages.createdAt, cursorMsg.createdAt))) .orderBy(desc(messages.createdAt)) - .limit(limit + 1); + .limit(limit); + + return c.json({ messages: rows.reverse(), nextCursor: rows.length === limit ? rows[0]?.id : null }); } } - const rows = await query; - const hasMore = rows.length > limit; - if (hasMore) rows.pop(); + const rows = await db + .select() + .from(messages) + .where(eq(messages.conversationId, conversationId)) + .orderBy(desc(messages.createdAt)) + .limit(limit); - const lastRow = rows[rows.length - 1]; - const nextCursor = hasMore && lastRow ? lastRow.id : null; - return c.json({ items: rows, nextCursor }); + return c.json({ messages: rows.reverse(), nextCursor: null }); +}); + +// POST /api/conversations/:id/messages — send a message +const sendMessageSchema = z.object({ + body: z.string().min(1).max(1600), }); -// POST /api/conversations/:id/messages — Send a message conversationsRouter.post( "/:id/messages", zValidator("json", sendMessageSchema), async (c) => { const db = getDb(); + const businessId = c.get("staff").businessId; const staffRow = c.get("staff"); - if (!staffRow) return c.json({ error: "Unauthorized" }, 401); - const conversationId = c.req.param("id"); const { body } = c.req.valid("json"); - const [settings] = await db - .select({ id: businessSettings.id }) - .from(businessSettings) - .limit(1); - if (!settings) return c.json({ error: "Business not found" }, 404); - - const [conv] = await db - .select({ id: conversations.id, clientId: conversations.clientId }) + // Verify staff owns this conversation + const [conversation] = await db + .select() .from(conversations) - .where( - and(eq(conversations.id, conversationId), eq(conversations.businessId, settings.id)) - ) + .where(and(eq(conversations.id, conversationId), eq(conversations.businessId, businessId))) .limit(1); - if (!conv) return c.json({ error: "Not found" }, 404); - const result = await sendMessage({ - businessId: settings.id, - clientId: conv.clientId, - body, - sentByStaffId: staffRow.id, - }); + if (!conversation) { + return c.json({ error: "Not found" }, 404); + } - if (result.suppressed) { + // Check if client has opted out + const [client] = await db + .select({ optedOutAt: clients.optedOutAt }) + .from(clients) + .where(eq(clients.id, conversation.clientId)) + .limit(1); + + if (client?.optedOutAt) { return c.json({ error: "Client has opted out of SMS" }, 409); } + // Create outbound message const [msg] = await db - .select({ - id: messages.id, - direction: messages.direction, - body: messages.body, - status: messages.status, - sentByStaffId: messages.sentByStaffId, - createdAt: messages.createdAt, - deliveredAt: messages.deliveredAt, + .insert(messages) + .values({ + conversationId, + direction: "outbound", + body, + status: "queued", + sentByStaffId: staffRow.id, }) - .from(messages) - .where(eq(messages.id, result.messageId)) - .limit(1); + .returning(); + + // Update conversation lastMessageAt + await db + .update(conversations) + .set({ lastMessageAt: new Date() }) + .where(eq(conversations.id, conversationId)); + + // TODO: Enqueue Telnyx outbound job return c.json(msg, 201); } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ea51314..f7b42c6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { AppointmentsPage } from "./pages/Appointments.js"; import { ClientsPage } from "./pages/Clients.js"; import { ClientDetailPage } from "./pages/ClientDetailPage.js"; import { ServicesPage } from "./pages/Services.js"; +import { MessagesPage } from "./pages/Messages.js"; import { StaffPage } from "./pages/Staff.js"; import { InvoicesPage } from "./pages/Invoices.js"; import { BookPage } from "./pages/Book.js"; @@ -170,6 +171,7 @@ function LoginPage() { const NAV_LINKS = [ { to: "/admin", label: "Appointments" }, + { to: "/admin/messages", label: "Messages" }, { to: "/admin/clients", label: "Clients" }, { to: "/admin/services", label: "Services" }, { to: "/admin/staff", label: "Staff" }, @@ -296,6 +298,7 @@ function AdminLayout() {
} /> + } /> } /> } /> } /> diff --git a/apps/web/src/__tests__/Messages.test.tsx b/apps/web/src/__tests__/Messages.test.tsx new file mode 100644 index 0000000..c02cfc1 --- /dev/null +++ b/apps/web/src/__tests__/Messages.test.tsx @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MessagesPage } from "../pages/Messages.js"; + +const mockConversations = [ + { + id: "conv-1", + clientId: "client-1", + clientName: "Alice Smith", + channel: "sms", + externalNumber: "+1234567890", + lastMessageAt: "2026-05-14T10:00:00Z", + staffReadAt: null, + lastMessageBody: "Hello, is my dog ready?", + unreadCount: 2, + status: "active", + }, + { + id: "conv-2", + clientId: "client-2", + clientName: "Bob Jones", + channel: "sms", + externalNumber: "+1987654321", + lastMessageAt: "2026-05-13T08:00:00Z", + staffReadAt: "2026-05-13T09:00:00Z", + lastMessageBody: "Thanks for the update", + unreadCount: 0, + status: "active", + }, +]; + +const mockMessages = [ + { + id: "msg-1", + direction: "inbound" as const, + body: "Hello, is my dog ready?", + status: "delivered", + createdAt: "2026-05-14T10:00:00Z", + sentByStaffId: null, + }, + { + id: "msg-2", + direction: "outbound" as const, + body: "Yes, she is all done!", + status: "delivered", + createdAt: "2026-05-14T10:05:00Z", + sentByStaffId: "staff-1", + }, +]; + +const makeResponse = (data: unknown): Response => { + return { + ok: true, + json: () => Promise.resolve(data), + } as Response; +}; + +const makeResponseWithStatus = (data: unknown, status: number): Response => { + return { + ok: true, + status, + json: () => Promise.resolve(data), + } as Response; +}; + +beforeEach(() => { + global.fetch = vi.fn(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("MessagesPage", () => { + it("renders empty state when no conversations", async () => { + vi.mocked(global.fetch).mockResolvedValue(makeResponse([])); + + render(); + await waitFor(() => { + expect(screen.getByText("No conversations yet")).toBeInTheDocument(); + }); + }); + + it("renders conversation list", async () => { + vi.mocked(global.fetch).mockResolvedValue(makeResponse(mockConversations)); + + render(); + await waitFor(() => { + expect(screen.getByText("Alice Smith")).toBeInTheDocument(); + expect(screen.getByText("Bob Jones")).toBeInTheDocument(); + }); + + const unreadBadges = screen.getAllByText("2"); + expect(unreadBadges).toHaveLength(1); + }); + + it("loads and displays messages when thread is selected", async () => { + vi.mocked(global.fetch).mockImplementation((input) => { + const url = String(input); + if (url === "/api/conversations?limit=20") { + return Promise.resolve(makeResponse(mockConversations)); + } + if (url === "/api/conversations/conv-1/messages?limit=50") { + return Promise.resolve(makeResponse({ messages: mockMessages })); + } + return Promise.resolve(makeResponseWithStatus(null, 404)); + }); + + render(); + + await waitFor(() => screen.getByText("Alice Smith")); + fireEvent.click(screen.getByText("Alice Smith")); + + await waitFor(() => { + expect(screen.getByText("Hello, is my dog ready?")).toBeInTheDocument(); + expect(screen.getByText("Yes, she is all done!")).toBeInTheDocument(); + }); + }); + + it("sends a message on form submit", async () => { + let capturedBody: unknown = null; + vi.mocked(global.fetch).mockImplementation((input, init) => { + const url = String(input); + if (url.includes("/messages") && init?.method === "POST") { + capturedBody = init?.body; + return Promise.resolve(makeResponseWithStatus({ + id: "msg-new", + direction: "outbound", + body: "Test message", + status: "queued", + createdAt: new Date().toISOString(), + sentByStaffId: "staff-1", + }, 201)); + } + return Promise.resolve(makeResponse(mockConversations)); + }); + + render(); + + await waitFor(() => screen.getByText("Alice Smith")); + fireEvent.click(screen.getByText("Alice Smith")); + + await waitFor(() => screen.getByPlaceholderText("Type a message…")); + fireEvent.change(screen.getByPlaceholderText("Type a message…"), { + target: { value: "Test message" }, + }); + fireEvent.click(screen.getByText("Send")); + + await waitFor(() => { + expect(capturedBody).toBe('{"body":"Test message"}'); + }); + }); +}); diff --git a/apps/web/src/pages/Messages.tsx b/apps/web/src/pages/Messages.tsx new file mode 100644 index 0000000..2e1743f --- /dev/null +++ b/apps/web/src/pages/Messages.tsx @@ -0,0 +1,275 @@ +import { useEffect, useState, useRef } from "react"; + +interface Conversation { + id: string; + clientId: string; + clientName: string; + channel: string; + externalNumber: string; + lastMessageAt: string | null; + staffReadAt: string | null; + lastMessageBody: string | null; + unreadCount: number; + status: string; +} + +interface Message { + id: string; + direction: "inbound" | "outbound"; + body: string | null; + status: string; + createdAt: string; + sentByStaffId: string | null; +} + +function relativeTime(dateStr: string | null): string { + if (!dateStr) return ""; + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function truncate(text: string | null, max: number): string { + if (!text) return ""; + return text.length > max ? text.slice(0, max) + "…" : text; +} + +export function MessagesPage() { + const [conversations, setConversations] = useState([]); + const [messages, setMessages] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(true); + const [messagesLoading, setMessagesLoading] = useState(false); + const [error, setError] = useState(null); + const [messageError, setMessageError] = useState(null); + const [body, setBody] = useState(""); + const [sending, setSending] = useState(false); + const messagesEndRef = useRef(null); + + async function loadConversations() { + try { + const res = await fetch("/api/conversations?limit=20"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as Conversation[]; + setConversations(data); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load conversations"); + } + } + + async function loadMessages(conversationId: string) { + setMessagesLoading(true); + setMessageError(null); + try { + const res = await fetch(`/api/conversations/${conversationId}/messages?limit=50`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as { messages: Message[] }; + setMessages(data.messages); + } catch (e: unknown) { + setMessageError(e instanceof Error ? e.message : "Failed to load messages"); + } finally { + setMessagesLoading(false); + } + } + + useEffect(() => { + loadConversations().finally(() => setLoading(false)); + const interval = setInterval(loadConversations, 10000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (selectedId) { + loadMessages(selectedId); + } else { + setMessages([]); + } + }, [selectedId]); + + useEffect(() => { + if (messages.length > 0) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [messages]); + + async function handleSend(e: React.FormEvent) { + e.preventDefault(); + if (!selectedId || !body.trim() || sending) return; + setSending(true); + setMessageError(null); + + const optimistic: Message = { + id: `temp-${Date.now()}`, + direction: "outbound", + body: body.trim(), + status: "queued", + createdAt: new Date().toISOString(), + sentByStaffId: null, + }; + + setMessages((prev) => [...prev, optimistic]); + const currentBody = body; + setBody(""); + + try { + const res = await fetch(`/api/conversations/${selectedId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: currentBody }), + }); + + if (res.status === 409) { + const data = (await res.json()) as { error?: string }; + setMessageError(data.error ?? "Client has opted out of SMS"); + setMessages((prev) => prev.filter((m) => m.id !== optimistic.id)); + return; + } + + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error ?? `HTTP ${res.status}`); + } + + const sent = (await res.json()) as Message; + setMessages((prev) => prev.map((m) => (m.id === optimistic.id ? sent : m))); + loadConversations(); + } catch (e: unknown) { + setMessageError(e instanceof Error ? e.message : "Failed to send message"); + setMessages((prev) => prev.filter((m) => m.id !== optimistic.id)); + } finally { + setSending(false); + } + } + + return ( +
+ {/* Thread list */} +
+
+ Conversations +
+ {loading ? ( +

Loading…

+ ) : error ? ( +

{error}

+ ) : conversations.length === 0 ? ( +

No conversations yet

+ ) : ( + conversations.map((conv) => ( +
setSelectedId(conv.id)} + style={{ + padding: "0.75rem 1rem", + borderBottom: "1px solid #f3f4f6", + cursor: "pointer", + background: selectedId === conv.id ? "#ecfdf5" : "transparent", + }} + > +
+ {conv.clientName} + {conv.unreadCount > 0 && ( + + {conv.unreadCount} + + )} +
+
+ {truncate(conv.lastMessageBody, 60)} +
+
+ {relativeTime(conv.lastMessageAt)} +
+
+ )) + )} +
+ + {/* Conversation view */} +
+ {!selectedId ? ( +
+ Select a conversation +
+ ) : messagesLoading ? ( +
+ Loading messages… +
+ ) : ( + <> +
+ {messages.map((msg) => ( +
+
+ {msg.body} +
+ + {new Date(msg.createdAt).toLocaleString()} + +
+ ))} +
+
+ + {messageError && ( +
+ {messageError} +
+ )} + +
+ setBody(e.target.value)} + placeholder="Type a message…" + disabled={sending} + style={{ flex: 1, padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }} + /> + +
+ + )} +
+
+ ); +} diff --git a/packages/db/migrations/0032_add_staff_read_at.sql b/packages/db/migrations/0032_add_staff_read_at.sql new file mode 100644 index 0000000..8c525a9 --- /dev/null +++ b/packages/db/migrations/0032_add_staff_read_at.sql @@ -0,0 +1,4 @@ +-- Add staffReadAt column to conversations for unread tracking +ALTER TABLE "conversations" ADD COLUMN "staff_read_at" timestamp; + +CREATE INDEX "idx_conversations_business_id_staff_read_at" ON "conversations"("business_id", "staff_read_at" DESC); diff --git a/packages/db/migrations/meta/0032_snapshot.json b/packages/db/migrations/meta/0032_snapshot.json new file mode 100644 index 0000000..87a385f --- /dev/null +++ b/packages/db/migrations/meta/0032_snapshot.json @@ -0,0 +1,3164 @@ +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "prevId": "619a413f-f5dc-4b63-acca-1ebac490cc4c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_groups": { + "name": "appointment_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_groups_client_id_clients_id_fk": { + "name": "appointment_groups_client_id_clients_id_fk", + "tableFrom": "appointment_groups", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointments": { + "name": "appointments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "bather_staff_id": { + "name": "bather_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "series_index": { + "name": "series_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "confirmation_status": { + "name": "confirmation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "buffer_minutes": { + "name": "buffer_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "confirmation_token": { + "name": "confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_notes": { + "name": "customer_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_appointments_client_id": { + "name": "idx_appointments_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_appointments_staff_id": { + "name": "idx_appointments_staff_id", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_appointments_start_time": { + "name": "idx_appointments_start_time", + "columns": [ + { + "expression": "start_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_appointments_status": { + "name": "idx_appointments_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "appointments_client_id_clients_id_fk": { + "name": "appointments_client_id_clients_id_fk", + "tableFrom": "appointments", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_pet_id_pets_id_fk": { + "name": "appointments_pet_id_pets_id_fk", + "tableFrom": "appointments", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_service_id_services_id_fk": { + "name": "appointments_service_id_services_id_fk", + "tableFrom": "appointments", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "appointments_staff_id_staff_id_fk": { + "name": "appointments_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_bather_staff_id_staff_id_fk": { + "name": "appointments_bather_staff_id_staff_id_fk", + "tableFrom": "appointments", + "tableTo": "staff", + "columnsFrom": [ + "bather_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_series_id_recurring_series_id_fk": { + "name": "appointments_series_id_recurring_series_id_fk", + "tableFrom": "appointments", + "tableTo": "recurring_series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "appointments_group_id_appointment_groups_id_fk": { + "name": "appointments_group_id_appointment_groups_id_fk", + "tableFrom": "appointments", + "tableTo": "appointment_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "appointments_confirmation_token_unique": { + "name": "appointments_confirmation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "confirmation_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_provider_config": { + "name": "auth_provider_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_base_url": { + "name": "internal_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openid profile email'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_provider_config_provider_id_unique": { + "name": "auth_provider_config_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.buffer_time_rules": { + "name": "buffer_time_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "size_category": { + "name": "size_category", + "type": "pet_size_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "coat_type": { + "name": "coat_type", + "type": "coat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "buffer_minutes": { + "name": "buffer_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_buffer_rules_service_id": { + "name": "idx_buffer_rules_service_id", + "columns": [ + { + "expression": "service_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "buffer_time_rules_service_id_services_id_fk": { + "name": "buffer_time_rules_service_id_services_id_fk", + "tableFrom": "buffer_time_rules", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "buffer_time_rules_service_id_size_category_coat_type_unique": { + "name": "buffer_time_rules_service_id_size_category_coat_type_unique", + "nullsNotDistinct": false, + "columns": [ + "service_id", + "size_category", + "coat_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.business_settings": { + "name": "business_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "business_name": { + "name": "business_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'GroomBook'" + }, + "logo_base64": { + "name": "logo_base64", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_mime_type": { + "name": "logo_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_key": { + "name": "logo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#4f8a6f'" + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#8b7355'" + }, + "messaging_phone_number": { + "name": "messaging_phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnyx_messaging_profile_id": { + "name": "telnyx_messaging_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_opt_out": { + "name": "email_opt_out", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sms_opt_in": { + "name": "sms_opt_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sms_consent_date": { + "name": "sms_consent_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sms_opt_out_date": { + "name": "sms_opt_out_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sms_consent_text": { + "name": "sms_consent_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "client_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_clients_email": { + "name": "idx_clients_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "business_id": { + "name": "business_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "messaging_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_number": { + "name": "external_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "business_number": { + "name": "business_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "staff_read_at": { + "name": "staff_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_conversations_business_id_last_message_at": { + "name": "idx_conversations_business_id_last_message_at", + "columns": [ + { + "expression": "business_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_conversations_business_id_staff_read_at": { + "name": "idx_conversations_business_id_staff_read_at", + "columns": [ + { + "expression": "business_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "staff_read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "predicate": null + } + }, + "foreignKeys": { + "conversations_client_id_clients_id_fk": { + "name": "conversations_client_id_clients_id_fk", + "tableFrom": "conversations", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_conversations_business_client_number": { + "name": "uq_conversations_business_client_number", + "nullsNotDistinct": false, + "columns": [ + "business_id", + "client_id", + "business_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grooming_visit_logs": { + "name": "grooming_visit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "products_used": { + "name": "products_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "groomed_at": { + "name": "groomed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "grooming_visit_logs_pet_id_pets_id_fk": { + "name": "grooming_visit_logs_pet_id_pets_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grooming_visit_logs_appointment_id_appointments_id_fk": { + "name": "grooming_visit_logs_appointment_id_appointments_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "grooming_visit_logs_staff_id_staff_id_fk": { + "name": "grooming_visit_logs_staff_id_staff_id_fk", + "tableFrom": "grooming_visit_logs", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_audit_logs": { + "name": "impersonation_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_visited": { + "name": "page_visited", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_audit_logs_session_id_idx": { + "name": "impersonation_audit_logs_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_audit_logs_session_id_impersonation_sessions_id_fk": { + "name": "impersonation_audit_logs_session_id_impersonation_sessions_id_fk", + "tableFrom": "impersonation_audit_logs", + "tableTo": "impersonation_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impersonation_sessions": { + "name": "impersonation_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "impersonation_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "impersonation_sessions_staff_id_status_idx": { + "name": "impersonation_sessions_staff_id_status_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "impersonation_sessions_client_id_idx": { + "name": "impersonation_sessions_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impersonation_sessions_staff_id_staff_id_fk": { + "name": "impersonation_sessions_staff_id_staff_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "impersonation_sessions_client_id_clients_id_fk": { + "name": "impersonation_sessions_client_id_clients_id_fk", + "tableFrom": "impersonation_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "unit_price_cents": { + "name": "unit_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_invoice_line_items_invoice_id": { + "name": "idx_invoice_line_items_invoice_id", + "columns": [ + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_line_items_invoice_id_invoices_id_fk": { + "name": "invoice_line_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_tip_splits": { + "name": "invoice_tip_splits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "staff_name": { + "name": "staff_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_pct": { + "name": "share_pct", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "share_cents": { + "name": "share_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_invoice_tip_splits_invoice_id": { + "name": "idx_invoice_tip_splits_invoice_id", + "columns": [ + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_tip_splits_invoice_id_invoices_id_fk": { + "name": "invoice_tip_splits_invoice_id_invoices_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoice_tip_splits_staff_id_staff_id_fk": { + "name": "invoice_tip_splits_staff_id_staff_id_fk", + "tableFrom": "invoice_tip_splits", + "tableTo": "staff", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subtotal_cents": { + "name": "subtotal_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tax_cents": { + "name": "tax_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tip_cents": { + "name": "tip_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cents": { + "name": "total_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invoice_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_refund_id": { + "name": "stripe_refund_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_failure_reason": { + "name": "payment_failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_invoices_client_id": { + "name": "idx_invoices_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_status": { + "name": "idx_invoices_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_created_at": { + "name": "idx_invoices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_stripe_payment_intent_id": { + "name": "idx_invoices_stripe_payment_intent_id", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_appointment_id_appointments_id_fk": { + "name": "invoices_appointment_id_appointments_id_fk", + "tableFrom": "invoices", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_attachments": { + "name": "message_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_media_id": { + "name": "provider_media_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_attachments_message_id": { + "name": "idx_message_attachments_message_id", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_attachments_message_id_messages_id_fk": { + "name": "message_attachments_message_id_messages_id_fk", + "tableFrom": "message_attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_consent_events": { + "name": "message_consent_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "business_id": { + "name": "business_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "message_consent_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_message_consent_events_client_id": { + "name": "idx_message_consent_events_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_consent_events_client_id_clients_id_fk": { + "name": "message_consent_events_client_id_clients_id_fk", + "tableFrom": "message_consent_events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "message_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "message_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_by_staff_id": { + "name": "sent_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "read_by_client_at": { + "name": "read_by_client_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_messages_conversation_id_created_at": { + "name": "idx_messages_conversation_id_created_at", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sent_by_staff_id_staff_id_fk": { + "name": "messages_sent_by_staff_id_staff_id_fk", + "tableFrom": "messages", + "tableTo": "staff", + "columnsFrom": [ + "sent_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_messages_provider_message_id": { + "name": "uq_messages_provider_message_id", + "nullsNotDistinct": false, + "columns": [ + "provider_message_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pets": { + "name": "pets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "species": { + "name": "species", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "breed": { + "name": "breed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_kg": { + "name": "weight_kg", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "health_alerts": { + "name": "health_alerts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grooming_notes": { + "name": "grooming_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cut_style": { + "name": "cut_style", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shampoo_preference": { + "name": "shampoo_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "special_care_notes": { + "name": "special_care_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "photo_key": { + "name": "photo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_uploaded_at": { + "name": "photo_uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_category": { + "name": "size_category", + "type": "pet_size_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "coat_type": { + "name": "coat_type", + "type": "coat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_pets_client_id": { + "name": "idx_pets_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pets_client_id_clients_id_fk": { + "name": "pets_client_id_clients_id_fk", + "tableFrom": "pets", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recurring_series": { + "name": "recurring_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "frequency_weeks": { + "name": "frequency_weeks", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refunds": { + "name": "refunds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_refund_id": { + "name": "stripe_refund_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_refunds_invoice_id": { + "name": "idx_refunds_invoice_id", + "columns": [ + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_refunds_idempotency_key": { + "name": "idx_refunds_idempotency_key", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refunds_invoice_id_invoices_id_fk": { + "name": "refunds_invoice_id_invoices_id_fk", + "tableFrom": "refunds", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refunds_idempotency_key_unique": { + "name": "refunds_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminder_logs": { + "name": "reminder_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reminder_type": { + "name": "reminder_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reminder_logs_appointment_id_appointments_id_fk": { + "name": "reminder_logs_appointment_id_appointments_id_fk", + "tableFrom": "reminder_logs", + "tableTo": "appointments", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reminder_logs_appointment_id_reminder_type_channel_unique": { + "name": "reminder_logs_appointment_id_reminder_type_channel_unique", + "nullsNotDistinct": false, + "columns": [ + "appointment_id", + "reminder_type", + "channel" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.services": { + "name": "services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_price_cents": { + "name": "base_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "default_buffer_minutes": { + "name": "default_buffer_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "services_name_unique": { + "name": "services_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff": { + "name": "staff", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "staff_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'groomer'" + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ical_token": { + "name": "ical_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "staff_user_id_user_id_fk": { + "name": "staff_user_id_user_id_fk", + "tableFrom": "staff", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_email_unique": { + "name": "staff_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "staff_oidc_sub_unique": { + "name": "staff_oidc_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "oidc_sub" + ] + }, + "staff_ical_token_unique": { + "name": "staff_ical_token_unique", + "nullsNotDistinct": false, + "columns": [ + "ical_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entries": { + "name": "waitlist_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pet_id": { + "name": "pet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "service_id": { + "name": "service_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "preferred_date": { + "name": "preferred_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferred_time": { + "name": "preferred_time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "waitlist_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notified_at": { + "name": "notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_waitlist_client_id": { + "name": "idx_waitlist_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_preferred_date": { + "name": "idx_waitlist_preferred_date", + "columns": [ + { + "expression": "preferred_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_waitlist_status": { + "name": "idx_waitlist_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "waitlist_entries_client_id_clients_id_fk": { + "name": "waitlist_entries_client_id_clients_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_pet_id_pets_id_fk": { + "name": "waitlist_entries_pet_id_pets_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "pets", + "columnsFrom": [ + "pet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "waitlist_entries_service_id_services_id_fk": { + "name": "waitlist_entries_service_id_services_id_fk", + "tableFrom": "waitlist_entries", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "scheduled", + "confirmed", + "in_progress", + "completed", + "cancelled", + "no_show" + ] + }, + "public.client_status": { + "name": "client_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.coat_type": { + "name": "coat_type", + "schema": "public", + "values": [ + "smooth", + "double", + "curly", + "wire", + "long", + "hairless" + ] + }, + "public.impersonation_session_status": { + "name": "impersonation_session_status", + "schema": "public", + "values": [ + "active", + "ended", + "expired" + ] + }, + "public.invoice_status": { + "name": "invoice_status", + "schema": "public", + "values": [ + "draft", + "pending", + "paid", + "void" + ] + }, + "public.message_consent_kind": { + "name": "message_consent_kind", + "schema": "public", + "values": [ + "opt_in", + "opt_out", + "help" + ] + }, + "public.message_direction": { + "name": "message_direction", + "schema": "public", + "values": [ + "inbound", + "outbound" + ] + }, + "public.message_status": { + "name": "message_status", + "schema": "public", + "values": [ + "queued", + "sent", + "delivered", + "failed", + "received" + ] + }, + "public.messaging_channel": { + "name": "messaging_channel", + "schema": "public", + "values": [ + "sms", + "mms" + ] + }, + "public.payment_method": { + "name": "payment_method", + "schema": "public", + "values": [ + "cash", + "card", + "check", + "other" + ] + }, + "public.pet_size_category": { + "name": "pet_size_category", + "schema": "public", + "values": [ + "small", + "medium", + "large", + "xlarge" + ] + }, + "public.staff_role": { + "name": "staff_role", + "schema": "public", + "values": [ + "groomer", + "receptionist", + "manager" + ] + }, + "public.waitlist_status": { + "name": "waitlist_status", + "schema": "public", + "values": [ + "active", + "notified", + "expired", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index c78337c..e8b8b8d 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -220,7 +220,7 @@ "breakpoints": true }, { - "idx": 31, + "idx": 32, "version": "7", "when": 1778818472097, "tag": "0032_staff_read_at", diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3894f47..8b1532d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -463,6 +463,7 @@ export const conversations = pgTable( businessNumber: text("business_number").notNull(), lastMessageAt: timestamp("last_message_at"), status: text("status").notNull().default("active"), + staffReadAt: timestamp("staff_read_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), staffReadAt: timestamp("staff_read_at"),