Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a222bd4542 | |||
| 31997e33c0 | |||
| e118607fd6 | |||
| e1e13d5091 | |||
| 80b66fe20c | |||
| ab4b9fe6fc |
@@ -27,12 +27,14 @@ 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;
|
||||||
@@ -58,10 +60,19 @@ 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: () => makeChainable(selectRows),
|
from: (table: unknown) => {
|
||||||
|
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>) => {
|
||||||
@@ -95,8 +106,10 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
clients,
|
clients,
|
||||||
|
appointments,
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
|
or: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
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 StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -89,14 +89,31 @@ 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) {
|
||||||
return c.json(
|
c.set("staff", fallbackRow);
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
await next();
|
||||||
403
|
return;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
c.set("staff", fallbackRow);
|
// Auto-link by email: staff record exists with matching email but no userId
|
||||||
await next();
|
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
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ 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({
|
||||||
@@ -41,6 +62,10 @@ const createAppointmentSchema = z.object({
|
|||||||
frequencyWeeks: z.number().int().min(1).max(52),
|
frequencyWeeks: z.number().int().min(1).max(52),
|
||||||
count: z.number().int().min(2).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(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,11 +233,54 @@ 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({
|
||||||
@@ -223,9 +291,19 @@ 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;
|
||||||
});
|
});
|
||||||
@@ -243,9 +321,12 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send confirmation email (fire-and-forget — never fails the request)
|
// Send confirmation email (fire-and-forget — never fails the request)
|
||||||
sendConfirmationEmail(db, firstRow).catch((err) => {
|
withRetry(
|
||||||
console.error("[appointments] Failed to send confirmation email:", err);
|
() => sendConfirmationEmail(db, firstRow),
|
||||||
});
|
2,
|
||||||
|
1000,
|
||||||
|
`Failed to send confirmation email for appointment ${firstRow.id}`
|
||||||
|
);
|
||||||
|
|
||||||
return c.json(firstRow, 201);
|
return c.json(firstRow, 201);
|
||||||
}
|
}
|
||||||
@@ -374,6 +455,76 @@ 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(),
|
||||||
};
|
};
|
||||||
@@ -409,6 +560,13 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,9 +744,12 @@ 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 });
|
||||||
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
withRetry(
|
||||||
console.error("[appointments] Failed to notify waitlist:", err);
|
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||||
});
|
2,
|
||||||
|
1000,
|
||||||
|
`Failed to notify waitlist for appointment ${id}`
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -611,9 +772,12 @@ 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);
|
||||||
|
|
||||||
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
withRetry(
|
||||||
console.error("[appointments] Failed to notify waitlist:", err);
|
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
||||||
});
|
2,
|
||||||
|
1000,
|
||||||
|
`Failed to notify waitlist for appointment ${id}`
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
|
|
||||||
const bookingSchema = z.object({
|
const bookingSchema = z.object({
|
||||||
serviceId: z.string().uuid(),
|
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),
|
clientName: z.string().min(1).max(200),
|
||||||
clientEmail: z.string().email(),
|
clientEmail: z.string().email(),
|
||||||
clientPhone: z.string().max(50).optional(),
|
clientPhone: z.string().max(50).optional(),
|
||||||
|
|||||||
@@ -135,9 +135,24 @@ 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, c.req.param("id")))
|
.where(eq(clients.id, clientId))
|
||||||
.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 });
|
||||||
|
|||||||
@@ -44,53 +44,61 @@ const updateInvoiceSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// List invoices
|
// List invoices
|
||||||
invoicesRouter.get("/", async (c) => {
|
const listInvoicesQuerySchema = z.object({
|
||||||
const db = getDb();
|
clientId: z.string().uuid().optional(),
|
||||||
const clientId = c.req.query("clientId");
|
appointmentId: z.string().uuid().optional(),
|
||||||
const appointmentId = c.req.query("appointmentId");
|
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
||||||
const status = c.req.query("status");
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
|
offset: z.coerce.number().int().min(0).default(0),
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
// Get single invoice with line items and tip splits
|
||||||
invoicesRouter.get("/:id", async (c) => {
|
invoicesRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
|
|||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
basePriceCents: z.number().int().positive(),
|
basePriceCents: z.number().int().positive(),
|
||||||
durationMinutes: z.number().int().positive(),
|
durationMinutes: z.number().int().positive().max(480),
|
||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
import { z } from "zod/v3";
|
||||||
import { eq, getDb, invoices } from "@groombook/db";
|
import { eq, getDb, invoices } from "@groombook/db";
|
||||||
import { getStripeClient } from "../services/payment.js";
|
import { getStripeClient } from "../services/payment.js";
|
||||||
|
|
||||||
@@ -44,10 +45,13 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
|
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||||
|
if (!parsed.success) continue;
|
||||||
|
const invoiceIdTrimmed = invoiceId.trim();
|
||||||
const [inv] = await db
|
const [inv] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.id, invoiceId))
|
.where(eq(invoices.id, invoiceIdTrimmed))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!inv) continue;
|
if (!inv) continue;
|
||||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||||
@@ -60,7 +64,7 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
stripePaymentIntentId: pi.id,
|
stripePaymentIntentId: pi.id,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceId));
|
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "payment_intent.payment_failed") {
|
} 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(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
|
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
||||||
|
if (!parsed.success) continue;
|
||||||
|
const invoiceIdTrimmed = invoiceId.trim();
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceId));
|
.where(eq(invoices.id, invoiceIdTrimmed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "charge.refunded") {
|
} else if (event.type === "charge.refunded") {
|
||||||
|
|||||||
Reference in New Issue
Block a user