diff --git a/apps/api/src/__tests__/groomerIsolation.test.ts b/apps/api/src/__tests__/groomerIsolation.test.ts index 04f087f..9f0838e 100644 --- a/apps/api/src/__tests__/groomerIsolation.test.ts +++ b/apps/api/src/__tests__/groomerIsolation.test.ts @@ -17,6 +17,7 @@ const MANAGER: StaffRow = { oidcSub: "oidc-manager-sub", userId: null, role: "manager", + isSuperUser: true, name: "Manager McManager", email: "manager@example.com", active: true, diff --git a/apps/api/src/__tests__/petPhotos.test.ts b/apps/api/src/__tests__/petPhotos.test.ts index 19b8564..29f22c9 100644 --- a/apps/api/src/__tests__/petPhotos.test.ts +++ b/apps/api/src/__tests__/petPhotos.test.ts @@ -9,6 +9,7 @@ const MANAGER: StaffRow = { oidcSub: "oidc-manager-sub", userId: null, role: "manager", + isSuperUser: true, name: "Manager McManager", email: "manager@example.com", active: true, diff --git a/apps/api/src/__tests__/rbac.test.ts b/apps/api/src/__tests__/rbac.test.ts index e213ed7..c79e821 100644 --- a/apps/api/src/__tests__/rbac.test.ts +++ b/apps/api/src/__tests__/rbac.test.ts @@ -10,6 +10,7 @@ const MANAGER: StaffRow = { oidcSub: "oidc-manager-sub", userId: "ba-user-manager", role: "manager", + isSuperUser: true, name: "Manager McManager", email: "manager@example.com", active: true, diff --git a/packages/db/migrations/0019_concerned_sunfire.sql b/packages/db/migrations/0019_concerned_sunfire.sql new file mode 100644 index 0000000..a1321eb --- /dev/null +++ b/packages/db/migrations/0019_concerned_sunfire.sql @@ -0,0 +1,84 @@ +CREATE TYPE "public"."waitlist_status" AS ENUM('active', 'notified', 'expired', 'cancelled');--> statement-breakpoint +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "waitlist_entries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "client_id" uuid NOT NULL, + "pet_id" uuid NOT NULL, + "service_id" uuid NOT NULL, + "preferred_date" text NOT NULL, + "preferred_time" text NOT NULL, + "status" "waitlist_status" DEFAULT 'active' NOT NULL, + "notified_at" timestamp, + "expires_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "appointments" ADD COLUMN "confirmation_status" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint +ALTER TABLE "appointments" ADD COLUMN "confirmed_at" timestamp;--> statement-breakpoint +ALTER TABLE "appointments" ADD COLUMN "cancelled_at" timestamp;--> statement-breakpoint +ALTER TABLE "appointments" ADD COLUMN "confirmation_token" text;--> statement-breakpoint +ALTER TABLE "appointments" ADD COLUMN "customer_notes" text;--> statement-breakpoint +ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint +ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;--> statement-breakpoint +ALTER TABLE "staff" ADD COLUMN "user_id" text;--> statement-breakpoint +ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "staff" ADD COLUMN "ical_token" text;--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_waitlist_client_id" ON "waitlist_entries" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "idx_waitlist_preferred_date" ON "waitlist_entries" USING btree ("preferred_date");--> statement-breakpoint +CREATE INDEX "idx_waitlist_status" ON "waitlist_entries" USING btree ("status");--> statement-breakpoint +ALTER TABLE "staff" ADD CONSTRAINT "staff_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_confirmation_token_unique" UNIQUE("confirmation_token");--> statement-breakpoint +ALTER TABLE "staff" ADD CONSTRAINT "staff_ical_token_unique" UNIQUE("ical_token"); \ No newline at end of file diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index df67583..eedc6e5 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -52,6 +52,7 @@ export function buildStaff(overrides: Partial = {}): StaffRow { oidcSub: `oidc-${id}`, userId: null, role: "groomer", + isSuperUser: false, active: true, icalToken: null, createdAt: new Date("2025-01-01T00:00:00Z"), diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e1bb62f..3c75c9f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -159,6 +159,8 @@ export const staff = pgTable("staff", { // 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(), diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index cd68a31..f7936be 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -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,24 +385,24 @@ 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]; @@ -411,6 +412,7 @@ async function seed() { name: s.name, email: s.email, role: s.role, + isSuperUser: s.isSuperUser, active: true, }); } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b019921..198b8b3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -72,6 +72,7 @@ export interface Staff { name: string; email: string; role: "groomer" | "receptionist" | "manager"; + isSuperUser: boolean; active: boolean; createdAt: string; updatedAt: string;