Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08e15dafd5 |
@@ -195,11 +195,10 @@ describe("POST /clients", () => {
|
|||||||
expect(insertedValues[0]!.name).toBe("Charlie");
|
expect(insertedValues[0]!.name).toBe("Charlie");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a client with name and email", async () => {
|
it("creates a client with only required name field", async () => {
|
||||||
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
|
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(insertedValues[0]!.name).toBe("Dana");
|
expect(insertedValues[0]!.name).toBe("Dana");
|
||||||
expect(insertedValues[0]!.email).toBe("dana@example.com");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects empty name", async () => {
|
it("rejects empty name", async () => {
|
||||||
|
|||||||
@@ -204,11 +204,15 @@ export async function initAuth(): Promise<void> {
|
|||||||
const userInfoUrl = discovery.userinfo_endpoint;
|
const userInfoUrl = discovery.userinfo_endpoint;
|
||||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||||
const authzUrlObj = new URL(authzUrl);
|
const authzUrlObj = new URL(authzUrl);
|
||||||
// Only validate authorizationUrl hostname against issuer — token/userinfo
|
const tokenUrlObj = new URL(tokenUrl);
|
||||||
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
|
const userInfoUrlObj = new URL(userInfoUrl);
|
||||||
if (authzUrlObj.hostname !== issuerHostname) {
|
if (
|
||||||
|
authzUrlObj.hostname !== issuerHostname ||
|
||||||
|
tokenUrlObj.hostname !== issuerHostname ||
|
||||||
|
userInfoUrlObj.hostname !== issuerHostname
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}', '${tokenUrlObj.hostname}', or '${userInfoUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
oidcConfig = {
|
oidcConfig = {
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
|
||||||
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
|
||||||
import type { PortalEnv } from "./portalSession.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side audit logging middleware for portal routes.
|
|
||||||
* Applied after validatePortalSession in the middleware chain.
|
|
||||||
*
|
|
||||||
* After the route handler completes (await next()), inserts an audit log entry
|
|
||||||
* into impersonationAuditLogs:
|
|
||||||
* - sessionId: from c.get("portalSessionId")
|
|
||||||
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
|
|
||||||
* - pageVisited: c.req.path
|
|
||||||
* - metadata: { method, statusCode: c.res.status }
|
|
||||||
*
|
|
||||||
* Log entries are written for both success and error responses.
|
|
||||||
* Does NOT throw if audit logging fails — errors are logged but the user's
|
|
||||||
* request is not affected.
|
|
||||||
*/
|
|
||||||
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
|
||||||
await next();
|
|
||||||
|
|
||||||
const sessionId = c.get("portalSessionId");
|
|
||||||
if (!sessionId) return;
|
|
||||||
|
|
||||||
const method = c.req.method;
|
|
||||||
const routePath = c.req.path;
|
|
||||||
const pageVisited = c.req.path;
|
|
||||||
const statusCode = c.res.status;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
await db
|
|
||||||
.insert(impersonationAuditLogs)
|
|
||||||
.values({
|
|
||||||
sessionId,
|
|
||||||
action: `${method} ${routePath}`,
|
|
||||||
pageVisited,
|
|
||||||
metadata: { method, statusCode },
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[portalAudit] Failed to write audit log:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
|
||||||
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
|
||||||
|
|
||||||
export interface PortalEnv {
|
|
||||||
Variables: {
|
|
||||||
portalClientId: string;
|
|
||||||
portalSessionId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
|
||||||
* Must be applied to all portal routes.
|
|
||||||
*
|
|
||||||
* Reads x-session-id from request headers, queries impersonationSessions for a row where
|
|
||||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
|
||||||
* Returns 401 if session is invalid/missing/expired.
|
|
||||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
|
||||||
*/
|
|
||||||
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
||||||
if (!sessionId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
c.set("portalClientId", session.clientId);
|
|
||||||
c.set("portalSessionId", session.id);
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
@@ -338,35 +338,44 @@ async function sendConfirmationEmail(
|
|||||||
db: ReturnType<typeof getDb>,
|
db: ReturnType<typeof getDb>,
|
||||||
appt: typeof appointments.$inferSelect
|
appt: typeof appointments.$inferSelect
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [row] = await db
|
const [client] = await db
|
||||||
.select({
|
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||||
clientName: clients.name,
|
.from(clients)
|
||||||
clientEmail: clients.email,
|
.where(eq(clients.id, appt.clientId))
|
||||||
clientEmailOptOut: clients.emailOptOut,
|
|
||||||
petName: pets.name,
|
|
||||||
serviceName: services.name,
|
|
||||||
groomerName: staff.name,
|
|
||||||
})
|
|
||||||
.from(appointments)
|
|
||||||
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
|
||||||
.innerJoin(pets, eq(pets.id, appointments.petId))
|
|
||||||
.innerJoin(services, eq(services.id, appointments.serviceId))
|
|
||||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
|
||||||
.where(eq(appointments.id, appt.id))
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!row) return;
|
if (!client || !client.email || client.emailOptOut) return;
|
||||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
|
||||||
|
|
||||||
if (!clientEmail || clientEmailOptOut) return;
|
const [pet] = await db
|
||||||
if (!petName || !serviceName) return;
|
.select({ name: pets.name })
|
||||||
|
.from(pets)
|
||||||
|
.where(eq(pets.id, appt.petId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const [service] = await db
|
||||||
|
.select({ name: services.name })
|
||||||
|
.from(services)
|
||||||
|
.where(eq(services.id, appt.serviceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let groomerName: string | null = null;
|
||||||
|
if (appt.staffId) {
|
||||||
|
const [groomer] = await db
|
||||||
|
.select({ name: staff.name })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, appt.staffId))
|
||||||
|
.limit(1);
|
||||||
|
groomerName = groomer?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pet || !service) return;
|
||||||
|
|
||||||
const sent = await sendEmail(
|
const sent = await sendEmail(
|
||||||
buildConfirmationEmail(clientEmail, {
|
buildConfirmationEmail(client.email, {
|
||||||
clientName,
|
clientName: client.name,
|
||||||
petName,
|
petName: pet.name,
|
||||||
serviceName,
|
serviceName: service.name,
|
||||||
groomerName: groomerName ?? null,
|
groomerName,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const clientsRouter = new Hono<AppEnv>();
|
|||||||
|
|
||||||
const createClientSchema = z.object({
|
const createClientSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
email: z.string().email(),
|
email: z.string().email().optional(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
address: z.string().max(500).optional(),
|
address: z.string().max(500).optional(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
|
|||||||
+122
-23
@@ -1,22 +1,33 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { eq, inArray } from "@groombook/db";
|
import { and, eq, inArray } from "@groombook/db";
|
||||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import { portalAudit } from "../middleware/portalAudit.js";
|
|
||||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
|
||||||
|
|
||||||
export const portalRouter = new Hono<PortalEnv>();
|
export const portalRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// Apply middleware to all portal routes
|
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
|
||||||
|
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const db = getDb();
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||||
|
.limit(1);
|
||||||
|
if (!session || session.expiresAt <= new Date()) return null;
|
||||||
|
return session.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
portalRouter.get("/me", async (c) => {
|
portalRouter.get("/me", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||||
if (!client) return c.json({ error: "Not found" }, 404);
|
if (!client) return c.json({ error: "Not found" }, 404);
|
||||||
@@ -38,7 +49,9 @@ portalRouter.get("/services", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/appointments", async (c) => {
|
portalRouter.get("/appointments", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const allAppts = await db
|
const allAppts = await db
|
||||||
@@ -88,7 +101,9 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/pets", async (c) => {
|
portalRouter.get("/pets", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||||
@@ -96,7 +111,9 @@ portalRouter.get("/pets", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/invoices", async (c) => {
|
portalRouter.get("/invoices", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||||
const invoiceIds = clientInvoices.map(i => i.id);
|
const invoiceIds = clientInvoices.map(i => i.id);
|
||||||
@@ -131,7 +148,12 @@ portalRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -174,7 +196,12 @@ portalRouter.patch(
|
|||||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -223,7 +250,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
|||||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -287,7 +319,28 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
let clientId: string | null = null;
|
||||||
|
if (sessionId) {
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (session && session.expiresAt > new Date()) {
|
||||||
|
clientId = session.clientId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [entry] = await db
|
const [entry] = await db
|
||||||
.insert(waitlistEntries)
|
.insert(waitlistEntries)
|
||||||
@@ -311,7 +364,26 @@ portalRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -320,7 +392,7 @@ portalRouter.patch(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||||
if (existing.clientId !== clientId) {
|
if (existing.clientId !== session.clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +414,26 @@ portalRouter.patch(
|
|||||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [entry] = await db
|
const [entry] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -351,7 +442,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||||
if (entry.clientId !== clientId) {
|
if (entry.clientId !== session.clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +475,9 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const invoiceRows = await db
|
const invoiceRows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -421,7 +514,9 @@ portalRouter.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
portalRouter.get("/payment-methods", async (c) => {
|
portalRouter.get("/payment-methods", async (c) => {
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const methods = await listPaymentMethods(clientId);
|
const methods = await listPaymentMethods(clientId);
|
||||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
@@ -429,7 +524,9 @@ portalRouter.get("/payment-methods", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.post("/payment-methods", async (c) => {
|
portalRouter.post("/payment-methods", async (c) => {
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||||
@@ -442,7 +539,9 @@ portalRouter.post("/payment-methods", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const paymentMethodId = c.req.param("id");
|
const paymentMethodId = c.req.param("id");
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getDb,
|
getDb,
|
||||||
gte,
|
gte,
|
||||||
lt,
|
lt,
|
||||||
|
sql,
|
||||||
appointments,
|
appointments,
|
||||||
clients,
|
clients,
|
||||||
pets,
|
pets,
|
||||||
@@ -31,6 +32,8 @@ function getReminderWindows(): { label: string; hours: number }[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checks for upcoming appointments that need reminders and sends them.
|
||||||
|
// Runs every minute — idempotent via reminder_logs unique constraint.
|
||||||
export async function runReminderCheck(): Promise<void> {
|
export async function runReminderCheck(): Promise<void> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -59,69 +62,74 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
||||||
|
|
||||||
|
if (appointmentIds.length === 0) continue;
|
||||||
|
|
||||||
|
// Batch-fetch already-sent appointment IDs (both EMAIL and SMS channels)
|
||||||
|
const sentAppointmentIds = new Set(
|
||||||
|
(
|
||||||
|
await db
|
||||||
|
.select({ appointmentId: reminderLogs.appointmentId })
|
||||||
|
.from(reminderLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminderLogs.reminderType, window.label),
|
||||||
|
appointmentIds.length === 1
|
||||||
|
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
|
||||||
|
: sql`${reminderLogs.appointmentId} = ANY(${appointmentIds})`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).map((r) => r.appointmentId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Batch-fetch all appointment data with related joins in a single query
|
||||||
|
const joinedRows = await db
|
||||||
|
.select({
|
||||||
|
appointmentId: appointments.id,
|
||||||
|
startTime: appointments.startTime,
|
||||||
|
clientId: appointments.clientId,
|
||||||
|
petId: appointments.petId,
|
||||||
|
serviceId: appointments.serviceId,
|
||||||
|
staffId: appointments.staffId,
|
||||||
|
confirmationToken: appointments.confirmationToken,
|
||||||
|
clientName: clients.name,
|
||||||
|
clientEmail: clients.email,
|
||||||
|
clientEmailOptOut: clients.emailOptOut,
|
||||||
|
clientPhone: clients.phone,
|
||||||
|
clientSmsOptIn: clients.smsOptIn,
|
||||||
|
petName: pets.name,
|
||||||
|
serviceName: services.name,
|
||||||
|
staffName: staff.name,
|
||||||
|
})
|
||||||
|
.from(appointments)
|
||||||
|
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||||
|
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||||
|
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||||
|
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${appointments.id} = ANY(${appointmentIds})`,
|
||||||
|
gte(appointments.startTime, windowStart),
|
||||||
|
lt(appointments.startTime, windowEnd),
|
||||||
|
eq(appointments.status, "scheduled")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const appointmentMap = new Map<string, typeof joinedRows[number]>();
|
||||||
|
for (const row of joinedRows) {
|
||||||
|
appointmentMap.set(row.appointmentId, row);
|
||||||
|
}
|
||||||
|
|
||||||
for (const appt of upcoming) {
|
for (const appt of upcoming) {
|
||||||
const [emailLog] = await db
|
// Already sent a reminder for this appointment in this window
|
||||||
.select({ id: reminderLogs.id })
|
if (sentAppointmentIds.has(appt.id)) continue;
|
||||||
.from(reminderLogs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
|
||||||
eq(reminderLogs.reminderType, window.label),
|
|
||||||
eq(reminderLogs.channel, "email")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [smsLog] = await db
|
const row = appointmentMap.get(appt.id);
|
||||||
.select({ id: reminderLogs.id })
|
if (!row) continue;
|
||||||
.from(reminderLogs)
|
if (!row.petName || !row.serviceName) continue;
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
|
||||||
eq(reminderLogs.reminderType, window.label),
|
|
||||||
eq(reminderLogs.channel, "sms")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [client] = await db
|
|
||||||
.select({
|
|
||||||
name: clients.name,
|
|
||||||
email: clients.email,
|
|
||||||
emailOptOut: clients.emailOptOut,
|
|
||||||
smsOptIn: clients.smsOptIn,
|
|
||||||
phone: clients.phone,
|
|
||||||
})
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.id, appt.clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!client || !client.email || client.emailOptOut) continue;
|
|
||||||
|
|
||||||
const [pet] = await db
|
|
||||||
.select({ name: pets.name })
|
|
||||||
.from(pets)
|
|
||||||
.where(eq(pets.id, appt.petId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [service] = await db
|
|
||||||
.select({ name: services.name })
|
|
||||||
.from(services)
|
|
||||||
.where(eq(services.id, appt.serviceId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let groomerName: string | null = null;
|
|
||||||
if (appt.staffId) {
|
|
||||||
const [groomer] = await db
|
|
||||||
.select({ name: staff.name })
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.id, appt.staffId))
|
|
||||||
.limit(1);
|
|
||||||
groomerName = groomer?.name ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pet || !service) continue;
|
|
||||||
|
|
||||||
|
// Generate confirmation token if missing
|
||||||
let confirmationToken = appt.confirmationToken;
|
let confirmationToken = appt.confirmationToken;
|
||||||
if (!confirmationToken) {
|
if (!confirmationToken) {
|
||||||
confirmationToken = randomBytes(32).toString("hex");
|
confirmationToken = randomBytes(32).toString("hex");
|
||||||
@@ -131,22 +139,22 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
.where(eq(appointments.id, appt.id));
|
.where(eq(appointments.id, appt.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emailLog) {
|
const clientName = row.clientName;
|
||||||
|
const petName = row.petName;
|
||||||
|
const serviceName = row.serviceName;
|
||||||
|
const groomerName = row.staffName ?? null;
|
||||||
|
const startTime = appt.startTime;
|
||||||
|
|
||||||
|
// EMAIL reminder
|
||||||
|
if (row.clientEmail && !row.clientEmailOptOut) {
|
||||||
const sent = await sendEmail(
|
const sent = await sendEmail(
|
||||||
buildReminderEmail(
|
buildReminderEmail(
|
||||||
client.email,
|
row.clientEmail,
|
||||||
{
|
{ clientName, petName, serviceName, groomerName, startTime },
|
||||||
clientName: client.name,
|
|
||||||
petName: pet.name,
|
|
||||||
serviceName: service.name,
|
|
||||||
groomerName,
|
|
||||||
startTime: appt.startTime,
|
|
||||||
},
|
|
||||||
window.hours,
|
window.hours,
|
||||||
confirmationToken
|
confirmationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
await db
|
await db
|
||||||
.insert(reminderLogs)
|
.insert(reminderLogs)
|
||||||
@@ -155,20 +163,21 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!smsLog && client.smsOptIn && client.phone) {
|
// SMS reminder
|
||||||
|
if (row.clientPhone && row.clientSmsOptIn) {
|
||||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||||
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
||||||
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
||||||
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
||||||
const smsBody = [
|
const smsBody = [
|
||||||
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
|
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
|
||||||
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
|
`Service: ${serviceName}${groomerName ? ` with ${groomerName}` : ""}`,
|
||||||
`Confirm: ${confirmUrl}`,
|
`Confirm: ${confirmUrl}`,
|
||||||
`Cancel: ${cancelUrl}`,
|
`Cancel: ${cancelUrl}`,
|
||||||
TCPA_OPT_OUT,
|
TCPA_OPT_OUT,
|
||||||
].join(". ");
|
].join(". ");
|
||||||
try {
|
try {
|
||||||
const smsOk = await smsSend(client.phone, smsBody);
|
const smsOk = await smsSend(row.clientPhone, smsBody);
|
||||||
if (smsOk) {
|
if (smsOk) {
|
||||||
await db
|
await db
|
||||||
.insert(reminderLogs)
|
.insert(reminderLogs)
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
-- Migration: 0029_db_indexes_constraints.sql
|
|
||||||
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
|
|
||||||
|
|
||||||
-- Backfill NULL emails before setting NOT NULL
|
|
||||||
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
|
|
||||||
|
|
||||||
-- Add indexes on appointments table
|
|
||||||
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
|
|
||||||
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
|
|
||||||
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
|
|
||||||
CREATE INDEX idx_appointments_status ON appointments(status);
|
|
||||||
|
|
||||||
-- Add index on pets table
|
|
||||||
CREATE INDEX idx_pets_client_id ON pets(client_id);
|
|
||||||
|
|
||||||
-- Add index on clients table
|
|
||||||
CREATE INDEX idx_clients_email ON clients(email);
|
|
||||||
|
|
||||||
-- Set NOT NULL on clients.email (after backfill)
|
|
||||||
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
|
|
||||||
+40
-48
@@ -102,55 +102,47 @@ export const verification = pgTable("verification", {
|
|||||||
|
|
||||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const clients = pgTable(
|
export const clients = pgTable("clients", {
|
||||||
"clients",
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
{
|
name: text("name").notNull(),
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
email: text("email"),
|
||||||
name: text("name").notNull(),
|
phone: text("phone"),
|
||||||
email: text("email").notNull(),
|
address: text("address"),
|
||||||
phone: text("phone"),
|
notes: text("notes"),
|
||||||
address: text("address"),
|
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||||
notes: text("notes"),
|
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
smsConsentDate: timestamp("sms_consent_date"),
|
||||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
smsOptOutDate: timestamp("sms_opt_out_date"),
|
||||||
smsConsentDate: timestamp("sms_consent_date"),
|
smsConsentText: text("sms_consent_text"),
|
||||||
smsOptOutDate: timestamp("sms_opt_out_date"),
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
smsConsentText: text("sms_consent_text"),
|
status: clientStatusEnum("status").notNull().default("active"),
|
||||||
stripeCustomerId: text("stripe_customer_id"),
|
disabledAt: timestamp("disabled_at"),
|
||||||
status: clientStatusEnum("status").notNull().default("active"),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
disabledAt: timestamp("disabled_at"),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
});
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [index("idx_clients_email").on(t.email)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const pets = pgTable(
|
export const pets = pgTable("pets", {
|
||||||
"pets",
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
{
|
clientId: uuid("client_id")
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
.notNull()
|
||||||
clientId: uuid("client_id")
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
.notNull()
|
name: text("name").notNull(),
|
||||||
.references(() => clients.id, { onDelete: "cascade" }),
|
species: text("species").notNull(),
|
||||||
name: text("name").notNull(),
|
breed: text("breed"),
|
||||||
species: text("species").notNull(),
|
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||||
breed: text("breed"),
|
dateOfBirth: timestamp("date_of_birth"),
|
||||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
healthAlerts: text("health_alerts"),
|
||||||
dateOfBirth: timestamp("date_of_birth"),
|
groomingNotes: text("grooming_notes"),
|
||||||
healthAlerts: text("health_alerts"),
|
cutStyle: text("cut_style"),
|
||||||
groomingNotes: text("grooming_notes"),
|
shampooPreference: text("shampoo_preference"),
|
||||||
cutStyle: text("cut_style"),
|
specialCareNotes: text("special_care_notes"),
|
||||||
shampooPreference: text("shampoo_preference"),
|
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||||
specialCareNotes: text("special_care_notes"),
|
photoKey: text("photo_key"),
|
||||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||||
photoKey: text("photo_key"),
|
image: text("image"),
|
||||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
image: text("image"),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
});
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [index("idx_pets_client_id").on(t.clientId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const services = pgTable("services", {
|
export const services = pgTable("services", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|||||||
@@ -462,37 +462,6 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
|
||||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
|
||||||
for (let i = 0; i < groomerCount; i++) {
|
|
||||||
const email = groomerEmails[i]!;
|
|
||||||
const name = groomerNames[i]!;
|
|
||||||
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
|
|
||||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
|
||||||
const [existingGroomer] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.staff)
|
|
||||||
.where(eq(schema.staff.email, email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingGroomer) {
|
|
||||||
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
|
|
||||||
} else {
|
|
||||||
await db.insert(schema.staff).values({
|
|
||||||
id: staffId,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
oidcSub: email,
|
|
||||||
role: "groomer",
|
|
||||||
isSuperUser: false,
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
console.log(`✓ Created staff groomer '${name}' (${email})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||||
@@ -660,31 +629,6 @@ async function seed() {
|
|||||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
|
||||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
|
||||||
for (let i = 0; i < groomerCount; i++) {
|
|
||||||
const email = groomerEmails[i]!;
|
|
||||||
const name = groomerNames[i]!;
|
|
||||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
|
||||||
await db.insert(schema.staff)
|
|
||||||
.values({
|
|
||||||
id: staffId,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
oidcSub: email,
|
|
||||||
role: "groomer",
|
|
||||||
isSuperUser: false,
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: schema.staff.email,
|
|
||||||
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
|
|
||||||
});
|
|
||||||
console.log(`✓ Upserted groomer '${name}' (${email})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services ──
|
// ── Services ──
|
||||||
// Upsert services using name as unique key. With deterministic IDs in
|
// Upsert services using name as unique key. With deterministic IDs in
|
||||||
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
||||||
|
|||||||
Reference in New Issue
Block a user