diff --git a/apps/api/package.json b/apps/api/package.json index b032eaf..d755d49 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,11 +27,11 @@ "@types/node": "^22.10.7", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.17", - "@vitest/coverage-v8": "^3.0.4", + "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.18.0", "tsx": "^4.19.2", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", - "vitest": "^3.0.4" + "vitest": "^3.2.4" } } diff --git a/apps/api/src/__tests__/factories.test.ts b/apps/api/src/__tests__/factories.test.ts new file mode 100644 index 0000000..bdb7fad --- /dev/null +++ b/apps/api/src/__tests__/factories.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + resetFactoryCounters, + buildStaff, + buildClient, + buildPet, + buildService, + buildAppointment, +} from "@groombook/db/factories"; + +describe("resetFactoryCounters", () => { + it("resets all counters so IDs restart from 1", () => { + buildStaff(); + buildStaff(); + buildClient(); + resetFactoryCounters(); + + const staff = buildStaff(); + const client = buildClient(); + + expect(staff.id).toBe("staff-1"); + expect(client.id).toBe("client-1"); + }); + + it("resets counters for every entity type", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + }); + + resetFactoryCounters(); + + expect(buildStaff().id).toBe("staff-1"); + expect(buildClient().id).toBe("client-1"); + expect(buildService().id).toBe("service-1"); + const c = buildClient(); + expect(buildPet({ clientId: c.id }).id).toBe("pet-1"); + const s = buildService(); + const p = buildPet({ clientId: c.id }); + expect( + buildAppointment({ clientId: c.id, petId: p.id, serviceId: s.id, staffId: "s-1" }).id + ).toBe("appointment-1"); + }); +}); + +describe("counter determinism", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("increments staff IDs sequentially", () => { + expect(buildStaff().id).toBe("staff-1"); + expect(buildStaff().id).toBe("staff-2"); + expect(buildStaff().id).toBe("staff-3"); + }); + + it("increments client IDs sequentially", () => { + expect(buildClient().id).toBe("client-1"); + expect(buildClient().id).toBe("client-2"); + }); + + it("increments pet IDs sequentially", () => { + const client = buildClient(); + expect(buildPet({ clientId: client.id }).id).toBe("pet-1"); + expect(buildPet({ clientId: client.id }).id).toBe("pet-2"); + }); + + it("increments service IDs sequentially", () => { + expect(buildService().id).toBe("service-1"); + expect(buildService().id).toBe("service-2"); + }); + + it("increments appointment IDs sequentially", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const required = { clientId: client.id, petId: pet.id, serviceId: service.id, staffId: "staff-1" }; + + expect(buildAppointment(required).id).toBe("appointment-1"); + expect(buildAppointment(required).id).toBe("appointment-2"); + }); + + it("each entity type maintains its own independent counter", () => { + buildStaff(); + buildStaff(); + buildClient(); + + // staff counter is at 2; client counter is at 1 + expect(buildStaff().id).toBe("staff-3"); + expect(buildClient().id).toBe("client-2"); + }); +}); + +describe("override merging", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("buildStaff applies overrides over defaults", () => { + const staff = buildStaff({ role: "manager", name: "Boss" }); + + expect(staff.role).toBe("manager"); + expect(staff.name).toBe("Boss"); + expect(staff.id).toBe("staff-1"); + expect(staff.active).toBe(true); // default preserved + }); + + it("buildStaff id override is respected without disrupting the counter", () => { + const staff = buildStaff({ id: "custom-id" }); + + expect(staff.id).toBe("custom-id"); + // counter still ticked — next call gets staff-2 + expect(buildStaff().id).toBe("staff-2"); + }); + + it("buildClient applies overrides over defaults", () => { + const client = buildClient({ name: "Alice Smith", emailOptOut: true }); + + expect(client.name).toBe("Alice Smith"); + expect(client.emailOptOut).toBe(true); + expect(client.status).toBe("active"); // default preserved + }); + + it("buildPet merges overrides and sets clientId from required arg", () => { + const pet = buildPet({ clientId: "client-99", name: "Fluffy", breed: "Poodle" }); + + expect(pet.clientId).toBe("client-99"); + expect(pet.name).toBe("Fluffy"); + expect(pet.breed).toBe("Poodle"); + expect(pet.species).toBe("Dog"); // default preserved + }); + + it("buildService applies overrides over defaults", () => { + const service = buildService({ basePriceCents: 9900, active: false }); + + expect(service.basePriceCents).toBe(9900); + expect(service.active).toBe(false); + expect(service.durationMinutes).toBe(60); // default preserved + }); + + it("buildAppointment applies overrides over defaults", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const appt = buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + status: "confirmed", + notes: "allergic to lavender", + }); + + expect(appt.status).toBe("confirmed"); + expect(appt.notes).toBe("allergic to lavender"); + expect(appt.clientId).toBe(client.id); + expect(appt.petId).toBe(pet.id); + // defaults preserved + expect(appt.batherStaffId).toBeNull(); + expect(appt.priceCents).toBeNull(); + }); +}); + +describe("buildAppointment required fields", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("produces a fully-populated AppointmentRow", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const appt = buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + }); + + expect(appt.id).toBeDefined(); + expect(appt.clientId).toBe(client.id); + expect(appt.petId).toBe(pet.id); + expect(appt.serviceId).toBe(service.id); + expect(appt.staffId).toBe("staff-1"); + expect(appt.startTime).toBeInstanceOf(Date); + expect(appt.endTime).toBeInstanceOf(Date); + expect(appt.status).toBe("scheduled"); + expect(appt.batherStaffId).toBeNull(); + expect(appt.seriesId).toBeNull(); + expect(appt.seriesIndex).toBeNull(); + expect(appt.groupId).toBeNull(); + expect(appt.notes).toBeNull(); + expect(appt.priceCents).toBeNull(); + expect(appt.createdAt).toBeInstanceOf(Date); + expect(appt.updatedAt).toBeInstanceOf(Date); + }); + + // TypeScript compile-time enforcement: omitting any required field produces a type error. + // The overrides parameter type is `Partial & { clientId: string; petId: string; serviceId: string; staffId: string }`. + // The test below verifies the type signature is correct by using @ts-expect-error. + it("type error when required fields are missing — compile-time enforcement", () => { + // @ts-expect-error clientId is required + buildAppointment({ petId: "p", serviceId: "s", staffId: "st" }); + // @ts-expect-error petId is required + buildAppointment({ clientId: "c", serviceId: "s", staffId: "st" }); + // @ts-expect-error serviceId is required + buildAppointment({ clientId: "c", petId: "p", staffId: "st" }); + // @ts-expect-error staffId is required + buildAppointment({ clientId: "c", petId: "p", serviceId: "s" }); + }); +}); diff --git a/apps/api/src/__tests__/search.test.ts b/apps/api/src/__tests__/search.test.ts new file mode 100644 index 0000000..3c4ca9a --- /dev/null +++ b/apps/api/src/__tests__/search.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock data ──────────────────────────────────────────────────────────────── + +const ACTIVE_CLIENT = { + id: "client-1", + name: "Alice Johnson", + email: "alice@example.com", + phone: "555-1234", +}; + +const PET_ROW = { + id: "pet-1", + name: "Bella", + breed: "Golden Retriever", + clientId: "client-1", + ownerName: "Alice Johnson", +}; + +// ─── Mock DB ────────────────────────────────────────────────────────────────── + +let clientResults: typeof ACTIVE_CLIENT[] = []; +let petResults: typeof PET_ROW[] = []; + +vi.mock("@groombook/db", () => { + // Proxy objects for table/column references — values don't matter for tests + const tableProxy = (name: string) => + new Proxy( + { _name: name }, + { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); + + const clients = tableProxy("clients"); + const pets = tableProxy("pets"); + + return { + getDb: () => ({ + select: (_fields?: unknown) => { + // Route which mock results to use based on a global flag set per test + return { + from: (table: { _name?: string }) => { + const results = table._name === "pets" ? petResults : clientResults; + const chain: Record = {}; + chain.where = () => chain; + chain.innerJoin = () => chain; + chain.limit = () => Promise.resolve(results); + return chain; + }, + }; + }, + }), + clients, + pets, + and: (...args: unknown[]) => ({ and: args }), + or: (...args: unknown[]) => ({ or: args }), + eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), + ilike: (col: unknown, pat: unknown) => ({ ilike: [col, pat] }), + }; +}); + +// ─── App under test ─────────────────────────────────────────────────────────── + +async function makeApp() { + const { searchRouter } = await import("../routes/search.js"); + const app = new Hono(); + app.route("/search", searchRouter); + return app; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.resetModules(); + clientResults = []; + petResults = []; +}); + +describe("GET /search", () => { + it("returns 400 when q is missing", async () => { + const app = await makeApp(); + const res = await app.request("/search"); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeTruthy(); + }); + + it("returns 400 when q is empty string", async () => { + const app = await makeApp(); + const res = await app.request("/search?q="); + expect(res.status).toBe(400); + }); + + it("returns 400 when q is only whitespace", async () => { + const app = await makeApp(); + const res = await app.request("/search?q= "); + expect(res.status).toBe(400); + }); + + it("returns matching clients and pets", async () => { + clientResults = [ACTIVE_CLIENT]; + petResults = [PET_ROW]; + + const app = await makeApp(); + const res = await app.request("/search?q=bell"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.clients).toEqual([ACTIVE_CLIENT]); + expect(body.pets).toEqual([PET_ROW]); + }); + + it("returns empty arrays when no matches", async () => { + clientResults = []; + petResults = []; + + const app = await makeApp(); + const res = await app.request("/search?q=xyzzy"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.clients).toEqual([]); + expect(body.pets).toEqual([]); + }); + + it("returns shape with clients and pets keys", async () => { + const app = await makeApp(); + const res = await app.request("/search?q=a"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("clients"); + expect(body).toHaveProperty("pets"); + expect(Array.isArray(body.clients)).toBe(true); + expect(Array.isArray(body.pets)).toBe(true); + }); + + it("handles special characters in query without throwing", async () => { + clientResults = []; + petResults = []; + + const app = await makeApp(); + // These characters should be escaped, not cause errors + const res = await app.request("/search?q=foo%25bar_baz"); + expect(res.status).toBe(200); + }); +}); + +describe("escapeLike helper (via integration)", () => { + it("% in query does not break the request", async () => { + clientResults = []; + petResults = []; + const app = await makeApp(); + const res = await app.request("/search?q=%25"); + expect(res.status).toBe(200); + }); + + it("_ in query does not break the request", async () => { + clientResults = []; + petResults = []; + const app = await makeApp(); + const res = await app.request("/search?q=_"); + expect(res.status).toBe(200); + }); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3454a83..495c285 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,6 +14,7 @@ import { appointmentGroupsRouter } from "./routes/appointmentGroups.js"; 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 { getDb, businessSettings } from "@groombook/db"; import { authMiddleware } from "./middleware/auth.js"; import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js"; @@ -98,6 +99,7 @@ api.route("/appointment-groups", appointmentGroupsRouter); api.route("/grooming-logs", groomingLogsRouter); api.route("/impersonation", impersonationRouter); api.route("/admin/settings", settingsRouter); +api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); console.log(`API server listening on port ${port}`); diff --git a/apps/api/src/routes/search.ts b/apps/api/src/routes/search.ts new file mode 100644 index 0000000..14e4ec3 --- /dev/null +++ b/apps/api/src/routes/search.ts @@ -0,0 +1,70 @@ +import { Hono } from "hono"; +import { and, eq, getDb, clients, ilike, or, pets } from "@groombook/db"; + +export const searchRouter = new Hono(); + +const LIMIT = 10; + +/** Escape %, _, and \ in user input before wrapping with ILIKE wildcards. */ +function escapeLike(s: string): string { + return `%${s.replace(/[%_\\]/g, "\\$&")}%`; +} + +/** + * GET /api/search?q={query} + * + * Returns up to 10 matching active clients and up to 10 matching pets. + * Clients are matched on name, email, or phone. + * Pets are matched on name or breed; includes owner name. + */ +searchRouter.get("/", async (c) => { + const q = c.req.query("q"); + if (!q || q.trim().length === 0) { + return c.json({ error: "Query parameter q is required" }, 400); + } + + const pattern = escapeLike(q.trim()); + const db = getDb(); + + const [matchingClients, matchingPets] = await Promise.all([ + db + .select({ + id: clients.id, + name: clients.name, + email: clients.email, + phone: clients.phone, + }) + .from(clients) + .where( + and( + eq(clients.status, "active"), + or( + ilike(clients.name, pattern), + ilike(clients.email, pattern), + ilike(clients.phone, pattern) + ) + ) + ) + .limit(LIMIT), + + db + .select({ + id: pets.id, + name: pets.name, + breed: pets.breed, + clientId: pets.clientId, + ownerName: clients.name, + }) + .from(pets) + .innerJoin(clients, and(eq(pets.clientId, clients.id), eq(clients.status, "active"))) + .where( + or( + ilike(pets.name, pattern), + ilike(pets.breed, pattern) + ) + ) + .limit(LIMIT), + ]); + + return c.json({ clients: matchingClients, pets: matchingPets }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 23ecc24..f819165 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -13,6 +13,7 @@ import { CustomerPortal } from "./portal/CustomerPortal.js"; import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; import { BrandingProvider, useBranding } from "./BrandingContext.js"; +import { GlobalSearch } from "./components/GlobalSearch.js"; const NAV_LINKS = [ { to: "/admin", label: "Appointments" }, @@ -68,6 +69,7 @@ function AdminLayout() { {branding.businessName} + (DEFAULT_BRANDING); + const metaThemeColorRef = useRef(null); const fetchBranding = useCallback(() => { fetch("/api/branding") @@ -45,6 +46,16 @@ export function BrandingProvider({ children }: { children: React.ReactNode }) { useEffect(() => { document.documentElement.style.setProperty("--color-primary", branding.primaryColor); document.documentElement.style.setProperty("--color-accent", branding.accentColor); + // Keep PWA theme-color meta tag in sync with primary color + if (!metaThemeColorRef.current) { + metaThemeColorRef.current = document.querySelector("meta[name='theme-color']"); + if (!metaThemeColorRef.current) { + metaThemeColorRef.current = document.createElement("meta"); + metaThemeColorRef.current.name = "theme-color"; + document.head.appendChild(metaThemeColorRef.current); + } + } + metaThemeColorRef.current.content = branding.primaryColor; }, [branding.primaryColor, branding.accentColor]); return ( diff --git a/apps/web/src/__tests__/BrandingContext.test.tsx b/apps/web/src/__tests__/BrandingContext.test.tsx new file mode 100644 index 0000000..2e7816c --- /dev/null +++ b/apps/web/src/__tests__/BrandingContext.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { BrandingProvider, useBranding } from "../BrandingContext.js"; + +function BrandingConsumer() { + const { branding } = useBranding(); + return ( +
+ {branding.primaryColor} + {branding.accentColor} +
+ ); +} + +beforeEach(() => { + vi.restoreAllMocks(); + document.documentElement.style.removeProperty("--color-primary"); + document.documentElement.style.removeProperty("--color-accent"); + // Remove any theme-color meta tags + document.querySelectorAll("meta[name='theme-color']").forEach((el) => el.remove()); +}); + +describe("BrandingProvider", () => { + it("applies CSS vars to document root when branding loads", async () => { + const branding = { + businessName: "Test Salon", + primaryColor: "#123456", + accentColor: "#654321", + logoBase64: null, + logoMimeType: null, + }; + global.fetch = vi.fn(() => + Promise.resolve({ ok: true, json: async () => branding } as Response) + ) as unknown as typeof fetch; + + render( + + + + ); + + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("#123456"); + expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("#654321"); + }); + }); + + it("creates and updates meta[name=theme-color]", async () => { + const branding = { + businessName: "Test Salon", + primaryColor: "#abcdef", + accentColor: "#fedcba", + logoBase64: null, + logoMimeType: null, + }; + global.fetch = vi.fn(() => + Promise.resolve({ ok: true, json: async () => branding } as Response) + ) as unknown as typeof fetch; + + render( + + + + ); + + await waitFor(() => { + const meta = document.querySelector("meta[name='theme-color']"); + expect(meta).not.toBeNull(); + expect(meta!.content).toBe("#abcdef"); + }); + }); + + it("does not create duplicate meta[name=theme-color] tags on rerender", async () => { + const branding = { + businessName: "Test Salon", + primaryColor: "#111111", + accentColor: "#222222", + logoBase64: null, + logoMimeType: null, + }; + global.fetch = vi.fn(() => + Promise.resolve({ ok: true, json: async () => branding } as Response) + ) as unknown as typeof fetch; + + const { rerender } = render( + + + + ); + + await waitFor(() => { + expect(document.querySelector("meta[name='theme-color']")).not.toBeNull(); + }); + + rerender( + + + + ); + + await waitFor(() => { + const metas = document.querySelectorAll("meta[name='theme-color']"); + expect(metas.length).toBe(1); + }); + }); +}); diff --git a/apps/web/src/components/DevSessionIndicator.tsx b/apps/web/src/components/DevSessionIndicator.tsx index 993698d..ee66891 100644 --- a/apps/web/src/components/DevSessionIndicator.tsx +++ b/apps/web/src/components/DevSessionIndicator.tsx @@ -29,7 +29,7 @@ export function DevSessionIndicator() { (null); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const debounceRef = useRef | null>(null); + const navigate = useNavigate(); + + // Debounced search + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + + const trimmed = query.trim(); + if (trimmed.length === 0) { + setResults(null); + setOpen(false); + return; + } + + debounceRef.current = setTimeout(async () => { + setLoading(true); + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`); + if (res.ok) { + const data: SearchResults = await res.json(); + setResults(data); + setOpen(true); + } + } catch (err) { + console.warn("GlobalSearch: fetch error", err); + } finally { + setLoading(false); + } + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query]); + + // Close dropdown on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if ( + inputRef.current && + !inputRef.current.contains(e.target as Node) && + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + function handleClientClick(client: ClientResult) { + setOpen(false); + setQuery(""); + navigate(`/admin/clients?highlight=${client.id}`); + } + + function handlePetClick(pet: PetResult) { + setOpen(false); + setQuery(""); + navigate(`/admin/clients?highlight=${pet.clientId}`); + } + + const hasResults = results && (results.clients.length > 0 || results.pets.length > 0); + + return ( +
+
+ + setQuery(e.target.value)} + onFocus={() => results && setOpen(true)} + style={{ + width: "100%", + boxSizing: "border-box", + height: 44, + paddingLeft: 32, + paddingRight: 12, + fontSize: 13, + border: "1px solid #e2e8f0", + borderRadius: 8, + outline: "none", + background: "#f8fafc", + color: "#1a202c", + }} + aria-label="Search clients and pets" + aria-expanded={open} + aria-haspopup="listbox" + role="combobox" + aria-autocomplete="list" + /> +
+ + {open && ( +
+ {loading && ( +
+ Searching… +
+ )} + + {!loading && !hasResults && ( +
+ No results found +
+ )} + + {!loading && results && results.clients.length > 0 && ( +
+
+ Clients +
+ {results.clients.map((client) => ( + + ))} +
+ )} + + {!loading && results && results.pets.length > 0 && ( +
+
+ Pets +
+ {results.pets.map((pet) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 7101912..61c98ed 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2,7 +2,12 @@ :root { --color-primary: #4f8a6f; + --color-primary-dark: color-mix(in srgb, var(--color-primary) 80%, #000); --color-accent: #8b7355; + --color-accent-hover: color-mix(in srgb, var(--color-accent) 88%, #000); + --color-accent-dark: color-mix(in srgb, var(--color-accent) 78%, #000); + --color-accent-light: color-mix(in srgb, var(--color-accent) 18%, #fff); + --color-accent-lighter: color-mix(in srgb, var(--color-accent) 9%, #fff); } *, *::before, *::after { @@ -51,8 +56,8 @@ button:active:not(:disabled) { /* ─── Admin input / select focus states ─── */ input:focus, select:focus, textarea:focus { outline: none; - border-color: #4f8a6f; - box-shadow: 0 0 0 3px rgba(79, 138, 111, 0.12); + border-color: var(--color-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent); } /* ─── Admin card-like containers (borders get subtle shadow) ─── */ diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index ea2c539..455c582 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -291,7 +291,7 @@ export function AppointmentsPage() { @@ -374,7 +374,7 @@ export function AppointmentsPage() {
{saving ? "Saving…" diff --git a/apps/web/src/pages/Book.tsx b/apps/web/src/pages/Book.tsx index ecfd5bb..0e0710d 100644 --- a/apps/web/src/pages/Book.tsx +++ b/apps/web/src/pages/Book.tsx @@ -66,8 +66,8 @@ function StepIndicator({ step }: { step: number }) { padding: "0.5rem 0.25rem", fontSize: 12, fontWeight: active ? 700 : 400, - color: active ? "#4f8a6f" : done ? "#4f8a6f" : "#9ca3af", - borderBottom: `3px solid ${active ? "#4f8a6f" : done ? "#4f8a6f" : "#e5e7eb"}`, + color: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#9ca3af", + borderBottom: `3px solid ${active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb"}`, }} >
-
+
{fmtPrice(svc.basePriceCents)}
{fmtDuration(svc.durationMinutes)}
@@ -349,8 +349,8 @@ export function BookPage() { style={{ padding: "0.4rem 0.85rem", borderRadius: 6, - border: `2px solid ${selectedSlot === slot ? "#4f8a6f" : "#d1d5db"}`, - background: selectedSlot === slot ? "#4f8a6f" : "#fff", + border: `2px solid ${selectedSlot === slot ? "var(--color-primary)" : "#d1d5db"}`, + background: selectedSlot === slot ? "var(--color-primary)" : "#fff", color: selectedSlot === slot ? "#fff" : "#374151", fontSize: 13, fontWeight: 500, diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index c5354d9..ba11584 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -367,7 +367,7 @@ export function ClientsPage() {

Clients

@@ -622,7 +622,7 @@ export function ClientsPage() { {clientFormError &&

{clientFormError}

}
- @@ -761,7 +761,7 @@ export function ClientsPage() { {logFormError &&

{logFormError}

}
- diff --git a/apps/web/src/pages/GroupBooking.tsx b/apps/web/src/pages/GroupBooking.tsx index 445530a..5cf8d39 100644 --- a/apps/web/src/pages/GroupBooking.tsx +++ b/apps/web/src/pages/GroupBooking.tsx @@ -287,7 +287,7 @@ function NewGroupBookingForm({ @@ -471,7 +471,7 @@ export function GroupBookingPage() { diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index d1c3457..5039dd3 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -129,7 +129,7 @@ function CreateFromAppointmentForm({ @@ -540,7 +540,7 @@ export function InvoicesPage() { diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx index f7b3ceb..a3515ce 100644 --- a/apps/web/src/pages/Reports.tsx +++ b/apps/web/src/pages/Reports.tsx @@ -270,7 +270,7 @@ export function ReportsPage() { -
diff --git a/apps/web/src/pages/Services.tsx b/apps/web/src/pages/Services.tsx index eb952b1..65cf380 100644 --- a/apps/web/src/pages/Services.tsx +++ b/apps/web/src/pages/Services.tsx @@ -119,7 +119,7 @@ export function ServicesPage() {

Services

@@ -232,7 +232,7 @@ export function ServicesPage() { diff --git a/apps/web/src/pages/Staff.tsx b/apps/web/src/pages/Staff.tsx index 5e9b594..aac23a7 100644 --- a/apps/web/src/pages/Staff.tsx +++ b/apps/web/src/pages/Staff.tsx @@ -78,7 +78,7 @@ export function StaffPage() {

Staff

-
@@ -145,7 +145,7 @@ export function StaffPage() {
{formError &&

{formError}

}
- diff --git a/apps/web/src/portal/sections/AccountSettings.tsx b/apps/web/src/portal/sections/AccountSettings.tsx index 670e879..2377023 100644 --- a/apps/web/src/portal/sections/AccountSettings.tsx +++ b/apps/web/src/portal/sections/AccountSettings.tsx @@ -22,7 +22,7 @@ export function AccountSettings({ readOnly }: Props) { key={id} onClick={() => setTab(id)} className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${ - tab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50" + tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50" }`} > @@ -69,7 +69,7 @@ function PersonalInfo({ readOnly }: { readOnly: boolean }) {
))} {!readOnly && ( - )} @@ -103,7 +103,7 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
-
@@ -116,7 +116,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
{PETS.map(pet => (
-
+
{pet.photo}
@@ -136,7 +136,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
))} {!readOnly && ( - @@ -165,7 +165,7 @@ function Agreements() { {new Date(agr.dateSigned).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} - + ))} diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index 7d1aa70..fdb9281 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -30,13 +30,13 @@ export function AppointmentsSection({ readOnly }: Props) {
@@ -44,7 +44,7 @@ export function AppointmentsSection({ readOnly }: Props) { {!readOnly && (
@@ -218,7 +218,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo key={pet.id} onClick={() => { setSelectedPet(pet); setStep(2); }} className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${ - selectedPet?.id === pet.id ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300" + selectedPet?.id === pet.id ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300" }`} > {pet.photo} @@ -246,7 +246,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo ); }} className={`w-full flex items-center justify-between p-3 rounded-xl border text-left ${ - selectedServices.find(s => s.id === svc.id) ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300" + selectedServices.find(s => s.id === svc.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300" }`} >
@@ -273,7 +273,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo ); }} className={`w-full flex items-center justify-between p-2.5 rounded-lg border text-left text-sm ${ - selectedAddOns.find(s => s.id === svc.id) ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300" + selectedAddOns.find(s => s.id === svc.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300" }`} >
@@ -291,7 +291,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo @@ -307,7 +307,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo @@ -426,7 +426,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index be750f9..63d1240 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -38,7 +38,7 @@ export function BillingPayments({ readOnly }: Props) { > Add Tip -
@@ -57,7 +57,7 @@ export function BillingPayments({ readOnly }: Props) { key={id} onClick={() => setTab(id)} className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${ - tab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50" + tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50" }`} > @@ -134,7 +134,7 @@ export function BillingPayments({ readOnly }: Props) {
))} {!readOnly && ( - @@ -145,8 +145,8 @@ export function BillingPayments({ readOnly }: Props) {
-
- +
+

Autopay

@@ -156,7 +156,7 @@ export function BillingPayments({ readOnly }: Props) { {!readOnly ? ( @@ -174,7 +174,7 @@ export function BillingPayments({ readOnly }: Props) { {PREPAID_PACKAGES.map(pkg => (
- +

{pkg.name}

@@ -184,7 +184,7 @@ export function BillingPayments({ readOnly }: Props) {
@@ -218,7 +218,7 @@ function TipModal({ onClose }: { onClose: () => void }) { key={pct} onClick={() => { setTipPercent(pct); setCustomTip(""); }} className={`flex-1 py-2 rounded-lg text-sm font-medium border ${ - tipPercent === pct ? "border-[#8b7355] bg-[#faf5ef] text-[#6b5a42]" : "border-stone-200 text-stone-600" + tipPercent === pct ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600" }`} > {pct}% @@ -227,7 +227,7 @@ function TipModal({ onClose }: { onClose: () => void }) { - +
diff --git a/apps/web/src/portal/sections/Communication.tsx b/apps/web/src/portal/sections/Communication.tsx index d7fa4c9..a965054 100644 --- a/apps/web/src/portal/sections/Communication.tsx +++ b/apps/web/src/portal/sections/Communication.tsx @@ -16,7 +16,7 @@ export function Communication({ readOnly }: Props) { @@ -177,7 +177,7 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) { onClick={() => toggle(cat.key, ch.key)} disabled={readOnly} className={`w-10 h-5 rounded-full transition-colors inline-block ${ - prefs[cat.key][ch.key] ? "bg-[#8b7355]" : "bg-stone-300" + prefs[cat.key][ch.key] ? "bg-(--color-accent)" : "bg-stone-300" } ${readOnly ? "cursor-not-allowed opacity-60" : ""}`} >
-
+
Next Appointment
@@ -71,7 +71,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
-
{daysUntil(nextAppt.date)}
+
{daysUntil(nextAppt.date)}
days away
@@ -103,7 +103,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) { className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm text-left hover:border-stone-300 transition-colors" >
-
+
{pet.photo}
@@ -128,14 +128,14 @@ export function Dashboard({ onNavigate, readOnly }: Props) { {/* Loyalty Card */}
-
+
Loyalty Rewards

{LOYALTY.points} pts

@@ -161,7 +161,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) { {!readOnly && ( @@ -176,7 +176,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
{recentEvents.map(evt => (
-
+
{evt.text} {formatDate(evt.date)}
@@ -184,7 +184,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
diff --git a/apps/web/src/portal/sections/PetProfiles.tsx b/apps/web/src/portal/sections/PetProfiles.tsx index 5034be8..3f10d20 100644 --- a/apps/web/src/portal/sections/PetProfiles.tsx +++ b/apps/web/src/portal/sections/PetProfiles.tsx @@ -30,7 +30,7 @@ export function PetProfiles({ readOnly }: Props) { key={p.id} onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }} className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors ${ - p.id === selectedPetId ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 bg-white hover:border-stone-300" + p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300" }`} > {p.photo} @@ -45,7 +45,7 @@ export function PetProfiles({ readOnly }: Props) { {/* Profile Header */}
-
+
{pet.photo}
@@ -74,7 +74,7 @@ export function PetProfiles({ readOnly }: Props) { key={id} onClick={() => setActiveTab(id)} className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap ${ - activeTab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:text-stone-700" + activeTab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:text-stone-700" }`} > @@ -114,7 +114,7 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) { {!readOnly && ( - )} @@ -148,7 +148,7 @@ function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) { {!readOnly && ( - )} @@ -189,7 +189,7 @@ function VaccinationsTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) { {vax.documentUploaded ? ( Uploaded ) : !readOnly ? ( - @@ -226,7 +226,7 @@ function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) { {new Date(appt.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} {appt.reportCardId && ( - Report → + Report → )}
)) diff --git a/apps/web/src/portal/sections/ReportCards.tsx b/apps/web/src/portal/sections/ReportCards.tsx index dacfe60..8336285 100644 --- a/apps/web/src/portal/sections/ReportCards.tsx +++ b/apps/web/src/portal/sections/ReportCards.tsx @@ -33,7 +33,7 @@ export function ReportCards() { className="w-full bg-white rounded-2xl border border-stone-200 p-5 shadow-sm text-left hover:border-stone-300 transition-colors" >
-
+
@@ -70,13 +70,13 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo return (
-
{/* Header */} -
+

{card.petName}'s Grooming Report

{card.beforeDescription}

-
-

After

-
+
+

After

+
Photo placeholder

{card.afterDescription}

@@ -148,7 +148,7 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo )} {/* Groomer's Note */} -
+

A Note from {card.groomerName}

"{card.groomerNote}"

@@ -161,7 +161,7 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo {new Date(card.nextRecommendedDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}

-
diff --git a/packages/db/migrations/0011_impersonation_indexes.sql b/packages/db/migrations/0011_impersonation_indexes.sql new file mode 100644 index 0000000..2529e84 --- /dev/null +++ b/packages/db/migrations/0011_impersonation_indexes.sql @@ -0,0 +1,6 @@ +-- Add indexes on impersonation tables to prevent full table scans +-- Ref: GitHub #95 + +CREATE INDEX "impersonation_sessions_staff_id_status_idx" ON "impersonation_sessions" USING btree ("staff_id","status");--> statement-breakpoint +CREATE INDEX "impersonation_sessions_client_id_idx" ON "impersonation_sessions" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "impersonation_audit_logs_session_id_idx" ON "impersonation_audit_logs" USING btree ("session_id"); diff --git a/packages/db/migrations/meta/0011_snapshot.json b/packages/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..2d20d90 --- /dev/null +++ b/packages/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,1468 @@ +{ + "id": "db89d732-7cd5-414e-848b-7f113dcd94c1", + "prevId": "477cddf9-970f-41c5-9cad-c1ed48c2bedf", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + }, + "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": {}, + "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" + }, + "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": {}, + "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 + }, + "role": { + "name": "role", + "type": "staff_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'groomer'" + }, + "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": { + "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" + ] + } + }, + "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" + ] + } + }, + "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 d0fbd7f..7a47235 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1742500800000, "tag": "0010_impersonation_sessions", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1742587200000, + "tag": "0011_impersonation_indexes", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9ea804f..0ba0d5e 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,7 +3,7 @@ import postgres from "postgres"; import * as schema from "./schema.js"; export * from "./schema.js"; -export { and, asc, desc, eq, gte, gt, lt, lte, ne, or, sql } from "drizzle-orm"; +export { and, asc, desc, eq, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm"; let _db: ReturnType | null = null; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index ab61312..7a59176 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,5 +1,6 @@ import { boolean, + index, integer, jsonb, numeric, @@ -232,32 +233,45 @@ export const impersonationSessionStatusEnum = pgEnum( ["active", "ended", "expired"] ); -export const impersonationSessions = pgTable("impersonation_sessions", { - id: uuid("id").primaryKey().defaultRandom(), - staffId: uuid("staff_id") - .notNull() - .references(() => staff.id, { onDelete: "restrict" }), - clientId: uuid("client_id") - .notNull() - .references(() => clients.id, { onDelete: "restrict" }), - reason: text("reason"), - status: impersonationSessionStatusEnum("status").notNull().default("active"), - startedAt: timestamp("started_at").notNull().defaultNow(), - endedAt: timestamp("ended_at"), - expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at").notNull().defaultNow(), -}); +export const impersonationSessions = pgTable( + "impersonation_sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + staffId: uuid("staff_id") + .notNull() + .references(() => staff.id, { onDelete: "restrict" }), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "restrict" }), + reason: text("reason"), + status: impersonationSessionStatusEnum("status") + .notNull() + .default("active"), + startedAt: timestamp("started_at").notNull().defaultNow(), + endedAt: timestamp("ended_at"), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [ + index("impersonation_sessions_staff_id_status_idx").on(t.staffId, t.status), + index("impersonation_sessions_client_id_idx").on(t.clientId), + ] +); -export const impersonationAuditLogs = pgTable("impersonation_audit_logs", { - id: uuid("id").primaryKey().defaultRandom(), - sessionId: uuid("session_id") - .notNull() - .references(() => impersonationSessions.id, { onDelete: "cascade" }), - action: text("action").notNull(), - pageVisited: text("page_visited"), - metadata: jsonb("metadata").$type>(), - createdAt: timestamp("created_at").notNull().defaultNow(), -}); +export const impersonationAuditLogs = pgTable( + "impersonation_audit_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + sessionId: uuid("session_id") + .notNull() + .references(() => impersonationSessions.id, { onDelete: "cascade" }), + action: text("action").notNull(), + pageVisited: text("page_visited"), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)] +); export const businessSettings = pgTable("business_settings", { id: uuid("id").primaryKey().defaultRandom(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ced239b..cb9f67b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,7 +51,7 @@ importers: specifier: ^6.4.17 version: 6.4.23 '@vitest/coverage-v8': - specifier: ^3.0.4 + specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) eslint: specifier: ^9.18.0 @@ -66,7 +66,7 @@ importers: specifier: ^8.20.0 version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: - specifier: ^3.0.4 + specifier: ^3.2.4 version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0) apps/e2e: