Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a222bd4542 | |||
| 31997e33c0 | |||
| e118607fd6 | |||
| e1e13d5091 | |||
| 80b66fe20c | |||
| 67e2157975 | |||
| 4fa4859eaf | |||
| ca88385b8d | |||
| 3f2769a43a | |||
| 0ed87f9ed8 | |||
| 648755eee5 | |||
| 77a6319459 | |||
| df07f2d6dc | |||
| dadabb0ea7 | |||
| d5a8b19322 | |||
| f4f522d5e6 | |||
| e8455195ee | |||
| ab4b9fe6fc |
@@ -7,3 +7,5 @@ apps/web/dist
|
||||
apps/api/dist
|
||||
packages/db/dist
|
||||
packages/types/dist
|
||||
.turbo
|
||||
screenshots/
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -35,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
|
||||
@@ -47,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"]
|
||||
@@ -142,8 +142,8 @@ describe("auth init", () => {
|
||||
...originalEnv,
|
||||
AUTH_DISABLED: "true",
|
||||
NODE_ENV: "test",
|
||||
BETTER_AUTH_SECRET: "placeholder-for-test-only",
|
||||
};
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
const { initAuth, getAuth } = await reimportAuth();
|
||||
await expect(initAuth()).resolves.toBeUndefined();
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -31,11 +31,11 @@ const BASE_APPT = {
|
||||
|
||||
// ─── Shared mock DB state ─────────────────────────────────────────────────────
|
||||
|
||||
let mockAppt: (typeof BASE_APPT & { confirmationToken: string }) | null = BASE_APPT as typeof BASE_APPT & { confirmationToken: string };
|
||||
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
|
||||
let lastUpdate: Record<string, unknown> = {};
|
||||
|
||||
function resetMock() {
|
||||
mockAppt = { ...BASE_APPT, confirmationToken: "valid-token-abc123" } as typeof BASE_APPT & { confirmationToken: string };
|
||||
mockAppt = { ...BASE_APPT };
|
||||
lastUpdate = {};
|
||||
}
|
||||
|
||||
@@ -55,39 +55,19 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => {
|
||||
const setVals = vals;
|
||||
return {
|
||||
where: () => {
|
||||
const preUpdate = mockAppt ? { ...mockAppt } : null;
|
||||
const preStatus = preUpdate?.confirmationStatus;
|
||||
const preStart = preUpdate?.startTime;
|
||||
lastUpdate = { ...setVals };
|
||||
const whereMatched =
|
||||
preUpdate != null &&
|
||||
preStatus === "pending" &&
|
||||
preStart != null &&
|
||||
preStart > new Date();
|
||||
if (whereMatched && mockAppt) {
|
||||
mockAppt = { ...mockAppt, ...setVals } as typeof BASE_APPT & { confirmationToken: string };
|
||||
}
|
||||
return {
|
||||
returning: () => {
|
||||
if (!preUpdate) return [];
|
||||
if (preStatus !== "pending") return [];
|
||||
if (preStart && preStart <= new Date()) return [];
|
||||
return whereMatched && mockAppt ? [mockAppt] : [];
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
lastUpdate = { ...vals };
|
||||
if (mockAppt) {
|
||||
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
|
||||
}
|
||||
return { returning: () => (mockAppt ? [mockAppt] : []) };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
appointments,
|
||||
eq: () => ({}),
|
||||
and: (a: unknown, b: unknown, c?: unknown) => (c ? [a, b, c] : [a, b]),
|
||||
gt: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -362,7 +362,7 @@ describe("requireRoleOrSuperUser", () => {
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/role 'receptionist' is not permitted/i);
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
});
|
||||
|
||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||
@@ -370,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/role 'groomer' is not permitted/i);
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
});
|
||||
|
||||
it("allows a manager with multiple allowed roles", async () => {
|
||||
|
||||
+16
-18
@@ -42,23 +42,6 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
// CSRF protection for state-changing requests
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const method = c.req.method;
|
||||
if (["GET", "HEAD", "OPTIONS"].includes(method)) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const origin = c.req.header("origin");
|
||||
const trustedOrigin = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
if (origin && origin !== trustedOrigin) {
|
||||
c.status(403);
|
||||
c.json({ error: "CSRF validation failed: origin mismatch" });
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// Health check (no auth required)
|
||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||
|
||||
@@ -204,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;
|
||||
|
||||
+11
-35
@@ -86,15 +86,10 @@ export async function initAuth(): Promise<void> {
|
||||
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
|
||||
// config so auth.handler exists (middleware bypasses it anyway)
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
if (!BETTER_AUTH_SECRET) {
|
||||
throw new Error(
|
||||
"[FATAL] BETTER_AUTH_SECRET must be set when AUTH_DISABLED=true"
|
||||
);
|
||||
}
|
||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
@@ -204,36 +199,20 @@ export async function initAuth(): Promise<void> {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
const validateIssuerHost = (url: string, issuerUrl: string): boolean => {
|
||||
try {
|
||||
const discovered = new URL(url);
|
||||
const expected = new URL(issuerUrl);
|
||||
return discovered.hostname === expected.hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const authzUrl = discovery.authorization_endpoint;
|
||||
const tokenUrl = discovery.token_endpoint;
|
||||
const userInfoUrl = discovery.userinfo_endpoint;
|
||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||
const validAuthz = validateIssuerHost(authzUrl, providerConfig.issuerUrl);
|
||||
const validToken = validateIssuerHost(tokenUrl, providerConfig.issuerUrl);
|
||||
const validUserInfo = validateIssuerHost(userInfoUrl, providerConfig.issuerUrl);
|
||||
if (!validAuthz || !validToken || !validUserInfo) {
|
||||
console.warn("[auth] OIDC discovery URL host mismatch — possible redirection attack, rejecting");
|
||||
} else {
|
||||
oidcConfig = {
|
||||
authorizationUrl: authzUrl,
|
||||
tokenUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||
: tokenUrl,
|
||||
userInfoUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||
: userInfoUrl,
|
||||
};
|
||||
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||
}
|
||||
oidcConfig = {
|
||||
authorizationUrl: authzUrl,
|
||||
tokenUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||
: tokenUrl,
|
||||
userInfoUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||
: userInfoUrl,
|
||||
};
|
||||
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||
} else {
|
||||
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
||||
}
|
||||
@@ -308,9 +287,6 @@ export async function initAuth(): Promise<void> {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
},
|
||||
cookieAttributes: {
|
||||
sameSite: "strict",
|
||||
},
|
||||
},
|
||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -149,9 +166,9 @@ export function requireRoleOrSuperUser(
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
error: hasAllowedRole
|
||||
? "Forbidden: super user privileges required"
|
||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
||||
error: staffRow.isSuperUser
|
||||
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||
: "Forbidden: super user privileges required",
|
||||
},
|
||||
403
|
||||
);
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
+53
-39
@@ -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(),
|
||||
@@ -255,37 +258,39 @@ bookRouter.get("/confirm/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
// Atomic: consume token and confirm in a single query to prevent replay.
|
||||
// Only future appointments can be confirmed.
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if appointment is in the past
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Idempotent confirm: if already confirmed, redirect to success
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
}
|
||||
|
||||
// Reject if already cancelled
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: new Date(),
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending"),
|
||||
gt(appointments.startTime, new Date())
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!appt) {
|
||||
// Check status for idempotency: already-confirmed → redirect to confirmed
|
||||
const [existing] = await db
|
||||
.select({ confirmationStatus: appointments.confirmationStatus })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
if (existing?.confirmationStatus === "confirmed") {
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
}
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
.where(eq(appointments.id, appt.id));
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
});
|
||||
@@ -297,9 +302,29 @@ bookRouter.get("/cancel/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
// Atomic: consume token and cancel in a single query to prevent replay.
|
||||
// Only future appointments can be cancelled.
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if appointment is in the past
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if already cancelled (token was nullified — this path won't normally hit,
|
||||
// but guard against edge cases where token lookup still works)
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Single-use cancellation: nullify token after use
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
@@ -307,18 +332,7 @@ bookRouter.get("/cancel/:token", async (c) => {
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending"),
|
||||
gt(appointments.startTime, new Date())
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
.where(eq(appointments.id, appt.id));
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
@@ -84,12 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
|
||||
.where(eq(staff.id, staffId))
|
||||
.limit(1);
|
||||
|
||||
if (
|
||||
!staffMember ||
|
||||
!staffMember.icalToken ||
|
||||
staffMember.icalToken.length !== token.length ||
|
||||
!timingSafeEqual(Buffer.from(staffMember.icalToken), Buffer.from(token))
|
||||
) {
|
||||
if (!staffMember || staffMember.icalToken !== token) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -44,53 +44,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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,25 +6,6 @@ import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const setupRouter = new Hono<AppEnv>();
|
||||
|
||||
// Simple in-memory rate limiter: 10 req/min per IP for setup endpoints
|
||||
const setupRateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
const SETUP_RATE_LIMIT = 10;
|
||||
const SETUP_RATE_WINDOW_MS = 60 * 1000;
|
||||
|
||||
function checkSetupRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = setupRateLimitMap.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
setupRateLimitMap.set(ip, { count: 1, resetAt: now + SETUP_RATE_WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
if (entry.count >= SETUP_RATE_LIMIT) {
|
||||
return false;
|
||||
}
|
||||
entry.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||
// and whether the auth provider bootstrap step should be shown
|
||||
setupRouter.get("/status", async (c) => {
|
||||
@@ -204,11 +185,6 @@ const authProviderTestSchema = z.object({
|
||||
* After setup completes, this endpoint permanently returns 403.
|
||||
*/
|
||||
setupRouter.post("/auth-provider", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
if (!checkSetupRateLimit(ip)) {
|
||||
return c.json({ error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
@@ -278,11 +254,6 @@ setupRouter.post("/auth-provider", async (c) => {
|
||||
* Only available when needsSetup is true (no super user = fresh install).
|
||||
*/
|
||||
setupRouter.post("/auth-provider/test", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
if (!checkSetupRateLimit(ip)) {
|
||||
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user