Compare commits

...

27 Commits

Author SHA1 Message Date
Flea Flicker 2573d067e4 fix(api): remove CSRF middleware that breaks POST/PUT/PATCH/DELETE
The CSRF middleware requires x-csrf-token header but the frontend never
sends it, which would break all mutating operations with 403 errors.

CSRF protection should be implemented in a separate coordinated PR with
frontend changes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 06:01:22 +00:00
Flea Flicker b903d1e506 fix(api): wire up CSRF middleware for protected routes
Register csrfMiddleware in the protected API routes after authMiddleware
and resolveStaffMiddleware to protect against CSRF attacks on state-
changing operations (POST, PUT, PATCH, DELETE).

Addresses CTO review feedback on PR #278.
2026-04-15 06:01:22 +00:00
Flea Flicker 8f06f32e7d fix(invoices): wrap refund flow in transaction for idempotency safety
- Wrap idempotency check + processRefund() + db.insert() in db.transaction()
- This prevents duplicate Stripe refunds if the DB insert fails after Stripe processes the refund
- Add migration 0027_refunds for the refunds table (was missing)
- Removes out-of-scope changes from PR #278 (csrf.ts, appointmentGroups, appointments, book, groomingLogs, services, stripe-webhooks)

Fixes GRO-637 per CTO review

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 06:01:22 +00:00
Paperclip 85af080ba2 Fix invoice status transitions, tip-split validation, refund idempotency, and tip-split response format
- Add ALLOWED_TRANSITIONS state machine for invoice status changes (GRO-637)
- Replace floating-point tip-split validation with integer basis-points math
- Add idempotency key support to refund endpoint with new refunds table
- Return full invoice shape from POST /:id/tip-splits matching GET response
- All existing tests pass

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 06:01:22 +00:00
groombook-cto[bot] dc3b3ddcb7 fix(auth): add email-based staff auto-linking in resolveStaffMiddleware
Auto-link staff records by email when userId is NULL on first authenticated request.
Resolves GRO-667 UAT 403 blocker.

Co-Authored-By: Flea Flicker <noreply@anthropic.com>
2026-04-15 05:50:59 +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
groombook-cto[bot] 67e2157975 feat(GRO-631): add graceful shutdown to API server (#292)
- Capture server instance from serve() call
- Add SIGTERM and SIGINT handlers for graceful shutdown
- Add 10-second forced exit timeout

Co-authored-by: Flea Flicker <flea-flicker@groombook.ai>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 01:54:00 +00:00
groombook-ceo[bot] 4fa4859eaf fix: set Manager 1 as super user in UAT seed to resolve OOBE redirect
Co-authored-by: Flea Flicker <flea-flicker@paperclip.ing>
Co-authored-by: groombook-cto[bot] <269737991+groombook-cto[bot]@users.noreply.github.com>
2026-04-15 00:47:09 +00:00
groombook-cto[bot] ca88385b8d fix(api): add server-side pagination to churn risk query (GRO-641)
fix(api): add server-side pagination to churn risk query (GRO-641)
2026-04-15 00:32:11 +00:00
groombook-cto[bot] 3f2769a43a Merge branch 'main' into fix/gro-641-churn-pagination 2026-04-15 00:25:55 +00:00
Flea Flicker 0ed87f9ed8 fix(api): add server-side pagination to churn risk query (GRO-641)
- Add SQL-level LIMIT/OFFSET pagination to churn risk query
- Add separate COUNT(*) subquery for total without fetching all rows
- Accept page and limit query params with sensible defaults and bounds
- Return page, limit, and churnRiskTotal in response

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 00:12:01 +00:00
groombook-cto[bot] 648755eee5 fix: add corepack cache dir to Dockerfile (GRO-655)
Adds mkdir -p /home/node/.cache/node/corepack in builder stage to fix ENOENT crash in migration/seed jobs.

Root cause: c438f57 image regression — container user's home cache directory not pre-created for corepack.

Blocking: GRO-618 (UAT promotion), GRO-607 (payment UI), GRO-609

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 23:02:37 +00:00
Flea Flicker 77a6319459 fix(GRO-655): create corepack cache dir in builder stage
Prevents ENOENT crash in migrate and seed jobs.

Root cause: corepack tries to mkdir /home/node/.cache/node/corepack/v1
but the directory does not exist in the builder stage. This was a
regression in c438f57 where the cache directory was not pre-created.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 22:45:37 +00:00
groombook-cto[bot] df07f2d6dc fix(GRO-635): implement groomer data isolation in appointmentGroups, groomingLogs + batherStaffId conflict check
- appointmentGroups: Hono<AppEnv>() + groomer isolation on all 5 endpoints
- groomingLogs: Hono<AppEnv>() + groomer isolation on GET, POST, DELETE with appointmentId preserved
- appointments: batherStaffId conflict checks in POST and PATCH handlers
- Non-groomer roles retain full access

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 18:15:05 +00:00
groombook-cto[bot] dadabb0ea7 fix(GRO-631): pin pnpm version and guard against duplicate CD PRs
fix(GRO-631): pin pnpm version and guard against duplicate CD PRs
2026-04-14 17:41:07 +00:00
groombook-cto[bot] d5a8b19322 Merge branch 'main' into feature/gro-631-ci-pnpm-pin 2026-04-14 17:34:02 +00:00
groombook-cto[bot] 4d1d94296f fix(GRO-631): add tag validation to promote-prod workflow (#282)
CTO review approved. Tag format validation and GHCR image existence check are correct and well-placed.
2026-04-14 16:40:07 +00:00
groombook-cto[bot] c6800a6144 Merge branch 'main' into feature/gro-631-prod-tag-validation 2026-04-14 16:35:46 +00:00
groombook-cto[bot] 000e90a617 feat(GRO-631): add security headers to nginx.conf
feat(GRO-631): add security headers to nginx.conf
2026-04-14 16:25:57 +00:00
Flea Flicker 70e9465b68 fix(GRO-631): add tag validation to promote-prod workflow
- Validate tag format against regex YYYY.MM.DD-sha7 before proceeding
- Verify image exists in GHCR using gh api with packages: read permission
- Add packages: read permission to job permissions block

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 16:22:23 +00:00
Flea Flicker 8c3e0f9554 feat(GRO-631): add security headers to nginx.conf
Add X-Content-Type-Options, X-Frame-Options, Referrer-Policy, X-XSS-Protection,
and Permissions-Policy headers to server block and static assets location.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 16:10:04 +00:00
Flea Flicker f4f522d5e6 fix(GRO-631): pin pnpm version and guard against duplicate CD PRs
- Pin pnpm/action-setup@v4 to version 9.15.4 in all 5 jobs
- Add duplicate PR guard in CD job before gh pr create
- Remove stale kubectl delete job migrate-schema command

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 15:56:15 +00:00
Flea Flicker e8455195ee feat(GRO-631): add Docker HEALTHCHECK and update .dockerignore
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 15:47:06 +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
22 changed files with 706 additions and 104 deletions
+2
View File
@@ -7,3 +7,5 @@ apps/web/dist
apps/api/dist
packages/db/dist
packages/types/dist
.turbo
screenshots/
+24 -9
View File
@@ -20,6 +20,8 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -42,6 +44,8 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -62,6 +66,8 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -101,6 +107,8 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -238,7 +246,6 @@ jobs:
echo "Deploying images tagged $TAG to groombook-dev..."
# Run migration with PR image
kubectl delete job migrate-schema -n groombook-dev --ignore-not-found
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
cat <<EOF | kubectl apply -n groombook-dev -f -
apiVersion: batch/v1
@@ -303,6 +310,8 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -409,11 +418,17 @@ jobs:
git push -u origin "chore/update-image-tags-${TAG}"
# Create PR and merge immediately (no required checks on groombook/infra)
PR_URL=$(gh pr create \
--repo groombook/infra \
--base main \
--head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --merge
# Check if PR already exists for this branch
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
if [ -n "$EXISTING_PR" ]; then
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
else
PR_URL=$(gh pr create \
--repo groombook/infra \
--base main \
--head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --merge
fi
+22
View File
@@ -14,7 +14,29 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
steps:
- name: Validate tag format
run: |
TAG="${{ inputs.tag }}"
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
exit 1
fi
echo "Tag format valid: $TAG"
- name: Verify image exists in GHCR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ inputs.tag }}"
# Check that the API image exists — if API was pushed, web/migrate were too
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
exit 1
fi
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
- name: Generate infra repo token
id: infra-token
uses: tibdex/github-app-token@v2
+5 -1
View File
@@ -12,6 +12,7 @@ RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
RUN mkdir -p /home/node/.cache/node/corepack
COPY packages/ packages/
COPY apps/api/ apps/api/
RUN pnpm --filter @groombook/types build && \
@@ -34,6 +35,9 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
RUN pnpm install --frozen-lockfile --prod
EXPOSE 3000
RUN apk add --no-cache curl
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "apps/api/dist/index.js"]
# Migrate stage — runs drizzle-kit migrate against the database
@@ -46,4 +50,4 @@ CMD ["pnpm", "db:seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset
CMD ["pnpm", "db:reset"]
CMD ["pnpm", "db:reset"]
+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(),
};
});
+16 -1
View File
@@ -187,9 +187,24 @@ api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
console.log(`API server listening on port ${port}`);
serve({ fetch: app.fetch, port });
const server = serve({ fetch: app.fetch, port });
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
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;
+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
);
};
/**
+71 -1
View File
@@ -16,8 +16,9 @@ import {
services,
staff,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono();
export const appointmentGroupsRouter = new Hono<AppEnv>();
// ─── Schemas ──────────────────────────────────────────────────────────────────
@@ -49,6 +50,8 @@ appointmentGroupsRouter.get("/", async (c) => {
const clientId = c.req.query("clientId");
const from = c.req.query("from");
const to = c.req.query("to");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const groupConditions = clientId
? [eq(appointmentGroups.clientId, clientId)]
@@ -88,6 +91,16 @@ appointmentGroupsRouter.get("/", async (c) => {
}))
.filter((g) => !from || g.appointments.length > 0);
if (isGroomer) {
return c.json(
result.filter((g) =>
g.appointments.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
)
);
}
return c.json(result);
});
@@ -96,6 +109,8 @@ appointmentGroupsRouter.get("/", async (c) => {
appointmentGroupsRouter.get("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select()
@@ -111,6 +126,7 @@ appointmentGroupsRouter.get("/:id", async (c) => {
serviceId: appointments.serviceId,
serviceName: services.name,
staffId: appointments.staffId,
batherStaffId: appointments.batherStaffId,
staffName: staff.name,
status: appointments.status,
startTime: appointments.startTime,
@@ -125,6 +141,15 @@ appointmentGroupsRouter.get("/:id", async (c) => {
.where(eq(appointments.groupId, id))
.orderBy(appointments.startTime);
if (
isGroomer &&
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
const [client] = await db
.select({ name: clients.name, email: clients.email })
.from(clients)
@@ -140,6 +165,13 @@ appointmentGroupsRouter.post(
zValidator("json", createGroupSchema),
async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (staffRow?.role === "groomer") {
return c.json(
{ error: "Forbidden: groomers cannot create group bookings" },
403
);
}
const body = c.req.valid("json");
const startTime = new Date(body.startTime);
@@ -244,6 +276,28 @@ appointmentGroupsRouter.patch(
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select({ id: appointmentGroups.id })
.from(appointmentGroups)
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
if (
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
}
const [updated] = await db
.update(appointmentGroups)
@@ -261,6 +315,8 @@ appointmentGroupsRouter.patch(
appointmentGroupsRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select({ id: appointmentGroups.id })
@@ -268,6 +324,20 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
if (
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
}
await db
.update(appointments)
.set({ status: "cancelled", updatedAt: new Date() })
+225 -10
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(),
});
@@ -163,6 +188,28 @@ appointmentsRouter.post(
}
}
if (apptFields.batherStaffId) {
const bathConflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (bathConflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
if (!recurrence) {
// Single appointment
const [inserted] = await tx
@@ -186,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({
@@ -201,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;
});
@@ -221,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);
}
@@ -352,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(),
};
@@ -387,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;
}
@@ -398,7 +578,8 @@ appointmentsRouter.patch(
const needsConflictCheck =
updateFields.startTime !== undefined ||
updateFields.endTime !== undefined ||
updateFields.staffId !== undefined;
updateFields.staffId !== undefined ||
updateFields.batherStaffId !== undefined;
const update: Record<string, unknown> = {
...updateFields,
@@ -434,6 +615,11 @@ appointmentsRouter.patch(
updateFields.staffId !== undefined
? updateFields.staffId
: current.staffId;
// Use provided batherStaffId (may be null to unassign); fall back to existing
const batherStaffId =
updateFields.batherStaffId !== undefined
? updateFields.batherStaffId
: current.batherStaffId;
if (end <= start) {
throw Object.assign(new Error("end before start"), {
@@ -461,6 +647,29 @@ appointmentsRouter.patch(
}
}
if (batherStaffId) {
const bathConflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, batherStaffId),
eq(appointments.batherStaffId, batherStaffId)
),
lt(appointments.startTime, end),
gte(appointments.endTime, start),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id),
)
)
.limit(1);
if (bathConflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
const [updated] = await tx
.update(appointments)
.set(update)
@@ -535,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 });
}
@@ -560,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 });
+93 -6
View File
@@ -1,9 +1,10 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const groomingLogsRouter = new Hono();
export const groomingLogsRouter = new Hono<AppEnv>();
const createLogSchema = z.object({
petId: z.string().uuid(),
@@ -20,6 +21,26 @@ groomingLogsRouter.get("/", async (c) => {
const db = getDb();
const petId = c.req.query("petId");
if (!petId) return c.json({ error: "petId is required" }, 400);
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
const rows = await db
.select()
.from(groomingVisitLogs)
@@ -33,11 +54,50 @@ groomingLogsRouter.post(
zValidator("json", createLogSchema),
async (c) => {
const db = getDb();
const { groomedAt, ...rest } = c.req.valid("json");
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
if (appointmentId) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.id, appointmentId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
} else {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
}
const [row] = await db
.insert(groomingVisitLogs)
.values({
...rest,
petId,
appointmentId: appointmentId ?? null,
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
})
.returning();
@@ -47,10 +107,37 @@ groomingLogsRouter.post(
groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb();
const [row] = await db
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [log] = await db
.select()
.from(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id))
.limit(1);
if (!log) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, log.petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
await db
.delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, c.req.param("id")))
.where(eq(groomingVisitLogs.id, id))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
+100 -57
View File
@@ -8,6 +8,7 @@ import {
invoices,
invoiceLineItems,
invoiceTipSplits,
refunds,
appointments,
services,
clients,
@@ -44,53 +45,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();
@@ -117,8 +126,8 @@ const tipSplitSchema = z.object({
})
).min(1).refine(
(splits) => {
const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
return Math.abs(total - 100) < 0.01;
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
return totalBps === 10000;
},
{ message: "Split percentages must sum to 100" }
),
@@ -162,12 +171,13 @@ invoicesRouter.post(
}
});
const splits = await db
.select()
.from(invoiceTipSplits)
.where(eq(invoiceTipSplits.invoiceId, id));
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
const [lineItems, tipSplits] = await Promise.all([
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]);
return c.json(splits, 201);
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
}
);
@@ -292,6 +302,13 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
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
invoicesRouter.patch(
"/:id",
@@ -307,8 +324,14 @@ invoicesRouter.patch(
.where(eq(invoices.id, id));
if (!current) return c.json({ error: "Not found" }, 404);
if (current.status === "void") {
return c.json({ error: "Cannot modify a voided invoice" }, 422);
if (body.status !== undefined) {
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
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() };
@@ -346,6 +369,7 @@ import { processRefund } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(),
idempotencyKey: z.string().max(255).optional(),
});
invoicesRouter.post(
@@ -371,9 +395,28 @@ invoicesRouter.post(
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
}
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
return await db.transaction(async (tx) => {
if (body.idempotencyKey) {
const [existing] = await tx
.select()
.from(refunds)
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
if (existing) {
return c.json({ refundId: existing.stripeRefundId });
}
}
return c.json({ refundId: result.refundId });
const result = await processRefund(id, body.amountCents);
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 });
});
}
);
+26 -3
View File
@@ -286,6 +286,10 @@ reportsRouter.get("/clients", async (c) => {
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
const offset = (page - 1) * limit;
const churnRisk = await db
.select({
clientId: clients.id,
@@ -298,15 +302,34 @@ reportsRouter.get("/clients", async (c) => {
.having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
)
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`)
.limit(limit)
.offset(offset);
const [churnCountRow] = await db
.select({ total: sql<number>`count(*)::int` })
.from(
db
.select({ id: clients.id })
.from(clients)
.leftJoin(appointments, eq(appointments.clientId, clients.id))
.groupBy(clients.id)
.having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
)
.as("churn_count")
);
const churnRiskTotal = churnCountRow?.total ?? 0;
return c.json({
from: from.toISOString(),
to: to.toISOString(),
newClients,
activeInPeriodCount: activeInPeriod.length,
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
churnRiskTotal: churnRisk.length,
churnRisk,
churnRiskTotal,
page,
limit,
});
});
+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") {
+2
View File
@@ -20,3 +20,5 @@ FROM nginx:alpine AS runner
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
+12
View File
@@ -3,10 +3,22 @@ server {
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Cache static assets
location ~* \.(js|css|png|svg|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}
# Proxy API calls to the API service
+11
View File
@@ -0,0 +1,11 @@
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,6 +190,13 @@
"when": 1775568867192,
"tag": "0026_stripe_payment",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1775655267192,
"tag": "0027_refunds",
"breakpoints": true
}
]
}
+19
View File
@@ -300,6 +300,25 @@ export const invoiceTipSplits = pgTable(
(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).
// reminder_type values: "confirmation", "24h", "2h"
export const reminderLogs = pgTable(
+1 -1
View File
@@ -567,7 +567,7 @@ async function seed() {
// ── Staff ──
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: profile === "uat" && i === 0 })
);
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false })