Compare commits

...

6 Commits

Author SHA1 Message Date
Flea Flicker a222bd4542 fix(auth): add email-based staff auto-linking in resolveStaffMiddleware
Add email-based auto-linking fallback so staff records without a userId are
automatically linked on first authenticated request. This fixes a UAT blocker
where all authenticated API routes returned HTTP 403 after login because
Better-Auth user IDs don't match seed-created staff records.

Fallback chain:
1. userId match (existing fast path)
2. oidcSub match (legacy records)
3. email match + auto-link (new)
2026-04-15 05:39:34 +00:00
groombook-cto[bot] 31997e33c0 fix(GRO-638): appointment scheduling correctness and client deletion integrity
fix(GRO-638): appointment scheduling correctness and client deletion integrity
2026-04-15 05:03:11 +00:00
groombook-cto[bot] e118607fd6 Merge branch 'main' into fix/gro-627-scheduling-correctness 2026-04-15 05:00:12 +00:00
groombook-cto[bot] e1e13d5091 fix(GRO-636): input validation fixes for 5 API routes
Adds Zod validation across 5 API routes:

1. invoices GET / — query param validation (uuid, enum, int bounds)
2. book POST / — future-time refinement on startTime
3. appointments — recurrence series capped at 1 year
4. services — durationMinutes capped at 480 (8 hours)
5. stripe-webhooks — UUID validation on invoice IDs before DB lookup

Closes GRO-636

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 04:26:20 +00:00
groombook-ceo[bot] 80b66fe20c fix(GRO-655): create corepack cache dir in builder stage
Co-authored-by: groombook-cto[bot] <269737991+groombook-cto[bot]@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 02:08:54 +00:00
Paperclip ab4b9fe6fc fix(GRO-638): appointment scheduling correctness and client deletion integrity
- Recurrence conflict checking: check ALL occurrences in recurrence loop
- Cascade update transaction safety: add conflict checking for shifted appointments
- Client deletion integrity: check for existing appointments before delete
- Email notification error handling: add retry wrapper (max 2 retries, 1s delay)
- Null guards on recurrence result: validate inserted after each insert

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 14:31:52 +00:00
8 changed files with 296 additions and 69 deletions
+14 -1
View File
@@ -27,12 +27,14 @@ const DISABLED_CLIENT = {
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let deletedId: string | null = null;
function resetMock() {
selectRows = [];
appointmentRows = [];
insertedValues = [];
updatedValues = [];
deletedId = null;
@@ -58,10 +60,19 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: () => makeChainable(selectRows),
from: (table: unknown) => {
const tableName = (table as { _name?: string })._name;
const rows = tableName === "appointments" ? appointmentRows : selectRows;
return makeChainable(rows);
},
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
@@ -95,8 +106,10 @@ vi.mock("@groombook/db", () => {
}),
}),
clients,
appointments,
eq: vi.fn(),
and: vi.fn(),
or: vi.fn(),
};
});
+25 -8
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { eq, getDb, staff } from "@groombook/db";
import { and, eq, getDb, sql, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -89,14 +89,31 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.select()
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
if (!fallbackRow) {
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
if (fallbackRow) {
c.set("staff", fallbackRow);
await next();
return;
}
c.set("staff", fallbackRow);
await next();
// Auto-link by email: staff record exists with matching email but no userId
if (jwt.email) {
const [byEmail] = await db
.select()
.from(staff)
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
if (byEmail) {
await db
.update(staff)
.set({ userId: jwt.sub, updatedAt: new Date() })
.where(eq(staff.id, byEmail.id));
c.set("staff", { ...byEmail, userId: jwt.sub });
await next();
return;
}
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
};
/**
+173 -9
View File
@@ -23,6 +23,27 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import type { AppEnv } from "../middleware/rbac.js";
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries: number,
delayMs: number,
context: string
): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await fn();
return;
} catch (err) {
lastError = err;
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
console.error(`[appointments] ${context}: ${lastError}`);
}
export const appointmentsRouter = new Hono<AppEnv>();
const createAppointmentSchema = z.object({
@@ -41,6 +62,10 @@ const createAppointmentSchema = z.object({
frequencyWeeks: z.number().int().min(1).max(52),
count: z.number().int().min(2).max(52),
})
.refine(
(r) => r.frequencyWeeks * r.count <= 52,
{ message: "Recurrence series must not exceed 1 year" }
)
.optional(),
});
@@ -208,11 +233,54 @@ appointmentsRouter.post(
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
let first: typeof appointments.$inferSelect | undefined;
const conflictingInstances: number[] = [];
for (let i = 0; i < recurrence.count; i++) {
const instanceStart = new Date(start.getTime() + i * intervalMs);
const instanceEnd = new Date(
instanceStart.getTime() + durationMs
);
if (apptFields.staffId) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, apptFields.staffId),
lt(appointments.startTime, instanceEnd),
gte(appointments.endTime, instanceStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (conflicts.length > 0) {
conflictingInstances.push(i);
}
}
if (apptFields.batherStaffId) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, instanceEnd),
gte(appointments.endTime, instanceStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (conflicts.length > 0) {
conflictingInstances.push(i);
}
}
const [inserted] = await tx
.insert(appointments)
.values({
@@ -223,9 +291,19 @@ appointmentsRouter.post(
seriesIndex: i,
})
.returning();
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
if (i === 0) first = inserted;
}
if (conflictingInstances.length > 0) {
throw Object.assign(
new Error(
`Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}`
),
{ statusCode: 409 }
);
}
if (!first) throw new Error("No appointments created");
return first;
});
@@ -243,9 +321,12 @@ appointmentsRouter.post(
}
// Send confirmation email (fire-and-forget — never fails the request)
sendConfirmationEmail(db, firstRow).catch((err) => {
console.error("[appointments] Failed to send confirmation email:", err);
});
withRetry(
() => sendConfirmationEmail(db, firstRow),
2,
1000,
`Failed to send confirmation email for appointment ${firstRow.id}`
);
return c.json(firstRow, 201);
}
@@ -374,6 +455,76 @@ appointmentsRouter.patch(
let firstUpdated: typeof appointments.$inferSelect | undefined;
for (const appt of affected) {
const newStart =
startDeltaMs !== 0
? new Date(appt.startTime.getTime() + startDeltaMs)
: appt.startTime;
const newEnd =
endDeltaMs !== 0
? new Date(appt.endTime.getTime() + endDeltaMs)
: appt.endTime;
const newStaffId =
updateFields.staffId !== undefined
? updateFields.staffId
: appt.staffId;
const newBatherStaffId =
updateFields.batherStaffId !== undefined
? updateFields.batherStaffId
: appt.batherStaffId;
if (
newStaffId &&
(startDeltaMs !== 0 ||
endDeltaMs !== 0 ||
updateFields.staffId !== undefined)
) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, newStaffId),
lt(appointments.startTime, newEnd),
gte(appointments.endTime, newStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, appt.id),
)
)
.limit(1);
if (conflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
if (
newBatherStaffId &&
(startDeltaMs !== 0 ||
endDeltaMs !== 0 ||
updateFields.batherStaffId !== undefined)
) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, newBatherStaffId),
eq(appointments.batherStaffId, newBatherStaffId)
),
lt(appointments.startTime, newEnd),
gte(appointments.endTime, newStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, appt.id),
)
)
.limit(1);
if (conflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
const apptUpdate: Record<string, unknown> = {
updatedAt: new Date(),
};
@@ -409,6 +560,13 @@ appointmentsRouter.patch(
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
if (statusCode === 422)
return c.json({ error: "endTime must be after startTime" }, 422);
if (statusCode === 409)
return c.json(
{
error: "Staff member has a conflicting appointment at this time",
},
409
);
throw err;
}
@@ -586,9 +744,12 @@ appointmentsRouter.delete("/:id", async (c) => {
const apptDate = current.startTime.toISOString().slice(0, 10);
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
console.error("[appointments] Failed to notify waitlist:", err);
});
withRetry(
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
2,
1000,
`Failed to notify waitlist for appointment ${id}`
);
return c.json({ ok: true });
}
@@ -611,9 +772,12 @@ appointmentsRouter.delete("/:id", async (c) => {
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
console.error("[appointments] Failed to notify waitlist:", err);
});
withRetry(
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
2,
1000,
`Failed to notify waitlist for appointment ${id}`
);
return c.json({ ok: true });
});
+4 -1
View File
@@ -102,7 +102,10 @@ bookRouter.get("/availability", async (c) => {
const bookingSchema = z.object({
serviceId: z.string().uuid(),
startTime: z.string().datetime(),
startTime: z.string().datetime().refine(
(dt) => new Date(dt) > new Date(),
{ message: "Appointment must be in the future" }
),
clientName: z.string().min(1).max(200),
clientEmail: z.string().email(),
clientPhone: z.string().max(50).optional(),
+16 -1
View File
@@ -135,9 +135,24 @@ clientsRouter.delete("/:id", async (c) => {
}
const db = getDb();
const clientId = c.req.param("id");
const [existingAppt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(eq(appointments.clientId, clientId))
.limit(1);
if (existingAppt) {
return c.json(
{ error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." },
409
);
}
const [row] = await db
.delete(clients)
.where(eq(clients.id, c.req.param("id")))
.where(eq(clients.id, clientId))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
+53 -45
View File
@@ -44,53 +44,61 @@ const updateInvoiceSchema = z.object({
});
// List invoices
invoicesRouter.get("/", async (c) => {
const db = getDb();
const clientId = c.req.query("clientId");
const appointmentId = c.req.query("appointmentId");
const status = c.req.query("status");
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
const offset = parseInt(c.req.query("offset") || "0", 10);
const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
const listInvoicesQuerySchema = z.object({
clientId: z.string().uuid().optional(),
appointmentId: z.string().uuid().optional(),
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
invoicesRouter.get(
"/",
zValidator("query", listInvoicesQuerySchema),
async (c) => {
const db = getDb();
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
}
);
// Get single invoice with line items and tip splits
invoicesRouter.get("/:id", async (c) => {
const db = getDb();
+1 -1
View File
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
basePriceCents: z.number().int().positive(),
durationMinutes: z.number().int().positive(),
durationMinutes: z.number().int().positive().max(480),
active: z.boolean().default(true),
});
+10 -3
View File
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import Stripe from "stripe";
import { z } from "zod/v3";
import { eq, getDb, invoices } from "@groombook/db";
import { getStripeClient } from "../services/payment.js";
@@ -44,10 +45,13 @@ webhooksRouter.post("/stripe", async (c) => {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) {
if (!invoiceId) continue;
const parsed = z.string().uuid().safeParse(invoiceId.trim());
if (!parsed.success) continue;
const invoiceIdTrimmed = invoiceId.trim();
const [inv] = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.where(eq(invoices.id, invoiceIdTrimmed))
.limit(1);
if (!inv) continue;
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
@@ -60,7 +64,7 @@ webhooksRouter.post("/stripe", async (c) => {
stripePaymentIntentId: pi.id,
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceId));
.where(eq(invoices.id, invoiceIdTrimmed));
}
}
} else if (event.type === "payment_intent.payment_failed") {
@@ -69,13 +73,16 @@ webhooksRouter.post("/stripe", async (c) => {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) {
if (!invoiceId) continue;
const parsed = z.string().uuid().safeParse(invoiceId.trim());
if (!parsed.success) continue;
const invoiceIdTrimmed = invoiceId.trim();
await db
.update(invoices)
.set({
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceId));
.where(eq(invoices.id, invoiceIdTrimmed));
}
}
} else if (event.type === "charge.refunded") {