diff --git a/apps/api/src/__tests__/groomerIsolation.test.ts b/apps/api/src/__tests__/groomerIsolation.test.ts index f1bd97d..04f087f 100644 --- a/apps/api/src/__tests__/groomerIsolation.test.ts +++ b/apps/api/src/__tests__/groomerIsolation.test.ts @@ -15,6 +15,7 @@ import type { StaffRow } from "../middleware/rbac.js"; const MANAGER: StaffRow = { id: "staff-manager-id", oidcSub: "oidc-manager-sub", + userId: null, role: "manager", name: "Manager McManager", email: "manager@example.com", diff --git a/apps/api/src/__tests__/petPhotos.test.ts b/apps/api/src/__tests__/petPhotos.test.ts index b4d2d6b..19b8564 100644 --- a/apps/api/src/__tests__/petPhotos.test.ts +++ b/apps/api/src/__tests__/petPhotos.test.ts @@ -7,6 +7,7 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js"; const MANAGER: StaffRow = { id: "staff-manager-id", oidcSub: "oidc-manager-sub", + userId: null, role: "manager", name: "Manager McManager", email: "manager@example.com", diff --git a/apps/api/src/__tests__/rbac.test.ts b/apps/api/src/__tests__/rbac.test.ts index b052507..9d8c597 100644 --- a/apps/api/src/__tests__/rbac.test.ts +++ b/apps/api/src/__tests__/rbac.test.ts @@ -8,6 +8,7 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js"; const MANAGER: StaffRow = { id: "staff-manager-id", oidcSub: "oidc-manager-sub", + userId: null, role: "manager", name: "Manager McManager", email: "manager@example.com", diff --git a/packages/db/migrations/0017_better_auth_tables.sql b/packages/db/migrations/0017_better_auth_tables.sql new file mode 100644 index 0000000..b5e1f74 --- /dev/null +++ b/packages/db/migrations/0017_better_auth_tables.sql @@ -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; diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index b2327e3..df67583 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -50,6 +50,7 @@ export function buildStaff(overrides: Partial = {}): StaffRow { name: `Staff Member ${id}`, email: `${id}@groombook.test`, oidcSub: `oidc-${id}`, + userId: null, role: "groomer", active: true, icalToken: null, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index ddcddc7..e1bb62f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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,6 +156,8 @@ 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"), active: boolean("active").notNull().default(true), // Token for iCal calendar feed subscription (no auth required)