This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
app/packages/db/src/schema.ts
T
groombook-cto[bot] d433c902b4 fix(GRO-637): invoice status transitions, tip-split validation, refund idempotency, and tip-split response format
* Fix invoice status transitions, tip-split validation, refund idempotency, and tip-split response format

- Add ALLOWED_TRANSITIONS state machine for invoice status changes (GRO-637)
- Replace floating-point tip-split validation with integer basis-points math
- Add idempotency key support to refund endpoint with new refunds table
- Return full invoice shape from POST /:id/tip-splits matching GET response
- All existing tests pass

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(invoices): wrap refund flow in transaction for idempotency safety

- Wrap idempotency check + processRefund() + db.insert() in db.transaction()
- This prevents duplicate Stripe refunds if the DB insert fails after Stripe processes the refund
- Add migration 0027_refunds for the refunds table (was missing)
- Removes out-of-scope changes from PR #278 (csrf.ts, appointmentGroups, appointments, book, groomingLogs, services, stripe-webhooks)

Fixes GRO-637 per CTO review

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(api): wire up CSRF middleware for protected routes

Register csrfMiddleware in the protected API routes after authMiddleware
and resolveStaffMiddleware to protect against CSRF attacks on state-
changing operations (POST, PUT, PATCH, DELETE).

Addresses CTO review feedback on PR #278.

* fix(api): remove CSRF middleware that breaks POST/PUT/PATCH/DELETE

The CSRF middleware requires x-csrf-token header but the frontend never
sends it, which would break all mutating operations with 403 errors.

CSRF protection should be implemented in a separate coordinated PR with
frontend changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Flea Flicker <flea-flicker@groombook.ai>
2026-04-15 06:04:38 +00:00

465 lines
17 KiB
TypeScript

import {
boolean,
index,
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",
]);
export const clientStatusEnum = pgEnum("client_status", [
"active",
"disabled",
]);
// ─── Better-Auth Tables ──────────────────────────────────────────────────────
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// ─── 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"),
emailOptOut: boolean("email_opt_out").notNull().default(false),
stripeCustomerId: text("stripe_customer_id"),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
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({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
image: text("image"),
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().unique(),
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(),
// Better-Auth user ID — links staff business record to auth identity
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
role: staffRoleEnum("role").notNull().default("groomer"),
// Super users bypass appointment-booking restrictions and access admin panels
isSuperUser: boolean("is_super_user").notNull().default(false),
active: boolean("active").notNull().default(true),
// Token for iCal calendar feed subscription (no auth required)
icalToken: text("ical_token").unique(),
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",
}),
// Customer confirmation/cancellation tracking
// Values: "pending" | "confirmed" | "cancelled"
confirmationStatus: text("confirmation_status").notNull().default("pending"),
confirmedAt: timestamp("confirmed_at"),
cancelledAt: timestamp("cancelled_at"),
// Token for tokenized email confirm/cancel links (no auth required)
confirmationToken: text("confirmation_token").unique(),
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
customerNotes: text("customer_notes"),
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"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
stripeRefundId: text("stripe_refund_id"),
paymentFailureReason: text("payment_failure_reason"),
notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
index("idx_invoices_client_id").on(t.clientId),
index("idx_invoices_status").on(t.status),
index("idx_invoices_created_at").on(t.createdAt),
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
]
);
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(),
},
(t) => [index("idx_invoice_line_items_invoice_id").on(t.invoiceId)]
);
// 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(),
},
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
);
// Refund records with idempotency key support
export const refunds = pgTable(
"refunds",
{
id: uuid("id").primaryKey().defaultRandom(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "restrict" }),
stripeRefundId: text("stripe_refund_id").notNull(),
idempotencyKey: text("idempotency_key").unique(),
amountCents: integer("amount_cents"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
index("idx_refunds_invoice_id").on(t.invoiceId),
index("idx_refunds_idempotency_key").on(t.idempotencyKey),
]
);
// 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)]
);
// ─── Impersonation ──────────────────────────────────────────────────────────
export const impersonationSessionStatusEnum = pgEnum(
"impersonation_session_status",
["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(),
},
(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<Record<string, unknown>>(),
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(),
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(),
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(),
});
export const waitlistStatusEnum = pgEnum("waitlist_status", [
"active",
"notified",
"expired",
"cancelled",
]);
export const waitlistEntries = pgTable(
"waitlist_entries",
{
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "cascade" }),
serviceId: uuid("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
preferredDate: text("preferred_date").notNull(),
preferredTime: text("preferred_time").notNull(),
status: waitlistStatusEnum("status").notNull().default("active"),
notifiedAt: timestamp("notified_at"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [
index("idx_waitlist_client_id").on(t.clientId),
index("idx_waitlist_preferred_date").on(t.preferredDate),
index("idx_waitlist_status").on(t.status),
]
);
// ─── Auth Provider Config ──────────────────────────────────────────────────
export const authProviderConfig = pgTable("auth_provider_config", {
id: uuid("id").primaryKey().defaultRandom(),
providerId: text("provider_id").notNull().unique(), // e.g. "authentik", "okta", "entra-id"
displayName: text("display_name").notNull(), // shown on login button
issuerUrl: text("issuer_url").notNull(), // OIDC issuer/discovery URL
internalBaseUrl: text("internal_base_url"), // for hairpin NAT / K8s internal routing
clientId: text("client_id").notNull(),
clientSecret: text("client_secret").notNull(), // AES-256-GCM encrypted using BETTER_AUTH_SECRET
scopes: text("scopes").notNull().default("openid profile email"),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});