Compare commits

..

5 Commits

8 changed files with 31 additions and 308 deletions
+1 -14
View File
@@ -27,14 +27,12 @@ const DISABLED_CLIENT = {
// ─── Queue-based mock DB ────────────────────────────────────────────────────── // ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = []; let selectRows: Record<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
let insertedValues: Record<string, unknown>[] = []; let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = []; let updatedValues: Record<string, unknown>[] = [];
let deletedId: string | null = null; let deletedId: string | null = null;
function resetMock() { function resetMock() {
selectRows = []; selectRows = [];
appointmentRows = [];
insertedValues = []; insertedValues = [];
updatedValues = []; updatedValues = [];
deletedId = null; deletedId = null;
@@ -60,19 +58,10 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } { 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 { return {
getDb: () => ({ getDb: () => ({
select: () => ({ select: () => ({
from: (table: unknown) => { from: () => makeChainable(selectRows),
const tableName = (table as { _name?: string })._name;
const rows = tableName === "appointments" ? appointmentRows : selectRows;
return makeChainable(rows);
},
}), }),
insert: () => ({ insert: () => ({
values: (vals: Record<string, unknown>) => { values: (vals: Record<string, unknown>) => {
@@ -106,10 +95,8 @@ vi.mock("@groombook/db", () => {
}), }),
}), }),
clients, clients,
appointments,
eq: vi.fn(), eq: vi.fn(),
and: vi.fn(), and: vi.fn(),
or: vi.fn(),
}; };
}); });
+8 -25
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "@groombook/db"; import { eq, getDb, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect; export type StaffRow = typeof staff.$inferSelect;
@@ -89,31 +89,14 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.select() .select()
.from(staff) .from(staff)
.where(eq(staff.oidcSub, jwt.sub)); .where(eq(staff.oidcSub, jwt.sub));
if (fallbackRow) { if (!fallbackRow) {
c.set("staff", fallbackRow); return c.json(
await next(); { error: "Forbidden: no staff record found for authenticated user" },
return; 403
);
} }
// Auto-link by email: staff record exists with matching email but no userId c.set("staff", fallbackRow);
if (jwt.email) { await next();
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
);
}; };
/** /**
+9 -169
View File
@@ -23,27 +23,6 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js"; import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import type { AppEnv } from "../middleware/rbac.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>(); export const appointmentsRouter = new Hono<AppEnv>();
const createAppointmentSchema = z.object({ const createAppointmentSchema = z.object({
@@ -233,54 +212,11 @@ appointmentsRouter.post(
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000; recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
let first: typeof appointments.$inferSelect | undefined; let first: typeof appointments.$inferSelect | undefined;
const conflictingInstances: number[] = [];
for (let i = 0; i < recurrence.count; i++) { for (let i = 0; i < recurrence.count; i++) {
const instanceStart = new Date(start.getTime() + i * intervalMs); const instanceStart = new Date(start.getTime() + i * intervalMs);
const instanceEnd = new Date( const instanceEnd = new Date(
instanceStart.getTime() + durationMs 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 const [inserted] = await tx
.insert(appointments) .insert(appointments)
.values({ .values({
@@ -291,19 +227,9 @@ appointmentsRouter.post(
seriesIndex: i, seriesIndex: i,
}) })
.returning(); .returning();
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
if (i === 0) first = inserted; 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"); if (!first) throw new Error("No appointments created");
return first; return first;
}); });
@@ -321,12 +247,9 @@ appointmentsRouter.post(
} }
// Send confirmation email (fire-and-forget — never fails the request) // Send confirmation email (fire-and-forget — never fails the request)
withRetry( sendConfirmationEmail(db, firstRow).catch((err) => {
() => sendConfirmationEmail(db, firstRow), console.error("[appointments] Failed to send confirmation email:", err);
2, });
1000,
`Failed to send confirmation email for appointment ${firstRow.id}`
);
return c.json(firstRow, 201); return c.json(firstRow, 201);
} }
@@ -455,76 +378,6 @@ appointmentsRouter.patch(
let firstUpdated: typeof appointments.$inferSelect | undefined; let firstUpdated: typeof appointments.$inferSelect | undefined;
for (const appt of affected) { 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> = { const apptUpdate: Record<string, unknown> = {
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -560,13 +413,6 @@ appointmentsRouter.patch(
if (statusCode === 404) return c.json({ error: "Not found" }, 404); if (statusCode === 404) return c.json({ error: "Not found" }, 404);
if (statusCode === 422) if (statusCode === 422)
return c.json({ error: "endTime must be after startTime" }, 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; throw err;
} }
@@ -744,12 +590,9 @@ appointmentsRouter.delete("/:id", async (c) => {
const apptDate = current.startTime.toISOString().slice(0, 10); const apptDate = current.startTime.toISOString().slice(0, 10);
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true }); const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
withRetry( notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId), console.error("[appointments] Failed to notify waitlist:", err);
2, });
1000,
`Failed to notify waitlist for appointment ${id}`
);
return c.json({ ok: true }); return c.json({ ok: true });
} }
@@ -772,12 +615,9 @@ appointmentsRouter.delete("/:id", async (c) => {
.returning(); .returning();
if (!row) return c.json({ error: "Not found" }, 404); if (!row) return c.json({ error: "Not found" }, 404);
withRetry( notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId), console.error("[appointments] Failed to notify waitlist:", err);
2, });
1000,
`Failed to notify waitlist for appointment ${id}`
);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
+1 -16
View File
@@ -135,24 +135,9 @@ clientsRouter.delete("/:id", async (c) => {
} }
const db = getDb(); 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 const [row] = await db
.delete(clients) .delete(clients)
.where(eq(clients.id, clientId)) .where(eq(clients.id, c.req.param("id")))
.returning(); .returning();
if (!row) return c.json({ error: "Not found" }, 404); if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true }); return c.json({ ok: true });
+12 -47
View File
@@ -8,7 +8,6 @@ import {
invoices, invoices,
invoiceLineItems, invoiceLineItems,
invoiceTipSplits, invoiceTipSplits,
refunds,
appointments, appointments,
services, services,
clients, clients,
@@ -126,8 +125,8 @@ const tipSplitSchema = z.object({
}) })
).min(1).refine( ).min(1).refine(
(splits) => { (splits) => {
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0); const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
return totalBps === 10000; return Math.abs(total - 100) < 0.01;
}, },
{ message: "Split percentages must sum to 100" } { message: "Split percentages must sum to 100" }
), ),
@@ -171,13 +170,12 @@ invoicesRouter.post(
} }
}); });
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id)); const splits = await db
const [lineItems, tipSplits] = await Promise.all([ .select()
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)), .from(invoiceTipSplits)
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), .where(eq(invoiceTipSplits.invoiceId, id));
]);
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201); return c.json(splits, 201);
} }
); );
@@ -302,13 +300,6 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
return c.json({ ...invoice, lineItems: [lineItem] }, 201); return c.json({ ...invoice, lineItems: [lineItem] }, 201);
}); });
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
draft: ["pending", "void"],
pending: ["draft", "paid", "void"],
paid: ["void"],
void: [],
};
// Update invoice // Update invoice
invoicesRouter.patch( invoicesRouter.patch(
"/:id", "/:id",
@@ -324,14 +315,8 @@ invoicesRouter.patch(
.where(eq(invoices.id, id)); .where(eq(invoices.id, id));
if (!current) return c.json({ error: "Not found" }, 404); if (!current) return c.json({ error: "Not found" }, 404);
if (body.status !== undefined) { if (current.status === "void") {
const allowed = ALLOWED_TRANSITIONS[current.status] ?? []; return c.json({ error: "Cannot modify a voided invoice" }, 422);
if (!allowed.includes(body.status)) {
return c.json(
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
422
);
}
} }
const update: Record<string, unknown> = { ...body, updatedAt: new Date() }; const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
@@ -369,7 +354,6 @@ import { processRefund } from "../services/payment.js";
const refundSchema = z.object({ const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(), amountCents: z.number().int().nonnegative().optional(),
idempotencyKey: z.string().max(255).optional(),
}); });
invoicesRouter.post( invoicesRouter.post(
@@ -395,28 +379,9 @@ invoicesRouter.post(
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422); return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
} }
return await db.transaction(async (tx) => { const result = await processRefund(id, body.amountCents);
if (body.idempotencyKey) { if (!result) return c.json({ error: "Refund failed" }, 500);
const [existing] = await tx
.select()
.from(refunds)
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
if (existing) {
return c.json({ refundId: existing.stripeRefundId });
}
}
const result = await processRefund(id, body.amountCents); return c.json({ refundId: result.refundId });
if (!result) return c.json({ error: "Refund failed" }, 500);
await tx.insert(refunds).values({
invoiceId: id,
stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null,
});
return c.json({ refundId: result.refundId });
});
} }
); );
-11
View File
@@ -1,11 +0,0 @@
CREATE TABLE "refunds" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT,
"stripe_refund_id" text NOT NULL,
"idempotency_key" text UNIQUE,
"amount_cents" integer,
"created_at" timestamp NOT NULL DEFAULT NOW()
);
CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id");
CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key");
@@ -190,13 +190,6 @@
"when": 1775568867192, "when": 1775568867192,
"tag": "0026_stripe_payment", "tag": "0026_stripe_payment",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1775655267192,
"tag": "0027_refunds",
"breakpoints": true
} }
] ]
} }
-19
View File
@@ -300,25 +300,6 @@ export const invoiceTipSplits = pgTable(
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)] (t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
); );
// Refund records with idempotency key support
export const refunds = pgTable(
"refunds",
{
id: uuid("id").primaryKey().defaultRandom(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "restrict" }),
stripeRefundId: text("stripe_refund_id").notNull(),
idempotencyKey: text("idempotency_key").unique(),
amountCents: integer("amount_cents"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
index("idx_refunds_invoice_id").on(t.invoiceId),
index("idx_refunds_idempotency_key").on(t.idempotencyKey),
]
);
// Tracks which reminder emails have been sent per appointment (prevents duplicates). // Tracks which reminder emails have been sent per appointment (prevents duplicates).
// reminder_type values: "confirmation", "24h", "2h" // reminder_type values: "confirmation", "24h", "2h"
export const reminderLogs = pgTable( export const reminderLogs = pgTable(