diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 286a969..e51436b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,6 +18,7 @@ import { groomingLogsRouter } from "./routes/groomingLogs.js"; import { impersonationRouter } from "./routes/impersonation.js"; import { settingsRouter } from "./routes/settings.js"; import { searchRouter } from "./routes/search.js"; +import { getPresignedGetUrl } from "./lib/s3.js"; import { calendarRouter } from "./routes/calendar.js"; import { setupRouter } from "./routes/setup.js"; import { getDb, businessSettings, eq, staff } from "@groombook/db"; @@ -55,11 +56,22 @@ app.route("/api/dev", devRouter); app.get("/api/branding", async (c) => { const db = getDb(); const [row] = await db.select().from(businessSettings).limit(1); - const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null }; + const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null }; + + let logoUrl: string | null = null; + if (settings.logoKey) { + try { + logoUrl = await getPresignedGetUrl(settings.logoKey); + } catch { + // If S3 URL generation fails, fall back to legacy base64 + } + } + return c.json({ businessName: settings.businessName, primaryColor: settings.primaryColor, accentColor: settings.accentColor, + logoUrl, logoBase64: settings.logoBase64, logoMimeType: settings.logoMimeType, }); diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 55332e4..102d015 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { eq, getDb, businessSettings } from "@groombook/db"; +import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js"; export const settingsRouter = new Hono(); @@ -23,11 +24,6 @@ const updateSettingsSchema = z.object({ businessName: z.string().min(1).max(200).optional(), primaryColor: z.string().regex(hexColorRegex, "Must be a hex color like #4f8a6f").optional(), accentColor: z.string().regex(hexColorRegex, "Must be a hex color like #8b7355").optional(), - logoBase64: z.string().max(700_000).nullable().optional(), // ~512KB base64 - logoMimeType: z - .enum(["image/png", "image/svg+xml", "image/jpeg", "image/webp"]) - .nullable() - .optional(), }); // PATCH /api/admin/settings — update business settings @@ -58,3 +54,119 @@ settingsRouter.patch( return c.json(updated); } ); + +// ─── Logo routes ────────────────────────────────────────────────────────────── + +const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/svg+xml", "image/jpeg", "image/webp"]); +const MAX_LOGO_SIZE = 512 * 1024; // 512 KB + +const logoUploadUrlSchema = z.object({ + contentType: z.string().refine((v) => ALLOWED_LOGO_TYPES.has(v), { + message: "contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp", + }), + fileSizeBytes: z.number().int().positive().max(MAX_LOGO_SIZE, { + message: "File must not exceed 512 KB", + }), +}); + +const logoConfirmSchema = z.object({ + key: z.string().min(1), +}); + +/** + * POST /api/admin/settings/logo/upload-url + * Returns a presigned S3 PUT URL and the object key for logo upload. + */ +settingsRouter.post( + "/logo/upload-url", + zValidator("json", logoUploadUrlSchema), + async (c) => { + const db = getDb(); + const { contentType, fileSizeBytes } = c.req.valid("json"); + + const rows = await db.select().from(businessSettings).limit(1); + if (!rows[0]) { + return c.json({ error: "Settings not found" }, 404); + } + const settingsId = rows[0].id; + + const ext = contentType.split("/")[1] ?? "png"; + const key = `logos/${settingsId}/${Date.now()}.${ext}`; + const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes); + + return c.json({ uploadUrl, key }); + } +); + +/** + * POST /api/admin/settings/logo/confirm + * Called after the client has successfully uploaded to the presigned URL. + * Records the object key in the DB and clears legacy base64 fields. + */ +settingsRouter.post( + "/logo/confirm", + zValidator("json", logoConfirmSchema), + async (c) => { + const db = getDb(); + const { key } = c.req.valid("json"); + + const rows = await db.select().from(businessSettings).limit(1); + if (!rows[0]) { + return c.json({ error: "Settings not found" }, 404); + } + const settingsId = rows[0].id; + + // Validate key prefix + if (!key.startsWith(`logos/${settingsId}/`)) { + return c.json({ error: "Invalid key" }, 400); + } + + // Delete previous S3 object if any + if (rows[0].logoKey) { + await deleteObject(rows[0].logoKey); + } + + const [updated] = await db + .update(businessSettings) + .set({ logoKey: key, logoBase64: null, logoMimeType: null, updatedAt: new Date() }) + .where(eq(businessSettings.id, settingsId)) + .returning(); + + return c.json({ ok: true, logoKey: updated.logoKey }); + } +); + +/** + * GET /api/admin/settings/logo + * Returns a presigned GET URL for the logo. + */ +settingsRouter.get("/logo", async (c) => { + const db = getDb(); + + const [row] = await db.select().from(businessSettings).limit(1); + if (!row) return c.json({ error: "Settings not found" }, 404); + if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); + + const url = await getPresignedGetUrl(row.logoKey); + return c.json({ url, logoKey: row.logoKey }); +}); + +/** + * DELETE /api/admin/settings/logo + * Removes the logo from S3 and clears the DB record. + */ +settingsRouter.delete("/logo", async (c) => { + const db = getDb(); + + const [row] = await db.select().from(businessSettings).limit(1); + if (!row) return c.json({ error: "Settings not found" }, 404); + if (!row.logoKey) return c.json({ error: "No logo on file" }, 404); + + await deleteObject(row.logoKey); + await db + .update(businessSettings) + .set({ logoKey: null, updatedAt: new Date() }) + .where(eq(businessSettings.id, row.id)); + + return c.json({ ok: true }); +}); diff --git a/apps/e2e/tests/admin-reports.spec.ts b/apps/e2e/tests/admin-reports.spec.ts new file mode 100644 index 0000000..631d8d7 --- /dev/null +++ b/apps/e2e/tests/admin-reports.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for admin reports page. + * Verifies that reports render with data when date range is set. + */ + +function getDateDaysAgo(days: number): string { + const d = new Date(); + d.setDate(d.getDate() - days); + return d.toISOString().slice(0, 10); +} + +const MOCK_SUMMARY = { + from: getDateDaysAgo(60), + to: new Date().toISOString().slice(0, 10), + revenue: { totalCents: 125000, paidInvoices: 15 }, + appointments: { total: 25, completed: 20, cancelled: 3, noShow: 2 }, + clients: { total: 42, new: 8 }, +}; + +const MOCK_REVENUE = { + byPeriod: [ + { period: "2026-03-01", totalCents: 45000, invoiceCount: 5 }, + { period: "2026-03-15", totalCents: 80000, invoiceCount: 10 }, + ], + byGroomer: [ + { staffId: "staff-1", staffName: "Alice Groomer", totalCents: 125000, invoiceCount: 15 }, + ], +}; + +test.describe("Admin Reports Data", () => { + test.beforeEach(async ({ page }) => { + // Login as staff + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("/admin"); + + // Mock all report endpoints + await page.route("**/api/reports/summary**", (route) => + route.fulfill({ json: MOCK_SUMMARY }) + ); + await page.route("**/api/reports/revenue**", (route) => + route.fulfill({ json: MOCK_REVENUE }) + ); + await page.route("**/api/reports/appointments**", (route) => + route.fulfill({ json: { byPeriod: [] } }) + ); + await page.route("**/api/reports/services**", (route) => + route.fulfill({ json: { rows: [] } }) + ); + await page.route("**/api/reports/clients**", (route) => + route.fulfill({ json: { newClients: [], activeInPeriodCount: 10, churnRisk: [], churnRiskTotal: 0 } }) + ); + }); + + test("reports page loads and displays KPI cards", async ({ page }) => { + await page.goto("/admin/reports"); + + // Wait for reports to load + await expect(page.locator("h1")).toContainText("Reports", { timeout: 10_000 }); + + // Should show KPI cards with data + await expect(page.locator("text=/Revenue/i")).toBeVisible(); + await expect(page.locator("text=/Appointments/i")).toBeVisible(); + await expect(page.locator("text=/New Clients/i")).toBeVisible(); + }); + + test("reports show non-zero data when data exists", async ({ page }) => { + await page.goto("/admin/reports"); + + // Wait for data to load + await page.waitForTimeout(2_000); + + // Revenue card should show non-zero value + const revenueCard = page.locator("text=/\\$1,250/"); // $1250 = 125000 cents + await expect(revenueCard.or(page.locator("text=/Revenue/"))).toBeVisible(); + + // Appointments card should show non-zero + const appointmentsCard = page.locator("text=/25/"); // 25 total appointments + await expect(appointmentsCard.or(page.locator("text=/Appointments/"))).toBeVisible(); + }); + + test("reports date range inputs exist and are functional", async ({ page }) => { + await page.goto("/admin/reports"); + + // Wait for page to load + await expect(page.locator("h1")).toContainText("Reports", { timeout: 10_000 }); + + // Date inputs should exist + const fromInput = page.locator('input[type="date"]').first(); + const toInput = page.locator('input[type="date"]').nth(1); + + await expect(fromInput).toBeVisible(); + await expect(toInput).toBeVisible(); + + // Change date range - set to last 60 days + const sixtyDaysAgo = getDateDaysAgo(60); + await fromInput.fill(sixtyDaysAgo); + + // Click refresh + await page.getByRole("button", { name: /Refresh/i }).click(); + + // Wait for data to reload + await page.waitForTimeout(1_000); + + // Reports should still display + await expect(page.locator("h1")).toContainText("Reports"); + }); + + test("reports page renders charts/metrics sections", async ({ page }) => { + await page.goto("/admin/reports"); + + // Wait for reports to load + await page.waitForTimeout(2_000); + + // Should show section headers + await expect(page.locator("text=/Revenue by/i")).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/admin-services.spec.ts b/apps/e2e/tests/admin-services.spec.ts new file mode 100644 index 0000000..738b658 --- /dev/null +++ b/apps/e2e/tests/admin-services.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for services deduplication. + * Verifies that service names are unique in both admin services list + * and in the booking service picker. + */ + +const MOCK_SERVICES = [ + { id: "svc-1", name: "Full Groom", description: "Bath and haircut", basePriceCents: 7500, durationMinutes: 90, isActive: true }, + { id: "svc-2", name: "Bath Only", description: "Just the bath", basePriceCents: 3500, durationMinutes: 45, isActive: true }, + { id: "svc-3", name: "Nail Trim", description: "Just nails", basePriceCents: 1500, durationMinutes: 15, isActive: true }, + { id: "svc-4", name: "Full Groom", description: "Dup name", basePriceCents: 7500, durationMinutes: 90, isActive: true }, // duplicate name for testing +]; + +test.describe("Services Deduplication", () => { + test.beforeEach(async ({ page }) => { + // Login as staff + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("/admin"); + + // Mock services endpoint + await page.route("**/api/services**", (route) => + route.fulfill({ json: MOCK_SERVICES }) + ); + }); + + test("admin services page shows no duplicate service names", async ({ page }) => { + await page.goto("/admin/services"); + + // Wait for services to load + await page.waitForTimeout(1_000); + + // Collect all service names from the table + const serviceNameCells = page.locator("table tbody tr td:first-child"); + const count = await serviceNameCells.count(); + + const names: string[] = []; + for (let i = 0; i < count; i++) { + const text = await serviceNameCells.nth(i).textContent(); + if (text) names.push(text.trim()); + } + + // Check for duplicates + const duplicates = names.filter((name, index) => names.indexOf(name) !== index); + + // Assert no duplicate names + expect(duplicates, `Found duplicate service names: ${duplicates.join(", ")}`).toHaveLength(0); + + // Verify all names are unique using Set comparison + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(names.length); + }); + + test("admin services page renders all services", async ({ page }) => { + await page.goto("/admin/services"); + + // Wait for table to render + await expect(page.locator("table")).toBeVisible({ timeout: 10_000 }); + + // Should show the heading + await expect(page.locator("h1")).toContainText("Services"); + + // Should show all unique services + const rowCount = await page.locator("table tbody tr").count(); + expect(rowCount).toBeGreaterThan(0); + }); + + test("booking service picker shows no duplicates", async ({ page }) => { + await page.goto("/admin/book"); + + // Wait for services to load in the booking wizard + await page.waitForTimeout(1_000); + + // Collect service names from the picker + const serviceCards = page.locator("text=/Full Groom|Bath Only|Nail Trim/"); + const serviceNames: string[] = []; + + // Get all text content that looks like service names + const allText = await page.locator("body").textContent(); + if (allText) { + const matches = allText.match(/(?:Full Groom|Bath Only|Nail Trim)/g); + if (matches) { + serviceNames.push(...matches); + } + } + + // Check for duplicates in the booking picker + const duplicates = serviceNames.filter((name, index) => serviceNames.indexOf(name) !== index); + + expect(duplicates, `Found duplicate service names in booking picker: ${duplicates.join(", ")}`).toHaveLength(0); + }); + + test("booking wizard step 1 shows services", async ({ page }) => { + await page.goto("/admin/book"); + + // Should show service selection step + await expect(page.getByText("Choose a service")).toBeVisible({ timeout: 10_000 }); + + // Should show at least one service + await expect(page.getByText("Full Groom")).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/console-health.spec.ts b/apps/e2e/tests/console-health.spec.ts new file mode 100644 index 0000000..96dce8a --- /dev/null +++ b/apps/e2e/tests/console-health.spec.ts @@ -0,0 +1,124 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for baseline console health. + * Verifies no 404s for critical assets and no JS exceptions on initial render. + */ + +test.describe("Console Health", () => { + test("admin page loads without 404s or JS errors", async ({ page }) => { + const consoleMessages: { type: string; text: string }[] = []; + const failedRequests: string[] = []; + + // Capture console messages + page.on("console", (msg) => { + consoleMessages.push({ type: msg.type(), text: msg.text() }); + }); + + // Capture failed requests + page.on("requestfailed", (request) => { + failedRequests.push(request.url()); + }); + + // Navigate to admin + await page.goto("/admin"); + + // Wait for initial render + await page.waitForLoadState("networkidle"); + + // Check no 404s for critical assets + const criticalAssetFailures = failedRequests.filter( + (url) => + url.includes("favicon") || + url.includes("manifest") || + url.includes(".js") || + url.includes(".css") || + url.includes(".png") || + url.includes(".svg") + ); + + expect( + criticalAssetFailures, + `Critical asset 404s found: ${criticalAssetFailures.join(", ")}` + ).toHaveLength(0); + + // Check no JS exceptions + const jsErrors = consoleMessages.filter( + (m) => m.type === "error" && !m.text.includes("favicon") + ); + + expect(jsErrors, `JS errors found: ${JSON.stringify(jsErrors)}`).toHaveLength(0); + }); + + test("portal page loads without 404s or JS errors", async ({ page }) => { + const consoleMessages: { type: string; text: string }[] = []; + const failedRequests: string[] = []; + + page.on("console", (msg) => { + consoleMessages.push({ type: msg.type(), text: msg.text() }); + }); + + page.on("requestfailed", (request) => { + failedRequests.push(request.url()); + }); + + // Login as client first + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("/"); + + // Wait for initial render + await page.waitForLoadState("networkidle"); + + // Check no 404s for critical assets + const criticalAssetFailures = failedRequests.filter( + (url) => + url.includes("favicon") || + url.includes("manifest") || + url.includes(".js") || + url.includes(".css") || + url.includes(".png") || + url.includes(".svg") + ); + + expect( + criticalAssetFailures, + `Critical asset 404s found: ${criticalAssetFailures.join(", ")}` + ).toHaveLength(0); + + // Check no JS exceptions + const jsErrors = consoleMessages.filter( + (m) => m.type === "error" && !m.text.includes("favicon") + ); + + expect(jsErrors, `JS errors found: ${JSON.stringify(jsErrors)}`).toHaveLength(0); + }); + + test("no uncaught exceptions on page load", async ({ page }) => { + const jsErrors: string[] = []; + + page.on("pageerror", (error) => { + jsErrors.push(error.message); + }); + + await page.goto("/admin"); + await page.waitForLoadState("domcontentloaded"); + + expect(jsErrors, `Uncaught exceptions: ${jsErrors.join(", ")}`).toHaveLength(0); + }); + + test("portal dashboard renders without uncaught exceptions", async ({ page }) => { + const jsErrors: string[] = []; + + page.on("pageerror", (error) => { + jsErrors.push(error.message); + }); + + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("/"); + await page.waitForLoadState("domcontentloaded"); + + expect(jsErrors, `Uncaught exceptions: ${jsErrors.join(", ")}`).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/portal-auth.spec.ts b/apps/e2e/tests/portal-auth.spec.ts new file mode 100644 index 0000000..5df7bca --- /dev/null +++ b/apps/e2e/tests/portal-auth.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for client portal authentication via dev login selector. + * Verifies the fix for the "Hi, Guest" bug where client name was not displayed. + */ + +test.describe("Client Portal Auth", () => { + test("portal shows client name after login via dev selector", async ({ page }) => { + // Navigate to login + await page.goto("/login"); + + // Select Carol Client (has 2 pets per fixtures.ts) + await page.getByText("Carol Client").click(); + + // Should navigate to portal home + await expect(page).toHaveURL("/"); + + // Heading should contain client name, NOT "Hi, Guest" or "Please sign in" + const greeting = page.locator("text=/Hi,\\s*Carol/"); + await expect(greeting).toBeVisible({ timeout: 10_000 }); + + // Should NOT show "Hi, Guest" + await expect(page.locator("text=/Hi,\\s*Guest/")).not.toBeVisible(); + + // Portal dashboard should render with actual content + await expect(page.locator("nav")).toBeVisible(); + // Dashboard section should be visible (Home nav item is active by default) + await expect(page.getByRole("button", { name: /Home/i })).toBeVisible(); + }); + + test("portal does not show Please sign in after client login", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("/"); + + // Should not show "Please sign in" message + await expect(page.locator("text=/Please sign in/i")).not.toBeVisible({ timeout: 5_000 }); + }); + + test("different client gets correct name displayed", async ({ page }) => { + await page.goto("/login"); + + // Select Dave Client (has 1 pet per fixtures.ts) + await page.getByText("Dave Client").click(); + + await expect(page).toHaveURL("/"); + + // Should show Dave's name, not Carol's + const greeting = page.locator("text=/Hi,\\s*Dave/"); + await expect(greeting).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=/Hi,\\s*Carol/")).not.toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/portal-data.spec.ts b/apps/e2e/tests/portal-data.spec.ts new file mode 100644 index 0000000..5e9b2ed --- /dev/null +++ b/apps/e2e/tests/portal-data.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for portal data integrity. + * Verifies that portal sections render correctly without JS errors + * and without showing "Please sign in" messages. + */ + +const MOCK_PET = { + id: "pet-1", + name: "Buddy", + species: "dog", + breed: "Golden Retriever", + clientId: "client-1", +}; + +const MOCK_SESSION = { + id: "session-1", + staffId: "staff-1", + clientId: "client-1", + reason: "E2E test", + status: "active", + startedAt: new Date().toISOString(), + endedAt: null, + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + createdAt: new Date().toISOString(), +}; + +test.describe("Portal Data Integrity", () => { + test.beforeEach(async ({ page }) => { + // Login as Carol Client first + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("/"); + + // Mock portal/me for client data + await page.route("**/api/portal/me", (route) => + route.fulfill({ json: { id: "client-1", name: "Carol Client", email: "carol@example.com" } }) + ); + + // Mock portal session endpoint + await page.route("**/api/portal/dev-session", (route) => + route.fulfill({ json: MOCK_SESSION }) + ); + }); + + test("appointments section renders without Please sign in", async ({ page }) => { + // Navigate to appointments section + await page.getByRole("button", { name: /Appointments/i }).click(); + + // Should not show "Please sign in" message + await expect(page.locator("text=/Please sign in/i")).not.toBeVisible({ timeout: 5_000 }); + + // Content area should be present + await expect(page.locator("main")).toBeVisible(); + }); + + test("pets section renders with content or empty state", async ({ page }) => { + // Mock pets endpoint + await page.route("**/api/pets**", (route) => + route.fulfill({ json: [MOCK_PET] }) + ); + + // Navigate to pets section + await page.getByRole("button", { name: /My Pets/i }).click(); + + // Should not show "Please sign in" message + await expect(page.locator("text=/Please sign in/i")).not.toBeVisible({ timeout: 5_000 }); + + // Content should render - either pet card or empty state + await expect(page.locator("main")).toBeVisible(); + }); + + test("billing section renders without JS errors", async ({ page }) => { + // Mock billing endpoint + await page.route("**/api/billing**", (route) => + route.fulfill({ json: { invoices: [], balanceCents: 0 } }) + ); + + const consoleErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + + // Navigate to billing section + await page.getByRole("button", { name: /Billing/i }).click(); + + // Wait for content to load + await page.waitForTimeout(1_000); + + // Should not show "Please sign in" message + await expect(page.locator("text=/Please sign in/i")).not.toBeVisible({ timeout: 5_000 }); + + // No JS errors should have occurred + const jsErrors = consoleErrors.filter(e => !e.includes("favicon") && !e.includes("404")); + expect(jsErrors).toHaveLength(0); + }); + + test("dashboard renders correctly after login", async ({ page }) => { + // Should already be on dashboard (/) after login + + // Should not show "Please sign in" + await expect(page.locator("text=/Please sign in/i")).not.toBeVisible({ timeout: 5_000 }); + + // Should show the greeting with client name + await expect(page.locator("text=/Hi,\\s*Carol/")).toBeVisible(); + + // Navigation should be visible + await expect(page.locator("nav")).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/web/e2e/test-results/.last-run.json b/apps/web/e2e/test-results/.last-run.json new file mode 100644 index 0000000..a388776 --- /dev/null +++ b/apps/web/e2e/test-results/.last-run.json @@ -0,0 +1,8 @@ +{ + "status": "failed", + "failedTests": [ + "cc6a1cee8cd61301041b-bf77e4c199938445b05b", + "cc6a1cee8cd61301041b-2a304fecac661e282f98", + "cc6a1cee8cd61301041b-846717512ae61dac91f4" + ] +} \ No newline at end of file diff --git a/apps/web/public/demo-pets/dog-bichon-frise.png b/apps/web/public/demo-pets/dog-bichon-frise.png new file mode 100644 index 0000000..479aea4 Binary files /dev/null and b/apps/web/public/demo-pets/dog-bichon-frise.png differ diff --git a/apps/web/public/demo-pets/dog-black-lab.png b/apps/web/public/demo-pets/dog-black-lab.png new file mode 100644 index 0000000..c864c2f Binary files /dev/null and b/apps/web/public/demo-pets/dog-black-lab.png differ diff --git a/apps/web/public/demo-pets/dog-cocker-spaniel.png b/apps/web/public/demo-pets/dog-cocker-spaniel.png new file mode 100644 index 0000000..6e09894 Binary files /dev/null and b/apps/web/public/demo-pets/dog-cocker-spaniel.png differ diff --git a/apps/web/public/demo-pets/dog-dachshund.png b/apps/web/public/demo-pets/dog-dachshund.png new file mode 100644 index 0000000..0ee1a7d Binary files /dev/null and b/apps/web/public/demo-pets/dog-dachshund.png differ diff --git a/apps/web/public/demo-pets/dog-golden-after.png b/apps/web/public/demo-pets/dog-golden-after.png new file mode 100644 index 0000000..5864899 Binary files /dev/null and b/apps/web/public/demo-pets/dog-golden-after.png differ diff --git a/apps/web/public/demo-pets/dog-golden-before.png b/apps/web/public/demo-pets/dog-golden-before.png new file mode 100644 index 0000000..d1442f7 Binary files /dev/null and b/apps/web/public/demo-pets/dog-golden-before.png differ diff --git a/apps/web/public/demo-pets/dog-maltese.png b/apps/web/public/demo-pets/dog-maltese.png new file mode 100644 index 0000000..b8cd227 Binary files /dev/null and b/apps/web/public/demo-pets/dog-maltese.png differ diff --git a/apps/web/public/demo-pets/dog-pomeranian.png b/apps/web/public/demo-pets/dog-pomeranian.png new file mode 100644 index 0000000..93dd0a4 Binary files /dev/null and b/apps/web/public/demo-pets/dog-pomeranian.png differ diff --git a/apps/web/public/demo-pets/dog-poodle-groomed.png b/apps/web/public/demo-pets/dog-poodle-groomed.png new file mode 100644 index 0000000..8f8ce80 Binary files /dev/null and b/apps/web/public/demo-pets/dog-poodle-groomed.png differ diff --git a/apps/web/public/demo-pets/dog-schnauzer.png b/apps/web/public/demo-pets/dog-schnauzer.png new file mode 100644 index 0000000..d1f0b23 Binary files /dev/null and b/apps/web/public/demo-pets/dog-schnauzer.png differ diff --git a/apps/web/public/demo-pets/dog-shih-tzu.png b/apps/web/public/demo-pets/dog-shih-tzu.png new file mode 100644 index 0000000..860572b Binary files /dev/null and b/apps/web/public/demo-pets/dog-shih-tzu.png differ diff --git a/apps/web/public/demo-pets/dog-terrier.png b/apps/web/public/demo-pets/dog-terrier.png new file mode 100644 index 0000000..c00f364 Binary files /dev/null and b/apps/web/public/demo-pets/dog-terrier.png differ diff --git a/apps/web/src/BrandingContext.tsx b/apps/web/src/BrandingContext.tsx index 1420e02..a29d12e 100644 --- a/apps/web/src/BrandingContext.tsx +++ b/apps/web/src/BrandingContext.tsx @@ -4,6 +4,7 @@ export interface Branding { businessName: string; primaryColor: string; accentColor: string; + logoUrl: string | null; logoBase64: string | null; logoMimeType: string | null; } @@ -12,6 +13,7 @@ const DEFAULT_BRANDING: Branding = { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", + logoUrl: null, logoBase64: null, logoMimeType: null, }; diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 09ff522..afc4533 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -5,8 +5,10 @@ interface SettingsForm { businessName: string; primaryColor: string; accentColor: string; - logoBase64: string | null; - logoMimeType: string | null; + logoKey: string | null; + logoUrl: string | null; + logoBase64: string | null; // legacy + logoMimeType: string | null; // legacy } export function SettingsPage() { @@ -15,6 +17,8 @@ export function SettingsPage() { businessName: "", primaryColor: "#4f8a6f", accentColor: "#8b7355", + logoKey: null, + logoUrl: null, logoBase64: null, logoMimeType: null, }); @@ -26,11 +30,25 @@ export function SettingsPage() { useEffect(() => { fetch("/api/admin/settings") .then((r) => r.json()) - .then((data) => { + .then(async (data) => { + let logoUrl: string | null = null; + if (data.logoKey) { + try { + const logoRes = await fetch("/api/admin/settings/logo"); + if (logoRes.ok) { + const logoData = await logoRes.json(); + logoUrl = logoData.url; + } + } catch { + // ignore + } + } setForm({ businessName: data.businessName ?? "GroomBook", primaryColor: data.primaryColor ?? "#4f8a6f", accentColor: data.accentColor ?? "#8b7355", + logoKey: data.logoKey ?? null, + logoUrl, logoBase64: data.logoBase64 ?? null, logoMimeType: data.logoMimeType ?? null, }); diff --git a/apps/web/vitest.config.ts.main.bak b/apps/web/vitest.config.ts.main.bak new file mode 100644 index 0000000..ae5e3f6 --- /dev/null +++ b/apps/web/vitest.config.ts.main.bak @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + globals: true, + coverage: { + provider: "v8", + include: ["src/**"], + exclude: ["src/test/**", "src/main.tsx"], + thresholds: { + lines: 50, + functions: 50, + }, + }, + }, +}); diff --git a/minimax-output/dog-bichon-frise.png b/minimax-output/dog-bichon-frise.png new file mode 100644 index 0000000..479aea4 Binary files /dev/null and b/minimax-output/dog-bichon-frise.png differ diff --git a/minimax-output/dog-black-lab.png b/minimax-output/dog-black-lab.png new file mode 100644 index 0000000..c864c2f Binary files /dev/null and b/minimax-output/dog-black-lab.png differ diff --git a/minimax-output/dog-cocker-spaniel.png b/minimax-output/dog-cocker-spaniel.png new file mode 100644 index 0000000..6e09894 Binary files /dev/null and b/minimax-output/dog-cocker-spaniel.png differ diff --git a/minimax-output/dog-corgi.png b/minimax-output/dog-corgi.png new file mode 100644 index 0000000..6b659b1 Binary files /dev/null and b/minimax-output/dog-corgi.png differ diff --git a/minimax-output/dog-dachshund.png b/minimax-output/dog-dachshund.png new file mode 100644 index 0000000..0ee1a7d Binary files /dev/null and b/minimax-output/dog-dachshund.png differ diff --git a/minimax-output/dog-golden-after.png b/minimax-output/dog-golden-after.png new file mode 100644 index 0000000..5864899 Binary files /dev/null and b/minimax-output/dog-golden-after.png differ diff --git a/minimax-output/dog-golden-before.png b/minimax-output/dog-golden-before.png new file mode 100644 index 0000000..d1442f7 Binary files /dev/null and b/minimax-output/dog-golden-before.png differ diff --git a/minimax-output/dog-golden-retriever.png b/minimax-output/dog-golden-retriever.png new file mode 100644 index 0000000..7c07cc9 Binary files /dev/null and b/minimax-output/dog-golden-retriever.png differ diff --git a/minimax-output/dog-labrador.png b/minimax-output/dog-labrador.png new file mode 100644 index 0000000..451fcc7 Binary files /dev/null and b/minimax-output/dog-labrador.png differ diff --git a/minimax-output/dog-maltese.png b/minimax-output/dog-maltese.png new file mode 100644 index 0000000..b8cd227 Binary files /dev/null and b/minimax-output/dog-maltese.png differ diff --git a/minimax-output/dog-mixed-breed.png b/minimax-output/dog-mixed-breed.png new file mode 100644 index 0000000..8b280b7 Binary files /dev/null and b/minimax-output/dog-mixed-breed.png differ diff --git a/minimax-output/dog-pomeranian.png b/minimax-output/dog-pomeranian.png new file mode 100644 index 0000000..93dd0a4 Binary files /dev/null and b/minimax-output/dog-pomeranian.png differ diff --git a/minimax-output/dog-poodle-groomed.png b/minimax-output/dog-poodle-groomed.png new file mode 100644 index 0000000..8f8ce80 Binary files /dev/null and b/minimax-output/dog-poodle-groomed.png differ diff --git a/minimax-output/dog-poodle.png b/minimax-output/dog-poodle.png new file mode 100644 index 0000000..ab69f7d Binary files /dev/null and b/minimax-output/dog-poodle.png differ diff --git a/minimax-output/dog-schnauzer.png b/minimax-output/dog-schnauzer.png new file mode 100644 index 0000000..d1f0b23 Binary files /dev/null and b/minimax-output/dog-schnauzer.png differ diff --git a/minimax-output/dog-shih-tzu.png b/minimax-output/dog-shih-tzu.png new file mode 100644 index 0000000..860572b Binary files /dev/null and b/minimax-output/dog-shih-tzu.png differ diff --git a/minimax-output/dog-terrier.png b/minimax-output/dog-terrier.png new file mode 100644 index 0000000..c00f364 Binary files /dev/null and b/minimax-output/dog-terrier.png differ diff --git a/minimax-output/groombook-logo.png b/minimax-output/groombook-logo.png new file mode 100644 index 0000000..ad6c338 Binary files /dev/null and b/minimax-output/groombook-logo.png differ diff --git a/packages/db/migrations/0022_logo_key.sql b/packages/db/migrations/0022_logo_key.sql new file mode 100644 index 0000000..7ea52cd --- /dev/null +++ b/packages/db/migrations/0022_logo_key.sql @@ -0,0 +1,2 @@ +-- Add logo_key column to business_settings for S3-based logo storage +ALTER TABLE "business_settings" ADD COLUMN "logo_key" text; \ No newline at end of file diff --git a/packages/db/migrations/meta/0021_snapshot.json b/packages/db/migrations/meta/0021_snapshot.json new file mode 100644 index 0000000..7a57e53 --- /dev/null +++ b/packages/db/migrations/meta/0021_snapshot.json @@ -0,0 +1,504 @@ +{ + "id": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97", + "prevId": "5983a2e9-f185-4f8a-a73f-5a7c0a0eea9c", + "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 }, + "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": {}, + "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.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 }, + "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'" }, + "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": false }, + "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 }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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": {}, + "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": {}, + "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 }, + "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": { + "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.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 }, + "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": { "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.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 }, + "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_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } }, + "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 }, + "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.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.payment_method": { "name": "payment_method", "schema": "public", "values": ["cash", "card", "check", "other"] }, + "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/0022_snapshot.json b/packages/db/migrations/meta/0022_snapshot.json new file mode 100644 index 0000000..a803ed0 --- /dev/null +++ b/packages/db/migrations/meta/0022_snapshot.json @@ -0,0 +1,505 @@ +{ + "id": "9e8d3f2a-1c7b-4a6d-8f0e-5c2b9a3d7e1f", + "prevId": "b3a381ca-f7a4-450f-aa7e-fdc2d652dc97", + "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 }, + "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": {}, + "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.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'" }, + "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": false }, + "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 }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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": {}, + "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": {}, + "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 }, + "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": { + "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.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 }, + "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": { "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.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 }, + "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_unique": { "name": "reminder_logs_appointment_id_reminder_type_unique", "nullsNotDistinct": false, "columns": ["appointment_id", "reminder_type"] } }, + "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 }, + "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.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.payment_method": { "name": "payment_method", "schema": "public", "values": ["cash", "card", "check", "other"] }, + "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 a0ea73f..d10accd 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -148,6 +148,20 @@ "when": 1775050467192, "tag": "0020_typical_daimon_hellstrom", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1775136867192, + "tag": "0021_pet_image", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1775223267192, + "tag": "0022_logo_key", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 47b42d0..e1af4c8 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -348,6 +348,7 @@ export const businessSettings = pgTable("business_settings", { businessName: text("business_name").notNull().default("GroomBook"), logoBase64: text("logo_base64"), logoMimeType: text("logo_mime_type"), + logoKey: text("logo_key"), primaryColor: text("primary_color").notNull().default("#4f8a6f"), accentColor: text("accent_color").notNull().default("#8b7355"), createdAt: timestamp("created_at").notNull().defaultNow(), diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 2b858a1..0a165a8 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -339,27 +339,41 @@ async function seedKnownUsers() { console.log("✓ Created client 'Demo Client'"); } - // ── Pet: Demo Dog ── - const [existingPet] = await db - .select() - .from(schema.pets) - .where(eq(schema.pets.id, DEMO_PET_ID)) - .limit(1); + // ── Pets: Demo Dogs & Cats ── + const demoPets = [ + { id: DEMO_PET_ID, name: "Demo Dog", species: "Dog", breed: "Golden Retriever", weight: "30.00", dob: "2020-06-15", image: "/demo-pets/dog-golden-after.png" }, + { id: uuid(), name: "Fluffy", species: "Dog", breed: "Poodle", weight: "8.50", dob: "2019-03-22", image: "/demo-pets/dog-poodle-groomed.png" }, + { id: uuid(), name: "Shadow", species: "Dog", breed: "Black Labrador", weight: "35.00", dob: "2018-11-10", image: "/demo-pets/dog-black-lab.png" }, + { id: uuid(), name: "Bella", species: "Dog", breed: "Shih Tzu", weight: "4.50", dob: "2021-02-14", image: "/demo-pets/dog-shih-tzu.png" }, + { id: uuid(), name: "Max", species: "Dog", breed: "Cocker Spaniel", weight: "15.00", dob: "2019-07-08", image: "/demo-pets/dog-cocker-spaniel.png" }, + { id: uuid(), name: "Buddy", species: "Dog", breed: "Schnauzer", weight: "12.00", dob: "2020-05-20", image: "/demo-pets/dog-schnauzer.png" }, + { id: uuid(), name: "Daisy", species: "Dog", breed: "Maltese", weight: "3.50", dob: "2021-09-03", image: "/demo-pets/dog-maltese.png" }, + { id: uuid(), name: "Charlie", species: "Dog", breed: "Dachshund", weight: "6.00", dob: "2020-01-15", image: "/demo-pets/dog-dachshund.png" }, + { id: uuid(), name: "Lucy", species: "Dog", breed: "Pomeranian", weight: "2.50", dob: "2022-04-10", image: "/demo-pets/dog-pomeranian.png" }, + ]; - if (existingPet) { - console.log(`✓ Pet '${existingPet.name}' already exists — skipping`); - } else { - await db.insert(schema.pets).values({ - id: DEMO_PET_ID, - clientId, - name: "Demo Dog", - species: "Dog", - breed: "Golden Retriever", - weightKg: "30.00", - dateOfBirth: new Date("2020-06-15T00:00:00Z"), - image: "/demo-pets/dog-golden-retriever.png", - }); - console.log("✓ Created pet 'Demo Dog'"); + for (const pet of demoPets) { + const [existing] = await db + .select() + .from(schema.pets) + .where(eq(schema.pets.id, pet.id)) + .limit(1); + + if (existing) { + console.log(`✓ Pet '${existing.name}' already exists — skipping`); + } else { + await db.insert(schema.pets).values({ + id: pet.id, + clientId, + name: pet.name, + species: pet.species, + breed: pet.breed, + weightKg: pet.weight, + dateOfBirth: new Date(`${pet.dob}T00:00:00Z`), + image: pet.image, + }); + console.log(`✓ Created pet '${pet.name}'`); + } } console.log("\nKnown-users seed complete!");