Merge pull request 'promote: dev → uat (GRO-1509 OIDC account_not_linked fix)' (#43) from dev into uat
promote: dev → uat (GRO-1509 OIDC account_not_linked fix) Merged-by: The Dogfather (CTO) Gitea-approved-by: Lint Roller (GRO-1512)
This commit was merged in pull request #43.
This commit is contained in:
@@ -38,6 +38,7 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet
|
|||||||
| TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table |
|
| TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table |
|
||||||
| TC-API-1.14 | Name fallback — no name, email present | Auto-provision where Better-Auth user has name = null, email = "test@example.com" | Staff name = "test" (email prefix before @) |
|
| TC-API-1.14 | Name fallback — no name, email present | Auto-provision where Better-Auth user has name = null, email = "test@example.com" | Staff name = "test" (email prefix before @) |
|
||||||
| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" |
|
| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" |
|
||||||
|
| TC-API-1.16 | OIDC login — Terraform-provisioned user | Initiate OIDC login as any UAT persona (uat-super, uat-groomer, uat-customer, uat-tester), complete authentik callback | 200 OK, session created — no account_not_linked error |
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||||
import { petsRouter } from "../routes/pets.js";
|
import { petsRouter } from "../routes/pets.js";
|
||||||
|
import { and, eq, exists, or } from "../db/index.js";
|
||||||
|
|
||||||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -164,10 +165,10 @@ vi.mock("../db", async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
pets,
|
pets,
|
||||||
appointments,
|
appointments,
|
||||||
and: (...conds: unknown[]) => conds,
|
and: db.and,
|
||||||
eq: (col: unknown, val: unknown) => ({ col, val }),
|
eq: db.eq,
|
||||||
exists: (q: unknown) => q,
|
exists: db.exists,
|
||||||
or: (...conds: unknown[]) => conds,
|
or: db.or,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { getDb, businessSettings, eq, staff } from "./db/index.js";
|
|||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
|
||||||
import { devRouter } from "./routes/dev.js";
|
import { devRouter } from "./routes/dev.js";
|
||||||
|
import { bufferRulesRouter } from "./routes/buffer-rules.js";
|
||||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||||
@@ -211,6 +212,7 @@ api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
|
|||||||
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
|
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
|
||||||
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
|
||||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||||
|
api.use("/buffer-rules/*", requireRole("manager"));
|
||||||
api.use("/admin/settings/*", requireSuperUser());
|
api.use("/admin/settings/*", requireSuperUser());
|
||||||
api.use("/reports/*", requireRole("manager"));
|
api.use("/reports/*", requireRole("manager"));
|
||||||
api.use("/invoices/*", requireRole("manager", "groomer"));
|
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||||
@@ -268,6 +270,7 @@ api.route("/impersonation", impersonationRouter);
|
|||||||
api.route("/admin/settings", settingsRouter);
|
api.route("/admin/settings", settingsRouter);
|
||||||
api.route("/admin/auth-provider", authProviderRouter);
|
api.route("/admin/auth-provider", authProviderRouter);
|
||||||
api.route("/admin/seed", adminSeedRouter);
|
api.route("/admin/seed", adminSeedRouter);
|
||||||
|
api.route("/buffer-rules", bufferRulesRouter);
|
||||||
api.route("/search", searchRouter);
|
api.route("/search", searchRouter);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { z } from "zod/v3";
|
||||||
|
import { and, eq, getDb, isNull } from "../db/index.js";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
import { bufferRules, services } from "../db/index.js";
|
||||||
|
|
||||||
|
export const bufferRulesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
const createBufferRuleSchema = z.object({
|
||||||
|
serviceId: z.string().uuid(),
|
||||||
|
sizeCategory: z
|
||||||
|
.enum(["small", "medium", "large", "extra_large"])
|
||||||
|
.optional(),
|
||||||
|
coatType: z
|
||||||
|
.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"])
|
||||||
|
.optional(),
|
||||||
|
bufferMinutes: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBufferRuleSchema = z.object({
|
||||||
|
bufferMinutes: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET / — list all buffer rules, optionally filtered by serviceId
|
||||||
|
bufferRulesRouter.get("/", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const serviceId = c.req.query("serviceId");
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (serviceId) conditions.push(eq(bufferRules.serviceId, serviceId));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: bufferRules.id,
|
||||||
|
serviceId: bufferRules.serviceId,
|
||||||
|
sizeCategory: bufferRules.sizeCategory,
|
||||||
|
coatType: bufferRules.coatType,
|
||||||
|
bufferMinutes: bufferRules.bufferMinutes,
|
||||||
|
createdAt: bufferRules.createdAt,
|
||||||
|
updatedAt: bufferRules.updatedAt,
|
||||||
|
serviceName: services.name,
|
||||||
|
})
|
||||||
|
.from(bufferRules)
|
||||||
|
.innerJoin(services, eq(bufferRules.serviceId, services.id))
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(bufferRules.createdAt);
|
||||||
|
|
||||||
|
return c.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST / — create a buffer rule
|
||||||
|
bufferRulesRouter.post(
|
||||||
|
"/",
|
||||||
|
zValidator("json", createBufferRuleSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
// Validate serviceId exists
|
||||||
|
const [svc] = await db
|
||||||
|
.select({ id: services.id })
|
||||||
|
.from(services)
|
||||||
|
.where(eq(services.id, body.serviceId));
|
||||||
|
if (!svc) return c.json({ error: "Service not found" }, 404);
|
||||||
|
|
||||||
|
// Check for duplicate (service + size + coat)
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: bufferRules.id })
|
||||||
|
.from(bufferRules)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(bufferRules.serviceId, body.serviceId),
|
||||||
|
body.sizeCategory !== undefined
|
||||||
|
? eq(bufferRules.sizeCategory, body.sizeCategory)
|
||||||
|
: isNull(bufferRules.sizeCategory),
|
||||||
|
body.coatType !== undefined
|
||||||
|
? eq(bufferRules.coatType, body.coatType)
|
||||||
|
: isNull(bufferRules.coatType)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (existing) return c.json({ error: "Duplicate rule for this service+size+coat combination" }, 409);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(bufferRules)
|
||||||
|
.values({
|
||||||
|
serviceId: body.serviceId,
|
||||||
|
sizeCategory: body.sizeCategory ?? null,
|
||||||
|
coatType: body.coatType ?? null,
|
||||||
|
bufferMinutes: body.bufferMinutes,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(row, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /:id — update bufferMinutes only
|
||||||
|
bufferRulesRouter.patch(
|
||||||
|
"/:id",
|
||||||
|
zValidator("json", updateBufferRuleSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const [row] = await db
|
||||||
|
.update(bufferRules)
|
||||||
|
.set({ bufferMinutes: body.bufferMinutes, updatedAt: new Date() })
|
||||||
|
.where(eq(bufferRules.id, c.req.param("id")))
|
||||||
|
.returning();
|
||||||
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
return c.json(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /:id — delete a buffer rule
|
||||||
|
bufferRulesRouter.delete("/:id", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const [row] = await db
|
||||||
|
.delete(bufferRules)
|
||||||
|
.where(eq(bufferRules.id, c.req.param("id")))
|
||||||
|
.returning();
|
||||||
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ const createPetSchema = z.object({
|
|||||||
shampooPreference: z.string().max(500).optional(),
|
shampooPreference: z.string().max(500).optional(),
|
||||||
specialCareNotes: z.string().max(2000).optional(),
|
specialCareNotes: z.string().max(2000).optional(),
|
||||||
customFields: z.record(z.string(), z.string()).optional(),
|
customFields: z.record(z.string(), z.string()).optional(),
|
||||||
|
sizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
|
||||||
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
|
||||||
temperamentScore: z.number().int().min(1).max(5).optional(),
|
temperamentScore: z.number().int().min(1).max(5).optional(),
|
||||||
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
|
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const createServiceSchema = z.object({
|
|||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateServiceSchema = createServiceSchema.partial();
|
const updateServiceSchema = createServiceSchema.partial().extend({
|
||||||
|
defaultBufferMinutes: z.number().int().min(0).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
servicesRouter.get("/", async (c) => {
|
servicesRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ export interface Client {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Medical Alerts ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AlertSeverity = "low" | "medium" | "high";
|
||||||
|
|
||||||
|
export interface MedicalAlert {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
severity: AlertSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pet Profile Summary ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CoatType = "short" | "medium" | "long" | "double" | "wire" | "silky" | "curly" | "hairless";
|
||||||
export interface Pet {
|
export interface Pet {
|
||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
|||||||
@@ -116,6 +116,26 @@ export const verification = pgTable("verification", {
|
|||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Pet enums ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const petSizeCategoryEnum = pgEnum("pet_size_category", [
|
||||||
|
"small",
|
||||||
|
"medium",
|
||||||
|
"large",
|
||||||
|
"extra_large",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const coatTypeEnum = pgEnum("coat_type", [
|
||||||
|
"short",
|
||||||
|
"medium",
|
||||||
|
"long",
|
||||||
|
"double",
|
||||||
|
"wire",
|
||||||
|
"silky",
|
||||||
|
"curly",
|
||||||
|
"hairless",
|
||||||
|
]);
|
||||||
|
|
||||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const clients = pgTable(
|
export const clients = pgTable(
|
||||||
@@ -178,6 +198,7 @@ export const services = pgTable("services", {
|
|||||||
durationMinutes: integer("duration_minutes").notNull(),
|
durationMinutes: integer("duration_minutes").notNull(),
|
||||||
defaultBufferMinutes: integer("default_buffer_minutes"),
|
defaultBufferMinutes: integer("default_buffer_minutes"),
|
||||||
active: boolean("active").notNull().default(true),
|
active: boolean("active").notNull().default(true),
|
||||||
|
defaultBufferMinutes: integer("default_buffer_minutes").notNull().default(0),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
@@ -640,3 +661,34 @@ export const authProviderConfig = pgTable("auth_provider_config", {
|
|||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Buffer Rules ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Buffer time rules per service + pet size/coat combination.
|
||||||
|
// Covers service-level defaults and pet-specific overrides.
|
||||||
|
export const bufferRules = pgTable(
|
||||||
|
"buffer_rules",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
serviceId: uuid("service_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => services.id, { onDelete: "cascade" }),
|
||||||
|
// null sizeCategory means "any size" (wildcard)
|
||||||
|
sizeCategory: petSizeCategoryEnum("size_category"),
|
||||||
|
// null coatType means "any coat type" (wildcard)
|
||||||
|
coatType: coatTypeEnum("coat_type"),
|
||||||
|
// minutes to add to the service duration for this size/coat combo
|
||||||
|
bufferMinutes: integer("buffer_minutes").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// One rule per unique (service, size, coat) combination
|
||||||
|
unique("uq_buffer_rules_service_size_coat").on(
|
||||||
|
t.serviceId,
|
||||||
|
t.sizeCategory,
|
||||||
|
t.coatType
|
||||||
|
),
|
||||||
|
index("idx_buffer_rules_service_id").on(t.serviceId),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|||||||
@@ -251,6 +251,10 @@ export async function initAuth(): Promise<void> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
|
accountLinking: {
|
||||||
|
enabled: true,
|
||||||
|
trustedProviders: ["authentik"],
|
||||||
|
},
|
||||||
storeStateStrategy: "cookie" as const,
|
storeStateStrategy: "cookie" as const,
|
||||||
},
|
},
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
|
|||||||
Reference in New Issue
Block a user