f2501d9972
* feat: add customizable business branding (name, logo, colors) Add admin settings for business branding with name, logo upload, and color scheme via CSS custom properties. Includes database migration, API endpoints, admin settings page, and dynamic branding in both admin nav and customer portal. Closes #61 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review feedback on branding PR - Replace dynamic import with static import for @groombook/db in public branding endpoint - Restore active nav item background highlight (bg-stone-100) in CustomerPortal - Remove non-null assertion in settings route, add proper error handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: trigger CI * fix: resolve lint error and test failure for branding feature Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update E2E tests for branding changes - Update navigation test to expect "GroomBook" (default branding) instead of hardcoded "Paws & Reflect" since CustomerPortal now uses dynamic branding - Add /api/branding mock to shared E2E fixtures so BrandingProvider resolves immediately in all tests, preventing unhandled fetch interference Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: GroomBook CTO <cto@groombook.dev> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: GroomBook CTO <cto@groombook.app>
249 lines
9.1 KiB
TypeScript
249 lines
9.1 KiB
TypeScript
import {
|
|
boolean,
|
|
integer,
|
|
jsonb,
|
|
numeric,
|
|
pgEnum,
|
|
pgTable,
|
|
text,
|
|
timestamp,
|
|
unique,
|
|
uuid,
|
|
} from "drizzle-orm/pg-core";
|
|
|
|
// ─── Enums ────────────────────────────────────────────────────────────────────
|
|
|
|
export const appointmentStatusEnum = pgEnum("appointment_status", [
|
|
"scheduled",
|
|
"confirmed",
|
|
"in_progress",
|
|
"completed",
|
|
"cancelled",
|
|
"no_show",
|
|
]);
|
|
|
|
export const staffRoleEnum = pgEnum("staff_role", [
|
|
"groomer",
|
|
"receptionist",
|
|
"manager",
|
|
]);
|
|
|
|
export const invoiceStatusEnum = pgEnum("invoice_status", [
|
|
"draft",
|
|
"pending",
|
|
"paid",
|
|
"void",
|
|
]);
|
|
|
|
export const paymentMethodEnum = pgEnum("payment_method", [
|
|
"cash",
|
|
"card",
|
|
"check",
|
|
"other",
|
|
]);
|
|
|
|
// ─── Tables ───────────────────────────────────────────────────────────────────
|
|
|
|
export const clients = pgTable("clients", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
name: text("name").notNull(),
|
|
email: text("email"),
|
|
phone: text("phone"),
|
|
address: text("address"),
|
|
notes: text("notes"),
|
|
// Set to true if the client has opted out of email reminders/notifications
|
|
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
});
|
|
|
|
export const pets = pgTable("pets", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
clientId: uuid("client_id")
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: "cascade" }),
|
|
name: text("name").notNull(),
|
|
species: text("species").notNull(),
|
|
breed: text("breed"),
|
|
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
|
dateOfBirth: timestamp("date_of_birth"),
|
|
healthAlerts: text("health_alerts"),
|
|
groomingNotes: text("grooming_notes"),
|
|
cutStyle: text("cut_style"),
|
|
shampooPreference: text("shampoo_preference"),
|
|
specialCareNotes: text("special_care_notes"),
|
|
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
});
|
|
|
|
export const services = pgTable("services", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
name: text("name").notNull(),
|
|
description: text("description"),
|
|
basePriceCents: integer("base_price_cents").notNull(),
|
|
durationMinutes: integer("duration_minutes").notNull(),
|
|
active: boolean("active").notNull().default(true),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
});
|
|
|
|
export const staff = pgTable("staff", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
name: text("name").notNull(),
|
|
email: text("email").notNull().unique(),
|
|
// oidcSub links to the Authentik OIDC subject claim
|
|
oidcSub: text("oidc_sub").unique(),
|
|
role: staffRoleEnum("role").notNull().default("groomer"),
|
|
active: boolean("active").notNull().default(true),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
});
|
|
|
|
export const recurringSeries = pgTable("recurring_series", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
// How many weeks between each appointment in the series
|
|
frequencyWeeks: integer("frequency_weeks").notNull(),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
});
|
|
|
|
// appointmentGroups links multiple appointments from the same client visit.
|
|
// Each pet in the group gets its own appointment row with its own groomer.
|
|
export const appointmentGroups = pgTable("appointment_groups", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
clientId: uuid("client_id")
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: "restrict" }),
|
|
notes: text("notes"),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
});
|
|
|
|
export const appointments = pgTable("appointments", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
clientId: uuid("client_id")
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: "restrict" }),
|
|
petId: uuid("pet_id")
|
|
.notNull()
|
|
.references(() => pets.id, { onDelete: "restrict" }),
|
|
serviceId: uuid("service_id")
|
|
.notNull()
|
|
.references(() => services.id, { onDelete: "restrict" }),
|
|
staffId: uuid("staff_id").references(() => staff.id, {
|
|
onDelete: "set null",
|
|
}),
|
|
// Optional secondary staff (bather/assistant) for tip-split tracking
|
|
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
|
onDelete: "set null",
|
|
}),
|
|
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
|
startTime: timestamp("start_time").notNull(),
|
|
endTime: timestamp("end_time").notNull(),
|
|
notes: text("notes"),
|
|
// Override price at time of booking (null = use service base price)
|
|
priceCents: integer("price_cents"),
|
|
// Recurring series support
|
|
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
|
onDelete: "set null",
|
|
}),
|
|
seriesIndex: integer("series_index"),
|
|
// Multi-pet group booking: links this appointment to others in the same visit
|
|
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
|
onDelete: "set null",
|
|
}),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
});
|
|
|
|
export const invoices = pgTable("invoices", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
appointmentId: uuid("appointment_id").references(() => appointments.id, {
|
|
onDelete: "restrict",
|
|
}),
|
|
clientId: uuid("client_id")
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: "restrict" }),
|
|
subtotalCents: integer("subtotal_cents").notNull(),
|
|
taxCents: integer("tax_cents").notNull().default(0),
|
|
tipCents: integer("tip_cents").notNull().default(0),
|
|
totalCents: integer("total_cents").notNull(),
|
|
status: invoiceStatusEnum("status").notNull().default("draft"),
|
|
paymentMethod: paymentMethodEnum("payment_method"),
|
|
paidAt: timestamp("paid_at"),
|
|
notes: text("notes"),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
});
|
|
|
|
export const invoiceLineItems = pgTable("invoice_line_items", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
invoiceId: uuid("invoice_id")
|
|
.notNull()
|
|
.references(() => invoices.id, { onDelete: "cascade" }),
|
|
description: text("description").notNull(),
|
|
quantity: integer("quantity").notNull().default(1),
|
|
unitPriceCents: integer("unit_price_cents").notNull(),
|
|
totalCents: integer("total_cents").notNull(),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
});
|
|
|
|
// Per-staff tip allocation calculated when an invoice is paid.
|
|
// staff_name is snapshotted at calculation time so reports remain accurate if staff is deleted.
|
|
export const invoiceTipSplits = pgTable("invoice_tip_splits", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
invoiceId: uuid("invoice_id")
|
|
.notNull()
|
|
.references(() => invoices.id, { onDelete: "cascade" }),
|
|
staffId: uuid("staff_id").references(() => staff.id, { onDelete: "set null" }),
|
|
staffName: text("staff_name").notNull(),
|
|
sharePct: numeric("share_pct", { precision: 5, scale: 2 }).notNull(),
|
|
shareCents: integer("share_cents").notNull(),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
});
|
|
|
|
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
|
// reminder_type values: "confirmation", "24h", "2h"
|
|
export const reminderLogs = pgTable(
|
|
"reminder_logs",
|
|
{
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
appointmentId: uuid("appointment_id")
|
|
.notNull()
|
|
.references(() => appointments.id, { onDelete: "cascade" }),
|
|
// "confirmation" | "24h" | "2h"
|
|
reminderType: text("reminder_type").notNull(),
|
|
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
|
},
|
|
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
|
);
|
|
|
|
export const businessSettings = pgTable("business_settings", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
businessName: text("business_name").notNull().default("GroomBook"),
|
|
logoBase64: text("logo_base64"),
|
|
logoMimeType: text("logo_mime_type"),
|
|
primaryColor: text("primary_color").notNull().default("#4f8a6f"),
|
|
accentColor: text("accent_color").notNull().default("#8b7355"),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
});
|
|
|
|
export const groomingVisitLogs = pgTable("grooming_visit_logs", {
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
petId: uuid("pet_id")
|
|
.notNull()
|
|
.references(() => pets.id, { onDelete: "cascade" }),
|
|
appointmentId: uuid("appointment_id").references(() => appointments.id, {
|
|
onDelete: "set null",
|
|
}),
|
|
staffId: uuid("staff_id").references(() => staff.id, {
|
|
onDelete: "set null",
|
|
}),
|
|
cutStyle: text("cut_style"),
|
|
productsUsed: text("products_used"),
|
|
notes: text("notes"),
|
|
groomedAt: timestamp("groomed_at").notNull().defaultNow(),
|
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
});
|