- Prisma: Booking.totalPriceCents (parsed from the order webhook's total_price string), populated in booking.server.ts so "revenue by method" is real data, not a placeholder. - app/services/dashboard.server.ts: pure aggregation over injected booking data (CLAUDE.md — no DB calls in the math) — bucketByLocalDate (each booking grouped under its own location's local calendar day, not a shared UTC day), revenueByMethod (confirmed/fulfilled only), utilizationByDateLocation (booked vs. summed SlotTemplate capacity for that weekday, capped at 100%), upcomingFulfillments, and a CSV writer/formatter with proper quote-escaping. - /app/dashboard: filterable (date range, location, method, status) view with revenue-by-method cards, a capacity-utilization list (color-coded by load), an upcoming-fulfillments table with one-click confirmed->fulfilled/no_show status transitions, and a full by-day booking list. - /app/dashboard/export: a resource route (loader only, no component) streaming the same filtered bookings as a downloadable CSV — kept separate from the dashboard route specifically so it can import dashboard.server.ts freely without the client-bundling constraint the main route has to respect (see below). Fixed the same class of server/client bundling bug from the Phase 5 commit before it could ship, this time by construction: dashboard.server.ts's aggregation functions are called only inside app.dashboard.tsx's `loader`, never referenced by the default-exported component (which only reads useLoaderData() output) — verified this holds by actually running `npm run build`, not just tsc/vitest, which both stay silent about this class of error. Also hit (and fixed) the same "loader Dates arrive as strings on the client" issue from Phase 1: swapped DateTime.fromJSDate for DateTime.fromISO in the two places the component formats a booking's slotStart. Verified: lint, typecheck, 102 unit tests (+16 new for dashboard.server.ts, +2 new for totalPriceCents parsing), 18 integration tests, both builds, and a live script exercising the full aggregation pipeline (revenue exclusion of cancelled bookings, utilization math, CSV output) against the Postgres container. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
132 lines
4.9 KiB
TypeScript
132 lines
4.9 KiB
TypeScript
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
|
import db from "../../app/db.server";
|
|
import redis from "../../app/lib/redis.server";
|
|
import { createBookingFromOrder } from "../../app/services/booking.server";
|
|
import { countActiveHolds, tryCreateHold } from "../../app/services/holds.server";
|
|
|
|
const shopDomain = "booking-integration-test.myshopify.com";
|
|
|
|
async function cleanup() {
|
|
await db.booking.deleteMany({ where: { shopDomain } });
|
|
await db.location.deleteMany({ where: { shopDomain } });
|
|
await db.shop.deleteMany({ where: { shopDomain } });
|
|
}
|
|
|
|
describe("createBookingFromOrder", () => {
|
|
beforeEach(cleanup);
|
|
afterAll(async () => {
|
|
await cleanup();
|
|
await db.$disconnect();
|
|
await redis.quit();
|
|
});
|
|
|
|
it("creates a Booking from an order's dd_* attributes and releases the matching hold", async () => {
|
|
const location = await db.location.create({
|
|
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
|
|
});
|
|
|
|
const slot = {
|
|
shopDomain,
|
|
locationId: location.id,
|
|
method: "PICKUP" as const,
|
|
slotStartIso: "2026-08-25T13:00:00.000Z",
|
|
};
|
|
await tryCreateHold(slot, "cart_abc123", 5);
|
|
expect(await countActiveHolds(slot)).toBe(1);
|
|
|
|
await createBookingFromOrder(shopDomain, {
|
|
admin_graphql_api_id: "gid://shopify/Order/1",
|
|
name: "#1001",
|
|
cart_token: "cart_abc123",
|
|
email: "shopper@example.com",
|
|
note_attributes: [
|
|
{ name: "dd_method", value: "PICKUP" },
|
|
{ name: "dd_date", value: "2026-08-25" },
|
|
{ name: "dd_start_min", value: "540" },
|
|
{ name: "dd_end_min", value: "600" },
|
|
{ name: "dd_location_id", value: location.id },
|
|
],
|
|
});
|
|
|
|
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/1" } });
|
|
expect(booking).not.toBeNull();
|
|
expect(booking?.method).toBe("PICKUP");
|
|
expect(booking?.status).toBe("confirmed");
|
|
expect(booking?.customerEmail).toBe("shopper@example.com");
|
|
expect(booking?.slotStart.toISOString()).toBe("2026-08-25T13:00:00.000Z"); // 9 AM EDT
|
|
|
|
// The hold this order consumed should now be released.
|
|
expect(await countActiveHolds(slot)).toBe(0);
|
|
});
|
|
|
|
it("is idempotent — a redelivered webhook does not create a second Booking", async () => {
|
|
const location = await db.location.create({
|
|
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
|
|
});
|
|
|
|
const order = {
|
|
admin_graphql_api_id: "gid://shopify/Order/2",
|
|
note_attributes: [
|
|
{ name: "dd_method", value: "PICKUP" },
|
|
{ name: "dd_date", value: "2026-08-25" },
|
|
{ name: "dd_start_min", value: "540" },
|
|
{ name: "dd_end_min", value: "600" },
|
|
{ name: "dd_location_id", value: location.id },
|
|
],
|
|
};
|
|
|
|
await createBookingFromOrder(shopDomain, order);
|
|
await createBookingFromOrder(shopDomain, order); // redelivery
|
|
|
|
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/2" } });
|
|
expect(count).toBe(1);
|
|
});
|
|
|
|
it("does nothing for an order with no scheduling attributes", async () => {
|
|
await createBookingFromOrder(shopDomain, { admin_graphql_api_id: "gid://shopify/Order/3", note_attributes: [] });
|
|
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } });
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
it("converts total_price to integer cents for the dashboard's revenue view", async () => {
|
|
const location = await db.location.create({
|
|
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
|
|
});
|
|
|
|
await createBookingFromOrder(shopDomain, {
|
|
admin_graphql_api_id: "gid://shopify/Order/4",
|
|
total_price: "49.99",
|
|
note_attributes: [
|
|
{ name: "dd_method", value: "PICKUP" },
|
|
{ name: "dd_date", value: "2026-08-25" },
|
|
{ name: "dd_start_min", value: "540" },
|
|
{ name: "dd_end_min", value: "600" },
|
|
{ name: "dd_location_id", value: location.id },
|
|
],
|
|
});
|
|
|
|
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/4" } });
|
|
expect(booking?.totalPriceCents).toBe(4999);
|
|
});
|
|
|
|
it("leaves totalPriceCents unset when total_price is missing", async () => {
|
|
const location = await db.location.create({
|
|
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
|
|
});
|
|
|
|
await createBookingFromOrder(shopDomain, {
|
|
admin_graphql_api_id: "gid://shopify/Order/5",
|
|
note_attributes: [
|
|
{ name: "dd_method", value: "PICKUP" },
|
|
{ name: "dd_date", value: "2026-08-25" },
|
|
{ name: "dd_start_min", value: "540" },
|
|
{ name: "dd_end_min", value: "600" },
|
|
{ name: "dd_location_id", value: location.id },
|
|
],
|
|
});
|
|
|
|
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/5" } });
|
|
expect(booking?.totalPriceCents).toBeNull();
|
|
});
|
|
});
|