Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c89c2fd6b4 | |||
| 203b600713 | |||
| b230e015c2 | |||
| 53b2dc6067 | |||
| 1bdfa9f3d2 | |||
| 369c2ce182 | |||
| 5e24678fa5 |
@@ -7,5 +7,3 @@ apps/web/dist
|
|||||||
apps/api/dist
|
apps/api/dist
|
||||||
packages/db/dist
|
packages/db/dist
|
||||||
packages/types/dist
|
packages/types/dist
|
||||||
.turbo
|
|
||||||
screenshots/
|
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -44,8 +42,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -66,8 +62,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -107,8 +101,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -246,6 +238,7 @@ jobs:
|
|||||||
echo "Deploying images tagged $TAG to groombook-dev..."
|
echo "Deploying images tagged $TAG to groombook-dev..."
|
||||||
|
|
||||||
# Run migration with PR image
|
# 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
|
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
|
||||||
cat <<EOF | kubectl apply -n groombook-dev -f -
|
cat <<EOF | kubectl apply -n groombook-dev -f -
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
@@ -310,8 +303,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: '9.15.4'
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -418,17 +409,11 @@ jobs:
|
|||||||
|
|
||||||
git push -u origin "chore/update-image-tags-${TAG}"
|
git push -u origin "chore/update-image-tags-${TAG}"
|
||||||
|
|
||||||
# Check if PR already exists for this branch
|
# Create PR and merge immediately (no required checks on groombook/infra)
|
||||||
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
|
PR_URL=$(gh pr create \
|
||||||
if [ -n "$EXISTING_PR" ]; then
|
--repo groombook/infra \
|
||||||
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
|
--base main \
|
||||||
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
|
--head "chore/update-image-tags-${TAG}" \
|
||||||
else
|
--title "chore: deploy ${TAG} to dev" \
|
||||||
PR_URL=$(gh pr create \
|
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
|
||||||
--repo groombook/infra \
|
gh pr merge "$PR_URL" --merge
|
||||||
--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
|
|
||||||
|
|||||||
@@ -14,29 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: read
|
|
||||||
steps:
|
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
|
- name: Generate infra repo token
|
||||||
id: infra-token
|
id: infra-token
|
||||||
uses: tibdex/github-app-token@v2
|
uses: tibdex/github-app-token@v2
|
||||||
|
|||||||
+1
-5
@@ -12,7 +12,6 @@ RUN pnpm install --frozen-lockfile
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
FROM deps AS builder
|
FROM deps AS builder
|
||||||
RUN mkdir -p /home/node/.cache/node/corepack
|
|
||||||
COPY packages/ packages/
|
COPY packages/ packages/
|
||||||
COPY apps/api/ apps/api/
|
COPY apps/api/ apps/api/
|
||||||
RUN pnpm --filter @groombook/types build && \
|
RUN pnpm --filter @groombook/types build && \
|
||||||
@@ -35,9 +34,6 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
|
|||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
EXPOSE 3000
|
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"]
|
CMD ["node", "apps/api/dist/index.js"]
|
||||||
|
|
||||||
# Migrate stage — runs drizzle-kit migrate against the database
|
# Migrate stage — runs drizzle-kit migrate against the database
|
||||||
@@ -50,4 +46,4 @@ CMD ["pnpm", "db:seed"]
|
|||||||
|
|
||||||
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
# Reset stage — drops all tables, re-runs migrations, and re-seeds
|
||||||
FROM builder AS reset
|
FROM builder AS reset
|
||||||
CMD ["pnpm", "db:reset"]
|
CMD ["pnpm", "db:reset"]
|
||||||
|
|||||||
@@ -27,14 +27,12 @@ const DISABLED_CLIENT = {
|
|||||||
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
let selectRows: Record<string, unknown>[] = [];
|
let selectRows: Record<string, unknown>[] = [];
|
||||||
let appointmentRows: Record<string, unknown>[] = [];
|
|
||||||
let insertedValues: Record<string, unknown>[] = [];
|
let insertedValues: Record<string, unknown>[] = [];
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
let updatedValues: Record<string, unknown>[] = [];
|
||||||
let deletedId: string | null = null;
|
let deletedId: string | null = null;
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
selectRows = [];
|
selectRows = [];
|
||||||
appointmentRows = [];
|
|
||||||
insertedValues = [];
|
insertedValues = [];
|
||||||
updatedValues = [];
|
updatedValues = [];
|
||||||
deletedId = null;
|
deletedId = null;
|
||||||
@@ -60,19 +58,10 @@ vi.mock("@groombook/db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
||||||
);
|
);
|
||||||
|
|
||||||
const appointments = new Proxy(
|
|
||||||
{ _name: "appointments" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: () => ({
|
||||||
from: (table: unknown) => {
|
from: () => makeChainable(selectRows),
|
||||||
const tableName = (table as { _name?: string })._name;
|
|
||||||
const rows = tableName === "appointments" ? appointmentRows : selectRows;
|
|
||||||
return makeChainable(rows);
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
insert: () => ({
|
insert: () => ({
|
||||||
values: (vals: Record<string, unknown>) => {
|
values: (vals: Record<string, unknown>) => {
|
||||||
@@ -106,10 +95,8 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
clients,
|
clients,
|
||||||
appointments,
|
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
or: vi.fn(),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
appointments,
|
appointments,
|
||||||
eq: () => ({}),
|
eq: () => ({}),
|
||||||
and: (..._clauses: unknown[]) => ({}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
staff,
|
staff,
|
||||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -363,7 +362,7 @@ describe("requireRoleOrSuperUser", () => {
|
|||||||
const res = await app.request("/test");
|
const res = await app.request("/test");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/role.*not permitted/i);
|
expect(body.error).toMatch(/super user privileges required/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||||
@@ -371,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
|
|||||||
const res = await app.request("/test");
|
const res = await app.request("/test");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/role.*not permitted/i);
|
expect(body.error).toMatch(/super user privileges required/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a manager with multiple allowed roles", async () => {
|
it("allows a manager with multiple allowed roles", async () => {
|
||||||
|
|||||||
+2
-32
@@ -33,26 +33,11 @@ import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
|||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// Global middleware
|
// Global middleware
|
||||||
const TRUSTED_ORIGINS = (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
|
||||||
.split(",")
|
|
||||||
.map((o) => o.trim());
|
|
||||||
|
|
||||||
const ALLOWED_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
|
||||||
|
|
||||||
app.use("*", logger());
|
app.use("*", logger());
|
||||||
app.use(
|
app.use(
|
||||||
"/api/*",
|
"/api/*",
|
||||||
cors({
|
cors({
|
||||||
origin: (origin, ctx) => {
|
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
|
||||||
if (!origin) {
|
|
||||||
return ALLOWED_ORIGIN;
|
|
||||||
}
|
|
||||||
if (TRUSTED_ORIGINS.includes(origin)) {
|
|
||||||
return origin;
|
|
||||||
}
|
|
||||||
ctx.status(403);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -202,24 +187,9 @@ api.route("/search", searchRouter);
|
|||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
await initAuth();
|
await initAuth();
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
const server = serve({ fetch: app.fetch, port });
|
serve({ fetch: app.fetch, port });
|
||||||
|
|
||||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||||
startReminderScheduler();
|
startReminderScheduler();
|
||||||
|
|
||||||
function shutdown() {
|
|
||||||
console.log("Shutting down gracefully...");
|
|
||||||
server.close(() => {
|
|
||||||
console.log("HTTP server closed");
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
console.error("Forced shutdown after timeout");
|
|
||||||
process.exit(1);
|
|
||||||
}, 10_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on("SIGTERM", shutdown);
|
|
||||||
process.on("SIGINT", shutdown);
|
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function initAuth(): Promise<void> {
|
|||||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||||
authInstance = betterAuth({
|
authInstance = betterAuth({
|
||||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||||
secret: BETTER_AUTH_SECRET!,
|
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -177,9 +177,9 @@ export async function initAuth(): Promise<void> {
|
|||||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||||
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||||
|
|
||||||
const issuerUrlObj = new URL(providerConfig.issuerUrl);
|
// Fetch OIDC discovery document to derive canonical provider URLs.
|
||||||
const issuerHostname = issuerUrlObj.hostname;
|
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
|
||||||
|
// while keeping authorizationUrl public for browser redirects.
|
||||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||||
let oidcConfig: Record<string, string> = {};
|
let oidcConfig: Record<string, string> = {};
|
||||||
try {
|
try {
|
||||||
@@ -203,18 +203,6 @@ export async function initAuth(): Promise<void> {
|
|||||||
const tokenUrl = discovery.token_endpoint;
|
const tokenUrl = discovery.token_endpoint;
|
||||||
const userInfoUrl = discovery.userinfo_endpoint;
|
const userInfoUrl = discovery.userinfo_endpoint;
|
||||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||||
const authzUrlObj = new URL(authzUrl);
|
|
||||||
const tokenUrlObj = new URL(tokenUrl);
|
|
||||||
const userInfoUrlObj = new URL(userInfoUrl);
|
|
||||||
if (
|
|
||||||
authzUrlObj.hostname !== issuerHostname ||
|
|
||||||
tokenUrlObj.hostname !== issuerHostname ||
|
|
||||||
userInfoUrlObj.hostname !== issuerHostname
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}', '${tokenUrlObj.hostname}', or '${userInfoUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
oidcConfig = {
|
oidcConfig = {
|
||||||
authorizationUrl: authzUrl,
|
authorizationUrl: authzUrl,
|
||||||
tokenUrl: providerConfig.internalBaseUrl
|
tokenUrl: providerConfig.internalBaseUrl
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
|
||||||
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
|
||||||
import type { PortalEnv } from "./portalSession.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side audit logging middleware for portal routes.
|
|
||||||
* Applied after validatePortalSession in the middleware chain.
|
|
||||||
*
|
|
||||||
* After the route handler completes (await next()), inserts an audit log entry
|
|
||||||
* into impersonationAuditLogs:
|
|
||||||
* - sessionId: from c.get("portalSessionId")
|
|
||||||
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
|
|
||||||
* - pageVisited: c.req.path
|
|
||||||
* - metadata: { method, statusCode: c.res.status }
|
|
||||||
*
|
|
||||||
* Log entries are written for both success and error responses.
|
|
||||||
* Does NOT throw if audit logging fails — errors are logged but the user's
|
|
||||||
* request is not affected.
|
|
||||||
*/
|
|
||||||
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
|
||||||
await next();
|
|
||||||
|
|
||||||
const sessionId = c.get("portalSessionId");
|
|
||||||
if (!sessionId) return;
|
|
||||||
|
|
||||||
const method = c.req.method;
|
|
||||||
const routePath = c.req.path;
|
|
||||||
const pageVisited = c.req.path;
|
|
||||||
const statusCode = c.res.status;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
await db
|
|
||||||
.insert(impersonationAuditLogs)
|
|
||||||
.values({
|
|
||||||
sessionId,
|
|
||||||
action: `${method} ${routePath}`,
|
|
||||||
pageVisited,
|
|
||||||
metadata: { method, statusCode },
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[portalAudit] Failed to write audit log:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
|
||||||
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
|
||||||
|
|
||||||
export interface PortalEnv {
|
|
||||||
Variables: {
|
|
||||||
portalClientId: string;
|
|
||||||
portalSessionId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
|
||||||
* Must be applied to all portal routes.
|
|
||||||
*
|
|
||||||
* Reads x-session-id from request headers, queries impersonationSessions for a row where
|
|
||||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
|
||||||
* Returns 401 if session is invalid/missing/expired.
|
|
||||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
|
||||||
*/
|
|
||||||
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
||||||
if (!sessionId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
c.set("portalClientId", session.clientId);
|
|
||||||
c.set("portalSessionId", session.id);
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { and, eq, getDb, sql, staff } from "@groombook/db";
|
import { eq, getDb, staff } from "@groombook/db";
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -89,31 +89,14 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
.select()
|
.select()
|
||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.oidcSub, jwt.sub));
|
.where(eq(staff.oidcSub, jwt.sub));
|
||||||
if (fallbackRow) {
|
if (!fallbackRow) {
|
||||||
c.set("staff", fallbackRow);
|
return c.json(
|
||||||
await next();
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
return;
|
403
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Auto-link by email: staff record exists with matching email but no userId
|
c.set("staff", fallbackRow);
|
||||||
if (jwt.email) {
|
await next();
|
||||||
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
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,9 +149,9 @@ export function requireRoleOrSuperUser(
|
|||||||
}
|
}
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: hasAllowedRole
|
error: staffRow.isSuperUser
|
||||||
? "Forbidden: super user privileges required"
|
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
: "Forbidden: super user privileges required",
|
||||||
},
|
},
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,27 +23,6 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
|||||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
async function withRetry<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
maxRetries: number,
|
|
||||||
delayMs: number,
|
|
||||||
context: string
|
|
||||||
): Promise<void> {
|
|
||||||
let lastError: unknown;
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err;
|
|
||||||
if (attempt < maxRetries) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error(`[appointments] ${context}: ${lastError}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const appointmentsRouter = new Hono<AppEnv>();
|
export const appointmentsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createAppointmentSchema = z.object({
|
const createAppointmentSchema = z.object({
|
||||||
@@ -188,8 +167,9 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check batherStaffId conflicts if set
|
||||||
if (apptFields.batherStaffId) {
|
if (apptFields.batherStaffId) {
|
||||||
const bathConflicts = await tx
|
const conflicts = await tx
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
@@ -205,7 +185,7 @@ appointmentsRouter.post(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (bathConflicts.length > 0) {
|
if (conflicts.length > 0) {
|
||||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,54 +213,11 @@ appointmentsRouter.post(
|
|||||||
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
let first: typeof appointments.$inferSelect | undefined;
|
let first: typeof appointments.$inferSelect | undefined;
|
||||||
const conflictingInstances: number[] = [];
|
|
||||||
for (let i = 0; i < recurrence.count; i++) {
|
for (let i = 0; i < recurrence.count; i++) {
|
||||||
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
||||||
const instanceEnd = new Date(
|
const instanceEnd = new Date(
|
||||||
instanceStart.getTime() + durationMs
|
instanceStart.getTime() + durationMs
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apptFields.staffId) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.staffId, apptFields.staffId),
|
|
||||||
lt(appointments.startTime, instanceEnd),
|
|
||||||
gte(appointments.endTime, instanceStart),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
conflictingInstances.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apptFields.batherStaffId) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, apptFields.batherStaffId),
|
|
||||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
|
||||||
),
|
|
||||||
lt(appointments.startTime, instanceEnd),
|
|
||||||
gte(appointments.endTime, instanceStart),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
conflictingInstances.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [inserted] = await tx
|
const [inserted] = await tx
|
||||||
.insert(appointments)
|
.insert(appointments)
|
||||||
.values({
|
.values({
|
||||||
@@ -291,19 +228,9 @@ appointmentsRouter.post(
|
|||||||
seriesIndex: i,
|
seriesIndex: i,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
|
||||||
if (i === 0) first = inserted;
|
if (i === 0) first = inserted;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conflictingInstances.length > 0) {
|
|
||||||
throw Object.assign(
|
|
||||||
new Error(
|
|
||||||
`Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}`
|
|
||||||
),
|
|
||||||
{ statusCode: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!first) throw new Error("No appointments created");
|
if (!first) throw new Error("No appointments created");
|
||||||
return first;
|
return first;
|
||||||
});
|
});
|
||||||
@@ -321,12 +248,9 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send confirmation email (fire-and-forget — never fails the request)
|
// Send confirmation email (fire-and-forget — never fails the request)
|
||||||
withRetry(
|
sendConfirmationEmail(db, firstRow).catch((err) => {
|
||||||
() => sendConfirmationEmail(db, firstRow),
|
console.error("[appointments] Failed to send confirmation email:", err);
|
||||||
2,
|
});
|
||||||
1000,
|
|
||||||
`Failed to send confirmation email for appointment ${firstRow.id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json(firstRow, 201);
|
return c.json(firstRow, 201);
|
||||||
}
|
}
|
||||||
@@ -455,76 +379,6 @@ appointmentsRouter.patch(
|
|||||||
|
|
||||||
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
||||||
for (const appt of affected) {
|
for (const appt of affected) {
|
||||||
const newStart =
|
|
||||||
startDeltaMs !== 0
|
|
||||||
? new Date(appt.startTime.getTime() + startDeltaMs)
|
|
||||||
: appt.startTime;
|
|
||||||
const newEnd =
|
|
||||||
endDeltaMs !== 0
|
|
||||||
? new Date(appt.endTime.getTime() + endDeltaMs)
|
|
||||||
: appt.endTime;
|
|
||||||
const newStaffId =
|
|
||||||
updateFields.staffId !== undefined
|
|
||||||
? updateFields.staffId
|
|
||||||
: appt.staffId;
|
|
||||||
const newBatherStaffId =
|
|
||||||
updateFields.batherStaffId !== undefined
|
|
||||||
? updateFields.batherStaffId
|
|
||||||
: appt.batherStaffId;
|
|
||||||
|
|
||||||
if (
|
|
||||||
newStaffId &&
|
|
||||||
(startDeltaMs !== 0 ||
|
|
||||||
endDeltaMs !== 0 ||
|
|
||||||
updateFields.staffId !== undefined)
|
|
||||||
) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.staffId, newStaffId),
|
|
||||||
lt(appointments.startTime, newEnd),
|
|
||||||
gte(appointments.endTime, newStart),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
ne(appointments.id, appt.id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
newBatherStaffId &&
|
|
||||||
(startDeltaMs !== 0 ||
|
|
||||||
endDeltaMs !== 0 ||
|
|
||||||
updateFields.batherStaffId !== undefined)
|
|
||||||
) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, newBatherStaffId),
|
|
||||||
eq(appointments.batherStaffId, newBatherStaffId)
|
|
||||||
),
|
|
||||||
lt(appointments.startTime, newEnd),
|
|
||||||
gte(appointments.endTime, newStart),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
ne(appointments.id, appt.id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const apptUpdate: Record<string, unknown> = {
|
const apptUpdate: Record<string, unknown> = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -560,13 +414,6 @@ appointmentsRouter.patch(
|
|||||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||||
if (statusCode === 422)
|
if (statusCode === 422)
|
||||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||||
if (statusCode === 409)
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
error: "Staff member has a conflicting appointment at this time",
|
|
||||||
},
|
|
||||||
409
|
|
||||||
);
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,8 +425,7 @@ appointmentsRouter.patch(
|
|||||||
const needsConflictCheck =
|
const needsConflictCheck =
|
||||||
updateFields.startTime !== undefined ||
|
updateFields.startTime !== undefined ||
|
||||||
updateFields.endTime !== undefined ||
|
updateFields.endTime !== undefined ||
|
||||||
updateFields.staffId !== undefined ||
|
updateFields.staffId !== undefined;
|
||||||
updateFields.batherStaffId !== undefined;
|
|
||||||
|
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
...updateFields,
|
...updateFields,
|
||||||
@@ -615,11 +461,6 @@ appointmentsRouter.patch(
|
|||||||
updateFields.staffId !== undefined
|
updateFields.staffId !== undefined
|
||||||
? updateFields.staffId
|
? updateFields.staffId
|
||||||
: current.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) {
|
if (end <= start) {
|
||||||
throw Object.assign(new Error("end before start"), {
|
throw Object.assign(new Error("end before start"), {
|
||||||
@@ -647,8 +488,13 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check batherStaffId conflicts if being updated or already set
|
||||||
|
const batherStaffId =
|
||||||
|
updateFields.batherStaffId !== undefined
|
||||||
|
? updateFields.batherStaffId
|
||||||
|
: current.batherStaffId;
|
||||||
if (batherStaffId) {
|
if (batherStaffId) {
|
||||||
const bathConflicts = await tx
|
const conflicts = await tx
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(
|
.where(
|
||||||
@@ -665,7 +511,7 @@ appointmentsRouter.patch(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (bathConflicts.length > 0) {
|
if (conflicts.length > 0) {
|
||||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -744,12 +590,9 @@ appointmentsRouter.delete("/:id", async (c) => {
|
|||||||
|
|
||||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||||
withRetry(
|
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
||||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
console.error("[appointments] Failed to notify waitlist:", err);
|
||||||
2,
|
});
|
||||||
1000,
|
|
||||||
`Failed to notify waitlist for appointment ${id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -772,12 +615,9 @@ appointmentsRouter.delete("/:id", async (c) => {
|
|||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
withRetry(
|
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
||||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
console.error("[appointments] Failed to notify waitlist:", err);
|
||||||
2,
|
});
|
||||||
1000,
|
|
||||||
`Failed to notify waitlist for appointment ${id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-24
@@ -268,36 +268,29 @@ bookRouter.get("/confirm/:token", async (c) => {
|
|||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject if appointment is in the past
|
||||||
if (appt.startTime < new Date()) {
|
if (appt.startTime < new Date()) {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idempotent confirm: if already confirmed, redirect to success
|
||||||
if (appt.confirmationStatus === "confirmed") {
|
if (appt.confirmationStatus === "confirmed") {
|
||||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject if already cancelled
|
||||||
if (appt.confirmationStatus === "cancelled") {
|
if (appt.confirmationStatus === "cancelled") {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await db
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({
|
.set({
|
||||||
confirmationStatus: "confirmed",
|
confirmationStatus: "confirmed",
|
||||||
confirmedAt: new Date(),
|
confirmedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(appointments.id, appt.id));
|
||||||
and(
|
|
||||||
eq(appointments.confirmationToken, token),
|
|
||||||
eq(appointments.confirmationStatus, "pending")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (updated.length === 0) {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||||
});
|
});
|
||||||
@@ -319,15 +312,19 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject if appointment is in the past
|
||||||
if (appt.startTime < new Date()) {
|
if (appt.startTime < new Date()) {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
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") {
|
if (appt.confirmationStatus === "cancelled") {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await db
|
// Single-use cancellation: nullify token after use
|
||||||
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({
|
.set({
|
||||||
confirmationStatus: "cancelled",
|
confirmationStatus: "cancelled",
|
||||||
@@ -335,17 +332,7 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
confirmationToken: null,
|
confirmationToken: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(appointments.id, appt.id));
|
||||||
and(
|
|
||||||
eq(appointments.confirmationToken, token),
|
|
||||||
eq(appointments.confirmationStatus, "pending")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (updated.length === 0) {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
@@ -84,18 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
|
|||||||
.where(eq(staff.id, staffId))
|
.where(eq(staff.id, staffId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!staffMember || !staffMember.icalToken) {
|
if (!staffMember || staffMember.icalToken !== token) {
|
||||||
return c.text("Unauthorized", 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedToken = staffMember.icalToken;
|
|
||||||
const incomingToken = token;
|
|
||||||
const storedBuf = Buffer.from(storedToken, "utf8");
|
|
||||||
const incomingBuf = Buffer.from(incomingToken, "utf8");
|
|
||||||
if (
|
|
||||||
storedBuf.length !== incomingBuf.length ||
|
|
||||||
!timingSafeEqual(storedBuf, incomingBuf)
|
|
||||||
) {
|
|
||||||
return c.text("Unauthorized", 401);
|
return c.text("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,24 +135,9 @@ clientsRouter.delete("/:id", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.req.param("id");
|
|
||||||
|
|
||||||
const [existingAppt] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.clientId, clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingAppt) {
|
|
||||||
return c.json(
|
|
||||||
{ error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." },
|
|
||||||
409
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.delete(clients)
|
.delete(clients)
|
||||||
.where(eq(clients.id, clientId))
|
.where(eq(clients.id, c.req.param("id")))
|
||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
invoices,
|
invoices,
|
||||||
invoiceLineItems,
|
invoiceLineItems,
|
||||||
invoiceTipSplits,
|
invoiceTipSplits,
|
||||||
refunds,
|
|
||||||
appointments,
|
appointments,
|
||||||
services,
|
services,
|
||||||
clients,
|
clients,
|
||||||
@@ -126,8 +125,8 @@ const tipSplitSchema = z.object({
|
|||||||
})
|
})
|
||||||
).min(1).refine(
|
).min(1).refine(
|
||||||
(splits) => {
|
(splits) => {
|
||||||
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||||
return totalBps === 10000;
|
return Math.abs(total - 100) < 0.01;
|
||||||
},
|
},
|
||||||
{ message: "Split percentages must sum to 100" }
|
{ message: "Split percentages must sum to 100" }
|
||||||
),
|
),
|
||||||
@@ -171,13 +170,12 @@ invoicesRouter.post(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
const splits = await db
|
||||||
const [lineItems, tipSplits] = await Promise.all([
|
.select()
|
||||||
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
.from(invoiceTipSplits)
|
||||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
.where(eq(invoiceTipSplits.invoiceId, id));
|
||||||
]);
|
|
||||||
|
|
||||||
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
|
return c.json(splits, 201);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -302,13 +300,6 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
|
|||||||
return c.json({ ...invoice, lineItems: [lineItem] }, 201);
|
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
|
// Update invoice
|
||||||
invoicesRouter.patch(
|
invoicesRouter.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
@@ -324,14 +315,8 @@ invoicesRouter.patch(
|
|||||||
.where(eq(invoices.id, id));
|
.where(eq(invoices.id, id));
|
||||||
if (!current) return c.json({ error: "Not found" }, 404);
|
if (!current) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
if (body.status !== undefined) {
|
if (current.status === "void") {
|
||||||
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
|
return c.json({ error: "Cannot modify a voided invoice" }, 422);
|
||||||
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() };
|
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
||||||
@@ -369,7 +354,6 @@ import { processRefund } from "../services/payment.js";
|
|||||||
|
|
||||||
const refundSchema = z.object({
|
const refundSchema = z.object({
|
||||||
amountCents: z.number().int().nonnegative().optional(),
|
amountCents: z.number().int().nonnegative().optional(),
|
||||||
idempotencyKey: z.string().max(255).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
invoicesRouter.post(
|
invoicesRouter.post(
|
||||||
@@ -395,28 +379,9 @@ invoicesRouter.post(
|
|||||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await db.transaction(async (tx) => {
|
const result = await processRefund(id, body.amountCents);
|
||||||
if (body.idempotencyKey) {
|
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||||
const [existing] = await tx
|
|
||||||
.select()
|
|
||||||
.from(refunds)
|
|
||||||
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
|
|
||||||
if (existing) {
|
|
||||||
return c.json({ refundId: existing.stripeRefundId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await processRefund(id, body.amountCents);
|
return c.json({ refundId: result.refundId });
|
||||||
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 });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+122
-23
@@ -1,22 +1,33 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { eq, inArray } from "@groombook/db";
|
import { and, eq, inArray } from "@groombook/db";
|
||||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import { portalAudit } from "../middleware/portalAudit.js";
|
|
||||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
|
||||||
|
|
||||||
export const portalRouter = new Hono<PortalEnv>();
|
export const portalRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// Apply middleware to all portal routes
|
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
|
||||||
|
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const db = getDb();
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||||
|
.limit(1);
|
||||||
|
if (!session || session.expiresAt <= new Date()) return null;
|
||||||
|
return session.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
portalRouter.get("/me", async (c) => {
|
portalRouter.get("/me", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||||
if (!client) return c.json({ error: "Not found" }, 404);
|
if (!client) return c.json({ error: "Not found" }, 404);
|
||||||
@@ -38,7 +49,9 @@ portalRouter.get("/services", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/appointments", async (c) => {
|
portalRouter.get("/appointments", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const allAppts = await db
|
const allAppts = await db
|
||||||
@@ -88,7 +101,9 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/pets", async (c) => {
|
portalRouter.get("/pets", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||||
@@ -96,7 +111,9 @@ portalRouter.get("/pets", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/invoices", async (c) => {
|
portalRouter.get("/invoices", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||||
const invoiceIds = clientInvoices.map(i => i.id);
|
const invoiceIds = clientInvoices.map(i => i.id);
|
||||||
@@ -131,7 +148,12 @@ portalRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -174,7 +196,12 @@ portalRouter.patch(
|
|||||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -223,7 +250,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
|||||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -287,7 +319,28 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
let clientId: string | null = null;
|
||||||
|
if (sessionId) {
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (session && session.expiresAt > new Date()) {
|
||||||
|
clientId = session.clientId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [entry] = await db
|
const [entry] = await db
|
||||||
.insert(waitlistEntries)
|
.insert(waitlistEntries)
|
||||||
@@ -311,7 +364,26 @@ portalRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -320,7 +392,7 @@ portalRouter.patch(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||||
if (existing.clientId !== clientId) {
|
if (existing.clientId !== session.clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +414,26 @@ portalRouter.patch(
|
|||||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [entry] = await db
|
const [entry] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -351,7 +442,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||||
if (entry.clientId !== clientId) {
|
if (entry.clientId !== session.clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +475,9 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const invoiceRows = await db
|
const invoiceRows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -421,7 +514,9 @@ portalRouter.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
portalRouter.get("/payment-methods", async (c) => {
|
portalRouter.get("/payment-methods", async (c) => {
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const methods = await listPaymentMethods(clientId);
|
const methods = await listPaymentMethods(clientId);
|
||||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
@@ -429,7 +524,9 @@ portalRouter.get("/payment-methods", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.post("/payment-methods", async (c) => {
|
portalRouter.post("/payment-methods", async (c) => {
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||||
@@ -442,7 +539,9 @@ portalRouter.post("/payment-methods", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const paymentMethodId = c.req.param("id");
|
const paymentMethodId = c.req.param("id");
|
||||||
|
|
||||||
|
|||||||
@@ -286,10 +286,6 @@ reportsRouter.get("/clients", async (c) => {
|
|||||||
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);
|
||||||
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
|
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
|
const churnRisk = await db
|
||||||
.select({
|
.select({
|
||||||
clientId: clients.id,
|
clientId: clients.id,
|
||||||
@@ -302,34 +298,15 @@ reportsRouter.get("/clients", async (c) => {
|
|||||||
.having(
|
.having(
|
||||||
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
|
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({
|
return c.json({
|
||||||
from: from.toISOString(),
|
from: from.toISOString(),
|
||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
newClients,
|
newClients,
|
||||||
activeInPeriodCount: activeInPeriod.length,
|
activeInPeriodCount: activeInPeriod.length,
|
||||||
churnRisk,
|
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
|
||||||
churnRiskTotal,
|
churnRiskTotal: churnRisk.length,
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,6 @@ import { z } from "zod/v3";
|
|||||||
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
||||||
const RATE_LIMIT_MAX = 10;
|
|
||||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
||||||
|
|
||||||
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
|
|
||||||
const now = Date.now();
|
|
||||||
const entry = rateLimitMap.get(ip);
|
|
||||||
if (!entry || now > entry.resetAt) {
|
|
||||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
|
||||||
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
|
|
||||||
}
|
|
||||||
if (entry.count >= RATE_LIMIT_MAX) {
|
|
||||||
return { allowed: false, remaining: 0 };
|
|
||||||
}
|
|
||||||
entry.count++;
|
|
||||||
return { allowed: true, remaining: RATE_LIMIT_MAX - entry.count };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setupRouter = new Hono<AppEnv>();
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||||
@@ -203,74 +185,52 @@ const authProviderTestSchema = z.object({
|
|||||||
* After setup completes, this endpoint permanently returns 403.
|
* After setup completes, this endpoint permanently returns 403.
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider", async (c) => {
|
setupRouter.post("/auth-provider", async (c) => {
|
||||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const { allowed, remaining } = rateLimitByIp(ip);
|
|
||||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
|
||||||
if (!allowed) {
|
|
||||||
return c.json({ error: "Too many requests. Please try again later." }, 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
let row: typeof authProviderConfig.$inferSelect;
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
try {
|
const [superUser] = await db
|
||||||
row = await db.transaction(async (tx) => {
|
.select({ id: staff.id })
|
||||||
const [superUser] = await tx
|
.from(staff)
|
||||||
.select({ id: staff.id })
|
.where(eq(staff.isSuperUser, true))
|
||||||
.from(staff)
|
.limit(1);
|
||||||
.where(eq(staff.isSuperUser, true))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (superUser) {
|
if (superUser) {
|
||||||
throw Object.assign(new Error("setup-complete"), { code: 403 });
|
// Setup already completed — lock this endpoint permanently
|
||||||
}
|
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
const [existingConfig] = await tx
|
// Guard: ensure no DB config already exists (should be redundant with status check but defensive)
|
||||||
.select({ id: authProviderConfig.id })
|
const [existingConfig] = await db
|
||||||
.from(authProviderConfig)
|
.select({ id: authProviderConfig.id })
|
||||||
.where(eq(authProviderConfig.enabled, true))
|
.from(authProviderConfig)
|
||||||
.limit(1);
|
.where(eq(authProviderConfig.enabled, true))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (existingConfig) {
|
if (existingConfig) {
|
||||||
throw Object.assign(new Error("config-exists"), { code: 409 });
|
return c.json({ error: "Auth provider is already configured." }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
||||||
|
|
||||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
// Encrypt clientSecret before storing
|
||||||
|
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||||
|
|
||||||
const [configRow] = await tx
|
const [row] = await db
|
||||||
.insert(authProviderConfig)
|
.insert(authProviderConfig)
|
||||||
.values({
|
.values({
|
||||||
providerId: body.providerId,
|
providerId: body.providerId,
|
||||||
displayName: body.displayName,
|
displayName: body.displayName,
|
||||||
issuerUrl: body.issuerUrl,
|
issuerUrl: body.issuerUrl,
|
||||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||||
clientId: body.clientId,
|
clientId: body.clientId,
|
||||||
clientSecret: encryptedSecret,
|
clientSecret: encryptedSecret,
|
||||||
scopes: body.scopes,
|
scopes: body.scopes,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!configRow) {
|
if (!row) {
|
||||||
throw Object.assign(new Error("insert-failed"), { code: 500 });
|
return c.json({ error: "Failed to save auth provider configuration." }, 500);
|
||||||
}
|
|
||||||
|
|
||||||
return configRow;
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const e = err as Error & { code?: number };
|
|
||||||
if (e.message === "setup-complete") {
|
|
||||||
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, e.code as 403);
|
|
||||||
}
|
|
||||||
if (e.message === "config-exists") {
|
|
||||||
return c.json({ error: "Auth provider is already configured." }, e.code as 409);
|
|
||||||
}
|
|
||||||
if (e.message === "insert-failed") {
|
|
||||||
return c.json({ error: "Failed to save auth provider configuration." }, e.code as 500);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -294,13 +254,6 @@ setupRouter.post("/auth-provider", async (c) => {
|
|||||||
* Only available when needsSetup is true (no super user = fresh install).
|
* Only available when needsSetup is true (no super user = fresh install).
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider/test", async (c) => {
|
setupRouter.post("/auth-provider/test", async (c) => {
|
||||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const { allowed, remaining } = rateLimitByIp(ip);
|
|
||||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
|
||||||
if (!allowed) {
|
|
||||||
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Guard: only allow during fresh install (no super user yet)
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
|
|||||||
@@ -20,5 +20,3 @@ FROM nginx:alpine AS runner
|
|||||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:80/ || exit 1
|
|
||||||
|
|||||||
@@ -3,22 +3,10 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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
|
# Cache static assets
|
||||||
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
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
|
# Proxy API calls to the API service
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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,13 +190,6 @@
|
|||||||
"when": 1775568867192,
|
"when": 1775568867192,
|
||||||
"tag": "0026_stripe_payment",
|
"tag": "0026_stripe_payment",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 27,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775655267192,
|
|
||||||
"tag": "0027_refunds",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -300,25 +300,6 @@ export const invoiceTipSplits = pgTable(
|
|||||||
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
|
(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).
|
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
||||||
// reminder_type values: "confirmation", "24h", "2h"
|
// reminder_type values: "confirmation", "24h", "2h"
|
||||||
export const reminderLogs = pgTable(
|
export const reminderLogs = pgTable(
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ async function seed() {
|
|||||||
|
|
||||||
// ── Staff ──
|
// ── Staff ──
|
||||||
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
|
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: profile === "uat" && i === 0 })
|
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false })
|
||||||
);
|
);
|
||||||
const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) =>
|
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 })
|
({ 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