fix(portal): prefix unused totalPending param with underscore

Silence @typescript-eslint/no-unused-vars for totalPending in
PaymentModal — it is accepted as a prop for API compatibility but the
modal computes selectedTotal from selected invoices instead.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Barkley Trimsworth
2026-03-29 19:56:14 +00:00
parent 9caa03723c
commit 38f435375f
89 changed files with 7064 additions and 916 deletions
@@ -0,0 +1,49 @@
-- Better-Auth required tables for session-based authentication
CREATE TABLE "user" (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT false,
image TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE "session" (
id TEXT PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
token TEXT NOT NULL UNIQUE,
ip_address TEXT,
user_agent TEXT,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE "account" (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at TIMESTAMPTZ,
refresh_token_expires_at TIMESTAMPTZ,
scope TEXT,
password TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE "verification" (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Link staff records to auth identity
ALTER TABLE staff ADD COLUMN user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL;
@@ -0,0 +1,14 @@
-- Backfill staff.user_id for staff records created before Better-Auth integration.
-- Staff records that predate this migration have user_id = NULL; the resolveStaffMiddleware
-- now falls back to staff.id (dev mode) and oidcSub (production) so these records still work.
-- This migration populates user_id for the known demo/dev staff seeded by seed.ts.
-- Create demo Better-Auth users for seeded staff (these match the ba-user-* IDs used in tests)
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
VALUES ('ba-user-manager', 'Demo Manager', 'demo-manager@groombook.dev', true, NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
-- Link the demo manager staff record to the Better-Auth user
UPDATE staff
SET user_id = 'ba-user-manager', updated_at = NOW()
WHERE oidc_sub = 'demo-manager-001' AND user_id IS NULL;
@@ -0,0 +1 @@
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
+56
View File
@@ -85,6 +85,62 @@
"when": 1742587200000,
"tag": "0011_impersonation_indexes",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1774080000000,
"tag": "0012_pet_photo",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1774166400000,
"tag": "0013_appointment_confirmation",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1774252800000,
"tag": "0014_customer_notes",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1774339200000,
"tag": "0015_waitlist",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1774425600000,
"tag": "0016_ical_token",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1774512000000,
"tag": "0017_better_auth_tables",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1774598400000,
"tag": "0018_backfill_staff_user_id",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1774729055924,
"tag": "0019_concerned_sunfire",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -33,5 +33,6 @@
"drizzle-kit": "^0.30.4",
"tsx": "^4.19.0",
"typescript": "^5.7.3"
}
},
"license": "AGPL-3.0-only"
}
+2
View File
@@ -50,7 +50,9 @@ export function buildStaff(overrides: Partial<StaffRow> = {}): StaffRow {
name: `Staff Member ${id}`,
email: `${id}@groombook.test`,
oidcSub: `oidc-${id}`,
userId: null,
role: "groomer",
isSuperUser: false,
active: true,
icalToken: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
+1 -1
View File
@@ -3,7 +3,7 @@ import postgres from "postgres";
import * as schema from "./schema.js";
export * from "./schema.js";
export { and, asc, desc, eq, exists, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm";
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
let _db: ReturnType<typeof drizzle> | null = null;
+56
View File
@@ -48,6 +48,58 @@ export const clientStatusEnum = pgEnum("client_status", [
"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", {
@@ -104,7 +156,11 @@ export const staff = pgTable("staff", {
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(),
+65 -25
View File
@@ -287,6 +287,7 @@ async function seedKnownUsers() {
email: "demo-manager@groombook.dev",
oidcSub: "demo-manager-001",
role: "manager",
isSuperUser: true,
active: true,
});
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
@@ -384,35 +385,41 @@ async function seed() {
// ── Staff ──
// Deterministic staff IDs so they can be referenced in scripts/tests
const managerStaff = [
{ id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const },
{ id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: true },
];
const receptionistStaff = [
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const },
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const, isSuperUser: false },
];
const groomers = [
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const, isSuperUser: false },
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const, isSuperUser: false },
{ id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const, isSuperUser: false },
];
// Bathers are groomers by role but serve as the secondary staff (bather) on appointments
const bathers = [
{ id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const, isSuperUser: false },
{ id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const, isSuperUser: false },
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const, isSuperUser: false },
];
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
for (const s of allStaff) {
await db.insert(schema.staff).values({
id: s.id,
name: s.name,
email: s.email,
role: s.role,
active: true,
});
await db.insert(schema.staff)
.values({
id: s.id,
name: s.name,
email: s.email,
role: s.role,
isSuperUser: s.isSuperUser,
active: true,
})
.onConflictDoUpdate({
target: schema.staff.email,
set: { name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true },
});
}
console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`);
@@ -421,14 +428,19 @@ async function seed() {
for (const s of servicesDef) {
const id = uuid();
serviceIds.push(id);
await db.insert(schema.services).values({
id,
name: s.name,
description: s.desc,
basePriceCents: s.price,
durationMinutes: s.dur,
active: true,
});
await db.insert(schema.services)
.values({
id,
name: s.name,
description: s.desc,
basePriceCents: s.price,
durationMinutes: s.dur,
active: true,
})
.onConflictDoUpdate({
target: schema.services.id,
set: { name: s.name, description: s.desc, basePriceCents: s.price, durationMinutes: s.dur, active: true },
});
}
console.log(`✓ Created ${servicesDef.length} services`);
@@ -500,8 +512,36 @@ async function seed() {
}
}
await db.insert(schema.clients).values(clientBatch);
await db.insert(schema.pets).values(petBatch);
for (const client of clientBatch) {
await db.insert(schema.clients)
.values(client)
.onConflictDoUpdate({
target: schema.clients.email,
set: { name: client.name, phone: client.phone, address: client.address, notes: client.notes, emailOptOut: client.emailOptOut },
});
}
for (const pet of petBatch) {
await db.insert(schema.pets)
.values(pet)
.onConflictDoUpdate({
target: schema.pets.id,
set: {
clientId: pet.clientId,
name: pet.name,
species: pet.species,
breed: pet.breed,
weightKg: pet.weightKg,
dateOfBirth: pet.dateOfBirth,
healthAlerts: pet.healthAlerts,
groomingNotes: pet.groomingNotes,
cutStyle: pet.cutStyle,
shampooPreference: pet.shampooPreference,
specialCareNotes: pet.specialCareNotes,
customFields: pet.customFields,
},
});
}
}
console.log(`✓ Created 500 clients with ${petRecords.length} pets`);