Compare commits

..

2 Commits

Author SHA1 Message Date
groombook-cto[bot] 6e4526d37a Merge branch 'main' into fix/gro-660-uat-seed-manager-superuser 2026-04-15 00:42:57 +00:00
Flea Flicker 85ec814b26 fix: set Manager 1 as super user in UAT seed to resolve OOBE redirect 2026-04-15 00:25:56 +00:00
6 changed files with 51 additions and 88 deletions
+1 -16
View File
@@ -187,24 +187,9 @@ api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000); const port = Number(process.env.PORT ?? 3000);
await initAuth(); await initAuth();
console.log(`API server listening on port ${port}`); console.log(`API server listening on port ${port}`);
const server = serve({ fetch: app.fetch, port }); serve({ fetch: app.fetch, port });
// Start background reminder scheduler (runs every minute to check for upcoming appointments) // Start background reminder scheduler (runs every minute to check for upcoming appointments)
startReminderScheduler(); startReminderScheduler();
function shutdown() {
console.log("Shutting down gracefully...");
server.close(() => {
console.log("HTTP server closed");
process.exit(0);
});
setTimeout(() => {
console.error("Forced shutdown after timeout");
process.exit(1);
}, 10_000);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
export default app; export default app;
-4
View File
@@ -41,10 +41,6 @@ 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(),
}); });
+1 -4
View File
@@ -102,10 +102,7 @@ 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().refine( startTime: z.string().datetime(),
(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(),
+45 -53
View File
@@ -44,61 +44,53 @@ const updateInvoiceSchema = z.object({
}); });
// List invoices // List invoices
const listInvoicesQuerySchema = z.object({ invoicesRouter.get("/", async (c) => {
clientId: z.string().uuid().optional(), const db = getDb();
appointmentId: z.string().uuid().optional(), const clientId = c.req.query("clientId");
status: z.enum(["draft", "pending", "paid", "void"]).optional(), const appointmentId = c.req.query("appointmentId");
limit: z.coerce.number().int().min(1).max(200).default(50), const status = c.req.query("status");
offset: z.coerce.number().int().min(0).default(0), 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 });
}); });
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();
+1 -1
View File
@@ -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().max(480), durationMinutes: z.number().int().positive(),
active: z.boolean().default(true), active: z.boolean().default(true),
}); });
+3 -10
View File
@@ -1,6 +1,5 @@
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";
@@ -45,13 +44,10 @@ 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, invoiceIdTrimmed)) .where(eq(invoices.id, invoiceId))
.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;
@@ -64,7 +60,7 @@ webhooksRouter.post("/stripe", async (c) => {
stripePaymentIntentId: pi.id, stripePaymentIntentId: pi.id,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(invoices.id, invoiceIdTrimmed)); .where(eq(invoices.id, invoiceId));
} }
} }
} else if (event.type === "payment_intent.payment_failed") { } else if (event.type === "payment_intent.payment_failed") {
@@ -73,16 +69,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();
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, invoiceIdTrimmed)); .where(eq(invoices.id, invoiceId));
} }
} }
} else if (event.type === "charge.refunded") { } else if (event.type === "charge.refunded") {