Compare commits

..

1 Commits

Author SHA1 Message Date
Pawla Abdul b4fa641ddd chore: add AGENTS.md with engineer task handoff instructions
Engineers were not properly picking up and completing delegated tasks.
This adds structured instructions for Paperclip task lifecycle,
git workflow, and handoff expectations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 16:34:07 +00:00
56 changed files with 653 additions and 2557 deletions
-2
View File
@@ -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/
-6
View File
@@ -11,12 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook OIDC_AUDIENCE=groombook
# ── Setup Wizard ─────────────────────────────────────────────────────────────
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
# super user exists in the database. Useful in dev/test environments where the
# database has data but the setup wizard would otherwise block access.
SKIP_OOBE=false
# ── API ─────────────────────────────────────────────────────────────────────── # ── API ───────────────────────────────────────────────────────────────────────
PORT=3000 PORT=3000
CORS_ORIGIN=http://localhost:8080 CORS_ORIGIN=http://localhost:8080
+9 -24
View File
@@ -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
-22
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
# GroomBook — Agent Instructions
## Repo Layout
- `apps/api` — NestJS backend API
- `apps/web` — React frontend (Vite)
- `apps/e2e` — Playwright E2E tests
- `apps/groombook` — CLI / meta package
- `packages/` — shared libraries
- `charts/` — Helm charts
- `infra/` — infrastructure config
## Development
- Package manager: `pnpm` (workspace monorepo)
- Run E2E tests: `pnpm --filter @groombook/e2e test`
- Run API: `pnpm --filter @groombook/api dev`
- Run web: `pnpm --filter @groombook/web dev`
## Git Workflow
- Branch from `main` using the pattern `fix/<issue-id>-<short-desc>` or `feat/<issue-id>-<short-desc>`
- Push to origin and open a PR against `main`
- If the issue specifies an existing PR/branch, push to that branch instead of creating a new one
- Commit messages: short imperative summary, reference the issue ID
## Task Handoff (Paperclip)
When you receive an assigned task from Paperclip:
1. **Checkout the task** immediately using `POST /api/issues/{issueId}/checkout`
2. **Read the full issue description** and any comments for context
3. **Do the work** — implement the fix/feature as described
4. **Test your changes** — run relevant tests, lint, type-check
5. **Commit and push** your changes to the appropriate branch
6. **Update the task** with status `in_review` and a comment summarizing what you did, including the commit SHA and PR link
7. **Never leave a task in `in_progress` without posting a comment** explaining current state before exiting your heartbeat
If you cannot complete the work (missing info, blocked by another task, environment issue):
- Set the task to `blocked` with a comment explaining what's blocking you
- Tag your manager in the comment if escalation is needed
Do NOT silently drop tasks. Every assigned task must get a status update and comment before your heartbeat ends.
+1 -5
View File
@@ -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"]
-3
View File
@@ -22,9 +22,6 @@
"hono": "^4.6.17", "hono": "^4.6.17",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"telnyx": "^1.23.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
+3 -17
View File
@@ -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(),
}; };
}); });
@@ -195,11 +182,10 @@ describe("POST /clients", () => {
expect(insertedValues[0]!.name).toBe("Charlie"); expect(insertedValues[0]!.name).toBe("Charlie");
}); });
it("creates a client with name and email", async () => { it("creates a client with only required name field", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" }); const res = await jsonRequest("POST", "/clients", { name: "Dana" });
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(insertedValues[0]!.name).toBe("Dana"); expect(insertedValues[0]!.name).toBe("Dana");
expect(insertedValues[0]!.email).toBe("dana@example.com");
}); });
it("rejects empty name", async () => { it("rejects empty name", async () => {
@@ -68,7 +68,6 @@ vi.mock("@groombook/db", () => {
}), }),
appointments, appointments,
eq: () => ({}), eq: () => ({}),
and: (..._clauses: unknown[]) => ({}),
}; };
}); });
+2 -3
View File
@@ -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 () => {
-42
View File
@@ -418,48 +418,6 @@ describe("GET /setup/status — OOBE bootstrap logic", () => {
expect(body.showAuthProviderStep).toBe(false); // DB config already exists expect(body.showAuthProviderStep).toBe(false); // DB config already exists
expect(body.authConfigExists).toBe(true); expect(body.authConfigExists).toBe(true);
}); });
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
dbStaffRows = []; // no super user
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "true";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.showAuthProviderStep).toBe(false);
expect(body.authConfigExists).toBe(false);
expect(body.authEnvVarsSet).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=1 also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "1";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=yes also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "yes";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
}); });
describe("POST /setup/auth-provider — OOBE bootstrap", () => { describe("POST /setup/auth-provider — OOBE bootstrap", () => {
+3 -43
View File
@@ -28,31 +28,15 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
import { devRouter } from "./routes/dev.js"; import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js"; import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js"; import { startReminderScheduler } from "./services/reminders.js";
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,
}) })
); );
@@ -66,9 +50,6 @@ app.route("/api/book", bookRouter);
// Public portal routes — client-facing, authenticated via impersonation session header // Public portal routes — client-facing, authenticated via impersonation session header
app.route("/api/portal", portalRouter); app.route("/api/portal", portalRouter);
// Public Stripe webhook endpoint — signature-verified, no auth required
app.route("/api/webhooks/stripe", webhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally // Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter); app.route("/api/dev", devRouter);
@@ -124,13 +105,7 @@ api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes // Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths // authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono(); const authRouter = new Hono();
authRouter.all("/*", (c) => { authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
try {
return getAuth().handler(c.req.raw);
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
});
api.route("/auth", authRouter); api.route("/auth", authRouter);
// ── Role guards ──────────────────────────────────────────────────────────────── // ── Role guards ────────────────────────────────────────────────────────────────
@@ -202,24 +177,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;
+13 -85
View File
@@ -3,7 +3,6 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "@groombook/db"; import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } from "@groombook/db"; import { decryptSecret } from "@groombook/db";
import { sendEmail } from "../services/email.js";
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
@@ -89,14 +88,8 @@ 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: {
enabled: true,
max: 10,
window: 60,
storage: "memory",
},
plugins: [ plugins: [
genericOAuth({ genericOAuth({
config: [ config: [
@@ -177,59 +170,7 @@ 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); const callbackBase = `${BETTER_AUTH_URL}/api/auth/callback`;
const issuerHostname = issuerUrlObj.hostname;
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
const discoveryRes = await fetch(discoveryUrlStr);
if (discoveryRes.ok) {
const discovery = await discoveryRes.json() as {
authorization_endpoint?: string;
token_endpoint?: string;
userinfo_endpoint?: string;
};
const replaceHost = (url: string, newHost: string) => {
try {
const parsed = new URL(url);
const newParsed = new URL(newHost);
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
} catch {
return url;
}
};
const authzUrl = discovery.authorization_endpoint;
const tokenUrl = discovery.token_endpoint;
const userInfoUrl = discovery.userinfo_endpoint;
if (authzUrl && tokenUrl && userInfoUrl) {
const authzUrlObj = new URL(authzUrl);
// Only validate authorizationUrl hostname against issuer — token/userinfo
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
if (authzUrlObj.hostname !== issuerHostname) {
throw new Error(
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
);
}
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");
}
} else {
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
}
} catch (err) {
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
}
// Build Better-Auth instance using resolved config // Build Better-Auth instance using resolved config
authInstance = betterAuth({ authInstance = betterAuth({
@@ -238,28 +179,6 @@ export async function initAuth(): Promise<void> {
}), }),
secret: BETTER_AUTH_SECRET, secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL, baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "memory",
},
account: {
storeStateStrategy: "cookie" as const,
},
emailAndPassword: {
enabled: true,
emailVerification: {
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
await sendEmail({
to: user.email,
subject: "Verify your GroomBook email",
text: `Click the link to verify your email: ${url}`,
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
});
},
},
},
plugins: [ plugins: [
genericOAuth({ genericOAuth({
config: [ config: [
@@ -267,8 +186,15 @@ export async function initAuth(): Promise<void> {
providerId: providerConfig.providerId, providerId: providerConfig.providerId,
clientId: providerConfig.clientId, clientId: providerConfig.clientId,
clientSecret: providerConfig.clientSecret, clientSecret: providerConfig.clientSecret,
discoveryUrl: discoveryUrlStr, ...(providerConfig.internalBaseUrl
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}), ? {
authorizationUrl: `${new URL(providerConfig.issuerUrl).origin}/application/o/authorize/`,
tokenUrl: `${providerConfig.internalBaseUrl}/application/o/token/`,
userInfoUrl: `${providerConfig.internalBaseUrl}/application/o/userinfo/`,
}
: {
discoveryUrl: `${providerConfig.issuerUrl}/.well-known/openid-configuration`,
}),
scopes: providerConfig.scopes.split(" ").filter(Boolean), scopes: providerConfig.scopes.split(" ").filter(Boolean),
}, },
], ],
@@ -279,12 +205,14 @@ export async function initAuth(): Promise<void> {
google: { google: {
clientId: process.env.GOOGLE_CLIENT_ID!, clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectURI: `${callbackBase}/google`,
}, },
} : {}), } : {}),
...(hasGitHub ? { ...(hasGitHub ? {
github: { github: {
clientId: process.env.GITHUB_CLIENT_ID!, clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!, clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectURI: `${callbackBase}/github`,
}, },
} : {}), } : {}),
}, },
+2 -8
View File
@@ -23,6 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
} }
export const authMiddleware: MiddlewareHandler = async (c, next) => { export const authMiddleware: MiddlewareHandler = async (c, next) => {
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
if (c.req.path.startsWith("/api/auth/")) { if (c.req.path.startsWith("/api/auth/")) {
await next(); await next();
return; return;
@@ -36,14 +37,7 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
return; return;
} }
let auth; const session = await getAuth().api.getSession({
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
headers: c.req.raw.headers, headers: c.req.raw.headers,
}); });
-45
View File
@@ -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);
}
};
-40
View File
@@ -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();
};
+29 -27
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "@groombook/db"; import { and, eq, getDb, isNull, 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,33 @@ 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); // Auto-link: staff record exists with matching email but no userId — link it now
await next(); if (jwt.email) {
return; const [linkedStaff] = await db
} .select()
// Auto-link by email: staff record exists with matching email but no userId .from(staff)
if (jwt.email) { .where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
const [byEmail] = await db if (linkedStaff) {
.select() await db
.from(staff) .update(staff)
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`)); .set({ userId: jwt.sub })
if (byEmail) { .where(eq(staff.id, linkedStaff.id));
await db console.log(
.update(staff) `[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
.set({ userId: jwt.sub, updatedAt: new Date() }) );
.where(eq(staff.id, byEmail.id)); c.set("staff", linkedStaff);
c.set("staff", { ...byEmail, userId: jwt.sub }); await next();
await next(); return;
return; }
} }
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
} }
return c.json( c.set("staff", fallbackRow);
{ error: "Forbidden: no staff record found for authenticated user" }, await next();
403
);
}; };
/** /**
@@ -166,9 +168,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
); );
+1 -71
View File
@@ -16,9 +16,8 @@ import {
services, services,
staff, staff,
} from "@groombook/db"; } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono<AppEnv>(); export const appointmentGroupsRouter = new Hono();
// ─── Schemas ────────────────────────────────────────────────────────────────── // ─── Schemas ──────────────────────────────────────────────────────────────────
@@ -50,8 +49,6 @@ appointmentGroupsRouter.get("/", async (c) => {
const clientId = c.req.query("clientId"); const clientId = c.req.query("clientId");
const from = c.req.query("from"); const from = c.req.query("from");
const to = c.req.query("to"); const to = c.req.query("to");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const groupConditions = clientId const groupConditions = clientId
? [eq(appointmentGroups.clientId, clientId)] ? [eq(appointmentGroups.clientId, clientId)]
@@ -91,16 +88,6 @@ appointmentGroupsRouter.get("/", async (c) => {
})) }))
.filter((g) => !from || g.appointments.length > 0); .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); return c.json(result);
}); });
@@ -109,8 +96,6 @@ appointmentGroupsRouter.get("/", async (c) => {
appointmentGroupsRouter.get("/:id", async (c) => { appointmentGroupsRouter.get("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db const [group] = await db
.select() .select()
@@ -126,7 +111,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
serviceId: appointments.serviceId, serviceId: appointments.serviceId,
serviceName: services.name, serviceName: services.name,
staffId: appointments.staffId, staffId: appointments.staffId,
batherStaffId: appointments.batherStaffId,
staffName: staff.name, staffName: staff.name,
status: appointments.status, status: appointments.status,
startTime: appointments.startTime, startTime: appointments.startTime,
@@ -141,15 +125,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
.where(eq(appointments.groupId, id)) .where(eq(appointments.groupId, id))
.orderBy(appointments.startTime); .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 const [client] = await db
.select({ name: clients.name, email: clients.email }) .select({ name: clients.name, email: clients.email })
.from(clients) .from(clients)
@@ -165,13 +140,6 @@ appointmentGroupsRouter.post(
zValidator("json", createGroupSchema), zValidator("json", createGroupSchema),
async (c) => { async (c) => {
const db = getDb(); 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 body = c.req.valid("json");
const startTime = new Date(body.startTime); const startTime = new Date(body.startTime);
@@ -276,28 +244,6 @@ appointmentGroupsRouter.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 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 const [updated] = await db
.update(appointmentGroups) .update(appointmentGroups)
@@ -315,8 +261,6 @@ appointmentGroupsRouter.patch(
appointmentGroupsRouter.delete("/:id", async (c) => { appointmentGroupsRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db const [group] = await db
.select({ id: appointmentGroups.id }) .select({ id: appointmentGroups.id })
@@ -324,20 +268,6 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
.where(eq(appointmentGroups.id, id)); .where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404); 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 await db
.update(appointments) .update(appointments)
.set({ status: "cancelled", updatedAt: new Date() }) .set({ status: "cancelled", updatedAt: new Date() })
+43 -249
View File
@@ -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({
@@ -62,10 +41,6 @@ const createAppointmentSchema = z.object({
frequencyWeeks: z.number().int().min(1).max(52), frequencyWeeks: z.number().int().min(1).max(52),
count: z.number().int().min(2).max(52), count: z.number().int().min(2).max(52),
}) })
.refine(
(r) => r.frequencyWeeks * r.count <= 52,
{ message: "Recurrence series must not exceed 1 year" }
)
.optional(), .optional(),
}); });
@@ -188,28 +163,6 @@ 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) { if (!recurrence) {
// Single appointment // Single appointment
const [inserted] = await tx const [inserted] = await tx
@@ -233,54 +186,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 +201,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 +221,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);
} }
@@ -338,35 +235,44 @@ async function sendConfirmationEmail(
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
appt: typeof appointments.$inferSelect appt: typeof appointments.$inferSelect
): Promise<void> { ): Promise<void> {
const [row] = await db const [client] = await db
.select({ .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
clientName: clients.name, .from(clients)
clientEmail: clients.email, .where(eq(clients.id, appt.clientId))
clientEmailOptOut: clients.emailOptOut,
petName: pets.name,
serviceName: services.name,
groomerName: staff.name,
})
.from(appointments)
.innerJoin(clients, eq(clients.id, appointments.clientId))
.innerJoin(pets, eq(pets.id, appointments.petId))
.innerJoin(services, eq(services.id, appointments.serviceId))
.leftJoin(staff, eq(staff.id, appointments.staffId))
.where(eq(appointments.id, appt.id))
.limit(1); .limit(1);
if (!row) return; if (!client || !client.email || client.emailOptOut) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
if (!clientEmail || clientEmailOptOut) return; const [pet] = await db
if (!petName || !serviceName) return; .select({ name: pets.name })
.from(pets)
.where(eq(pets.id, appt.petId))
.limit(1);
const [service] = await db
.select({ name: services.name })
.from(services)
.where(eq(services.id, appt.serviceId))
.limit(1);
let groomerName: string | null = null;
if (appt.staffId) {
const [groomer] = await db
.select({ name: staff.name })
.from(staff)
.where(eq(staff.id, appt.staffId))
.limit(1);
groomerName = groomer?.name ?? null;
}
if (!pet || !service) return;
const sent = await sendEmail( const sent = await sendEmail(
buildConfirmationEmail(clientEmail, { buildConfirmationEmail(client.email, {
clientName, clientName: client.name,
petName, petName: pet.name,
serviceName, serviceName: service.name,
groomerName: groomerName ?? null, groomerName,
startTime: appt.startTime, startTime: appt.startTime,
}) })
); );
@@ -446,76 +352,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(),
}; };
@@ -551,13 +387,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;
} }
@@ -569,8 +398,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,
@@ -606,11 +434,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"), {
@@ -638,29 +461,6 @@ 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 const [updated] = await tx
.update(appointments) .update(appointments)
.set(update) .set(update)
@@ -735,12 +535,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 });
} }
@@ -763,12 +560,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 });
}); });
+12 -28
View File
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
const bookingSchema = z.object({ const bookingSchema = z.object({
serviceId: z.string().uuid(), serviceId: z.string().uuid(),
startTime: z.string().datetime().refine( startTime: z.string().datetime(),
(dt) => new Date(dt) > new Date(),
{ message: "Appointment must be in the future" }
),
clientName: z.string().min(1).max(200), clientName: z.string().min(1).max(200),
clientEmail: z.string().email(), clientEmail: z.string().email(),
clientPhone: z.string().max(50).optional(), clientPhone: z.string().max(50).optional(),
@@ -268,36 +265,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 +309,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 +329,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`);
}); });
+2 -13
View File
@@ -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);
} }
+3 -27
View File
@@ -8,12 +8,10 @@ export const clientsRouter = new Hono<AppEnv>();
const createClientSchema = z.object({ const createClientSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
email: z.string().email(), email: z.string().email().optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
address: z.string().max(500).optional(), address: z.string().max(500).optional(),
notes: z.string().max(2000).optional(), notes: z.string().max(2000).optional(),
smsOptIn: z.boolean().optional(),
smsConsentText: z.string().max(1000).optional(),
}); });
@@ -97,7 +95,6 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
// Update a client (including status changes) // Update a client (including status changes)
const patchClientSchema = createClientSchema.partial().extend({ const patchClientSchema = createClientSchema.partial().extend({
status: z.enum(["active", "disabled"]).optional(), status: z.enum(["active", "disabled"]).optional(),
smsOptOut: z.boolean().optional(),
}); });
clientsRouter.patch( clientsRouter.patch(
@@ -110,19 +107,13 @@ clientsRouter.patch(
const setValues: Record<string, unknown> = { ...body, updatedAt: now }; const setValues: Record<string, unknown> = { ...body, updatedAt: now };
// When disabling, set disabledAt; when re-enabling, clear it
if (body.status === "disabled") { if (body.status === "disabled") {
setValues.disabledAt = now; setValues.disabledAt = now;
} else if (body.status === "active") { } else if (body.status === "active") {
setValues.disabledAt = null; setValues.disabledAt = null;
} }
if (body.smsOptOut === true) {
setValues.smsOptIn = false;
setValues.smsOptOutDate = now;
delete setValues.smsOptOut;
}
delete setValues.smsOptOut;
const [row] = await db const [row] = await db
.update(clients) .update(clients)
.set(setValues) .set(setValues)
@@ -144,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 });
+6 -93
View File
@@ -1,10 +1,9 @@
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 { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db"; import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const groomingLogsRouter = new Hono<AppEnv>(); export const groomingLogsRouter = new Hono();
const createLogSchema = z.object({ const createLogSchema = z.object({
petId: z.string().uuid(), petId: z.string().uuid(),
@@ -21,26 +20,6 @@ groomingLogsRouter.get("/", async (c) => {
const db = getDb(); const db = getDb();
const petId = c.req.query("petId"); const petId = c.req.query("petId");
if (!petId) return c.json({ error: "petId is required" }, 400); 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 const rows = await db
.select() .select()
.from(groomingVisitLogs) .from(groomingVisitLogs)
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
zValidator("json", createLogSchema), zValidator("json", createLogSchema),
async (c) => { async (c) => {
const db = getDb(); const db = getDb();
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json"); const { groomedAt, ...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 const [row] = await db
.insert(groomingVisitLogs) .insert(groomingVisitLogs)
.values({ .values({
...rest, ...rest,
petId,
appointmentId: appointmentId ?? null,
groomedAt: groomedAt ? new Date(groomedAt) : new Date(), groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
}) })
.returning(); .returning();
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
groomingLogsRouter.delete("/:id", async (c) => { groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const [row] = await db
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) .delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id)) .where(eq(groomingVisitLogs.id, c.req.param("id")))
.returning(); .returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
+55 -137
View File
@@ -8,15 +8,13 @@ import {
invoices, invoices,
invoiceLineItems, invoiceLineItems,
invoiceTipSplits, invoiceTipSplits,
refunds,
appointments, appointments,
services, services,
clients, clients,
sql, sql,
} from "@groombook/db"; } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const invoicesRouter = new Hono<AppEnv>(); export const invoicesRouter = new Hono();
const createInvoiceSchema = z.object({ const createInvoiceSchema = z.object({
appointmentId: z.string().uuid().optional(), appointmentId: z.string().uuid().optional(),
@@ -45,61 +43,53 @@ const updateInvoiceSchema = z.object({
}); });
// List invoices // List invoices
const listInvoicesQuerySchema = z.object({ invoicesRouter.get("/", async (c) => {
clientId: z.string().uuid().optional(), const db = getDb();
appointmentId: z.string().uuid().optional(), const clientId = c.req.query("clientId");
status: z.enum(["draft", "pending", "paid", "void"]).optional(), const appointmentId = c.req.query("appointmentId");
limit: z.coerce.number().int().min(1).max(200).default(50), const status = c.req.query("status");
offset: z.coerce.number().int().min(0).default(0), const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
const offset = parseInt(c.req.query("offset") || "0", 10);
const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
}); });
invoicesRouter.get(
"/",
zValidator("query", listInvoicesQuerySchema),
async (c) => {
const db = getDb();
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
}
);
// Get single invoice with line items and tip splits // Get single invoice with line items and tip splits
invoicesRouter.get("/:id", async (c) => { invoicesRouter.get("/:id", async (c) => {
const db = getDb(); const db = getDb();
@@ -126,8 +116,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 +161,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 +291,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 +306,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() };
@@ -362,61 +338,3 @@ invoicesRouter.patch(
return c.json({ ...updated, lineItems }); return c.json({ ...updated, lineItems });
} }
); );
// ─── Refund ───────────────────────────────────────────────────────────────────
import { processRefund } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(),
idempotencyKey: z.string().max(255).optional(),
});
invoicesRouter.post(
"/:id/refund",
zValidator("json", refundSchema),
async (c) => {
const db = getDb();
const staff = c.get("staff");
if (!staff) return c.json({ error: "Forbidden" }, 403);
if (staff.role !== "manager" && !staff.isSuperUser) {
return c.json({ error: "Manager role required" }, 403);
}
const id = c.req.param("id");
const body = c.req.valid("json");
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
if (!invoice) return c.json({ error: "Not found" }, 404);
if (invoice.status !== "paid") {
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
}
if (!invoice.stripePaymentIntentId) {
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
}
return await db.transaction(async (tx) => {
if (body.idempotencyKey) {
const [existing] = await tx
.select()
.from(refunds)
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
if (existing) {
return c.json({ refundId: existing.stripeRefundId });
}
}
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
await tx.insert(refunds).values({
invoiceId: id,
stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null,
});
return c.json({ refundId: result.refundId });
});
}
);
+111 -125
View File
@@ -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);
@@ -24,12 +35,6 @@ portalRouter.get("/me", async (c) => {
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone }); return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
}); });
portalRouter.get("/config", async (c) => {
return c.json({
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
});
});
portalRouter.get("/services", async (c) => { portalRouter.get("/services", async (c) => {
const db = getDb(); const db = getDb();
const allServices = await db.select().from(services).where(eq(services.active, true)); const allServices = await db.select().from(services).where(eq(services.active, true));
@@ -38,7 +43,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 +95,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 +105,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);
@@ -112,7 +123,7 @@ portalRouter.get("/invoices", async (c) => {
id: inv.id, id: inv.id,
status: inv.status, status: inv.status,
totalCents: inv.totalCents, totalCents: inv.totalCents,
date: inv.createdAt, createdAt: inv.createdAt,
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })), lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
}))); })));
}); });
@@ -131,7 +142,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 +190,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 +244,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 +313,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 +358,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 +386,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 +408,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 +436,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);
} }
@@ -363,105 +448,6 @@ portalRouter.delete("/waitlist/:id", async (c) => {
return c.json({ ok: true }); return c.json({ ok: true });
}); });
// ─── Payment routes ───────────────────────────────────────────────────────────
import {
createPaymentIntent,
listPaymentMethods,
detachPaymentMethod,
createSetupIntent,
getOrCreateStripeCustomer,
getStripeClient,
} from "../services/payment.js";
const payMultipleSchema = z.object({
invoiceIds: z.array(z.string().uuid()).min(1),
});
portalRouter.post(
"/invoices/pay-multiple",
zValidator("json", payMultipleSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const invoiceRows = await db
.select()
.from(invoices)
.where(inArray(invoices.id, body.invoiceIds));
if (invoiceRows.length !== body.invoiceIds.length) {
return c.json({ error: "One or more invoices not found" }, 404);
}
for (const inv of invoiceRows) {
if (inv.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
if (inv.status === "draft" || inv.status === "void") {
return c.json({ error: `Invoice ${inv.id} cannot be paid (draft or void)` }, 422);
}
if (inv.status === "paid") {
return c.json({ error: `Invoice ${inv.id} is already paid` }, 422);
}
}
const firstInvoice = invoiceRows[0];
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
if (!allSameClient) {
return c.json({ error: "All invoices must belong to the same client" }, 422);
}
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const result = await createPaymentIntent(body.invoiceIds, clientId);
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
}
);
portalRouter.get("/payment-methods", async (c) => {
const clientId = c.get("portalClientId");
const methods = await listPaymentMethods(clientId);
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
return c.json(methods);
});
portalRouter.post("/payment-methods", async (c) => {
const clientId = c.get("portalClientId");
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const customerId = await getOrCreateStripeCustomer(clientId);
if (!customerId) return c.json({ error: "Could not create customer" }, 500);
const result = await createSetupIntent(customerId);
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
});
portalRouter.delete("/payment-methods/:id", async (c) => {
const clientId = c.get("portalClientId");
const paymentMethodId = c.req.param("id");
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
const stripe = getStripeClient();
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
return c.json({ error: "Payment method not found" }, 404);
}
const ok = await detachPaymentMethod(paymentMethodId);
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
return c.json({ ok: true });
});
// ─── Dev-mode session creation ────────────────────────────────────────────── // ─── Dev-mode session creation ──────────────────────────────────────────────
// Allows the dev login selector to vend an impersonation session for a client // Allows the dev login selector to vend an impersonation session for a client
// without requiring manager auth. Only available when AUTH_DISABLED=true. // without requiring manager auth. Only available when AUTH_DISABLED=true.
+3 -26
View File
@@ -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,
}); });
}); });
+1 -1
View File
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
basePriceCents: z.number().int().positive(), basePriceCents: z.number().int().positive(),
durationMinutes: z.number().int().positive().max(480), durationMinutes: z.number().int().positive(),
active: z.boolean().default(true), active: z.boolean().default(true),
}); });
+37 -95
View File
@@ -4,40 +4,11 @@ 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
// and whether the auth provider bootstrap step should be shown // and whether the auth provider bootstrap step should be shown
setupRouter.get("/status", async (c) => { setupRouter.get("/status", async (c) => {
const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase());
if (skipOobe) {
return c.json({
needsSetup: false,
showAuthProviderStep: false,
authConfigExists: false,
authEnvVarsSet: false,
skipped: true,
});
}
const db = getDb(); const db = getDb();
// Check if any super user exists // Check if any super user exists
@@ -203,74 +174,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 +243,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)
-30
View File
@@ -18,10 +18,6 @@ const createStaffSchema = z.object({
const updateStaffSchema = createStaffSchema.partial().omit({ email: true }); const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
const linkUserSchema = z.object({
userId: z.string().min(1),
});
staffRouter.get("/me", async (c) => { staffRouter.get("/me", async (c) => {
const staffRow = c.get("staff"); const staffRow = c.get("staff");
return c.json(staffRow); return c.json(staffRow);
@@ -110,32 +106,6 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
return c.json(row); return c.json(row);
}); });
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
const db = getDb();
const targetId = c.req.param("id");
const body = c.req.valid("json");
const currentStaff = c.get("staff");
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
}
const [existing] = await db
.select()
.from(staff)
.where(eq(staff.id, targetId))
.limit(1);
if (!existing) return c.json({ error: "Not found" }, 404);
const [updated] = await db
.update(staff)
.set({ userId: body.userId, updatedAt: new Date() })
.where(eq(staff.id, targetId))
.returning();
return c.json(updated);
});
staffRouter.delete("/:id", async (c) => { staffRouter.delete("/:id", async (c) => {
const db = getDb(); const db = getDb();
const id = c.req.param("id"); const id = c.req.param("id");
-119
View File
@@ -1,119 +0,0 @@
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";
export const webhooksRouter = new Hono();
webhooksRouter.post("/stripe", async (c) => {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return c.json({ error: "Webhook secret not configured" }, 503);
}
const signature = c.req.header("stripe-signature");
if (!signature) {
return c.json({ error: "Missing signature" }, 401);
}
let rawBody: string;
try {
rawBody = await c.req.text();
} catch {
return c.json({ error: "Could not read body" }, 400);
}
const stripe = getStripeClient();
if (!stripe) {
return c.json({ error: "Stripe not configured" }, 503);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid signature";
return c.json({ error: message }, 401);
}
const db = getDb();
if (event.type === "payment_intent.succeeded") {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata?.groombook_invoice_ids) {
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, invoiceIdTrimmed))
.limit(1);
if (!inv) continue;
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
await db
.update(invoices)
.set({
status: "paid",
paymentMethod: "card",
paidAt: new Date(),
stripePaymentIntentId: pi.id,
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceIdTrimmed));
}
}
} else if (event.type === "payment_intent.payment_failed") {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata?.groombook_invoice_ids) {
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, invoiceIdTrimmed));
}
}
} else if (event.type === "charge.refunded") {
const charge = event.data.object as Stripe.Charge;
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
const [inv] = await db
.select({ id: invoices.id })
.from(invoices)
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
.limit(1);
if (inv) {
const refundId =
typeof charge.refunded === "boolean" && charge.refunded
? `ch_${charge.id}_refund`
: null;
await db
.update(invoices)
.set({
status: "void",
stripeRefundId: refundId,
updatedAt: new Date(),
})
.where(eq(invoices.id, inv.id));
}
}
} else if (event.type === "charge.dispute.created") {
const dispute = event.data.object as Stripe.Dispute;
console.error(
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
);
}
return c.json({ received: true });
});
-164
View File
@@ -1,164 +0,0 @@
import Stripe from "stripe";
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
let _stripe: Stripe | null | undefined;
export function getStripeClient(): Stripe | null {
if (_stripe === undefined) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) return null;
_stripe = new Stripe(secretKey);
}
return _stripe;
}
export async function getOrCreateStripeCustomer(clientId: string): Promise<string | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const db = getDb();
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
if (!client) return null;
if (client.stripeCustomerId) return client.stripeCustomerId;
const customer = await stripe.customers.create({
metadata: { groombook_client_id: clientId },
});
await db
.update(clients)
.set({ stripeCustomerId: customer.id, updatedAt: new Date() })
.where(eq(clients.id, clientId));
return customer.id;
}
export async function createPaymentIntent(
invoiceIdOrIds: string | string[],
clientId: string
): Promise<{ clientSecret: string; paymentIntentId: string } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const db = getDb();
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
const firstInvoiceId = invoiceIds[0];
if (!firstInvoiceId) return null;
const invoiceRows = await db
.select()
.from(invoices)
.where(eq(invoices.id, firstInvoiceId));
const [invoice] = invoiceRows;
if (!invoice) return null;
let totalCents = invoice.totalCents;
if (invoiceIds.length > 1) {
const allInvoices = await db
.select({ totalCents: invoices.totalCents })
.from(invoices)
.where(inArray(invoices.id, invoiceIds));
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
}
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return null;
const paymentIntent = await stripe.paymentIntents.create({
amount: totalCents,
currency: "usd",
customer: stripeCustomerId,
metadata: {
groombook_invoice_ids: invoiceIds.join(","),
groombook_client_id: clientId,
},
automatic_payment_methods: { enabled: true },
});
for (const invId of invoiceIds) {
await db
.update(invoices)
.set({ stripePaymentIntentId: paymentIntent.id, updatedAt: new Date() })
.where(eq(invoices.id, invId));
}
const clientSecret = paymentIntent.client_secret;
if (!clientSecret) return null;
return { clientSecret, paymentIntentId: paymentIntent.id };
}
export async function processRefund(
invoiceId: string,
amountCents?: number
): Promise<{ refundId: string } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const db = getDb();
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, invoiceId)).limit(1);
if (!invoice?.stripePaymentIntentId) return null;
const refund = await stripe.refunds.create({
payment_intent: invoice.stripePaymentIntentId,
amount: amountCents,
});
await db
.update(invoices)
.set({ stripeRefundId: refund.id, updatedAt: new Date() })
.where(eq(invoices.id, invoiceId));
return { refundId: refund.id };
}
export async function listPaymentMethods(clientId: string): Promise<Stripe.PaymentMethod[] | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return null;
const methods = await stripe.paymentMethods.list({
customer: stripeCustomerId,
type: "card",
});
return methods.data;
}
export async function attachPaymentMethod(
clientId: string,
paymentMethodId: string
): Promise<boolean> {
const stripe = getStripeClient();
if (!stripe) return false;
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
if (!stripeCustomerId) return false;
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
return true;
}
export async function detachPaymentMethod(paymentMethodId: string): Promise<boolean> {
const stripe = getStripeClient();
if (!stripe) return false;
await stripe.paymentMethods.detach(paymentMethodId);
return true;
}
export async function createSetupIntent(customerId: string): Promise<{ clientSecret: string } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const setupIntent = await stripe.setupIntents.create({
customer: customerId,
payment_method_types: ["card"],
});
return { clientSecret: setupIntent.client_secret! };
}
+38 -82
View File
@@ -12,16 +12,14 @@ import {
services, services,
staff, staff,
reminderLogs, reminderLogs,
session,
} from "@groombook/db"; } from "@groombook/db";
import { import {
buildReminderEmail, buildReminderEmail,
sendEmail, sendEmail,
} from "./email.js"; } from "./email.js";
import { smsSend } from "./sms.js";
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
// How many hours before the appointment to send each reminder.
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
function getReminderWindows(): { label: string; hours: number }[] { function getReminderWindows(): { label: string; hours: number }[] {
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24); const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2); const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
@@ -31,14 +29,20 @@ function getReminderWindows(): { label: string; hours: number }[] {
]; ];
} }
// Checks for upcoming appointments that need reminders and sends them.
// Runs every minute — idempotent via reminder_logs unique constraint.
export async function runReminderCheck(): Promise<void> { export async function runReminderCheck(): Promise<void> {
const db = getDb(); const db = getDb();
const now = new Date(); const now = new Date();
for (const window of getReminderWindows()) { for (const window of getReminderWindows()) {
// Target window: appointments starting between (hours - 1) and hours from now.
// Running every minute means we check a 1-minute slice; the 1-hour window
// ensures we catch appointments that started between heartbeats.
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000); const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
const windowEnd = new Date(now.getTime() + window.hours * 3600_000); const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
// Find upcoming appointments in this time window that haven't been cancelled/completed
const upcoming = await db const upcoming = await db
.select({ .select({
id: appointments.id, id: appointments.id,
@@ -60,38 +64,23 @@ export async function runReminderCheck(): Promise<void> {
); );
for (const appt of upcoming) { for (const appt of upcoming) {
const [emailLog] = await db // Check if reminder already sent (unique constraint prevents double-send)
const existing = await db
.select({ id: reminderLogs.id }) .select({ id: reminderLogs.id })
.from(reminderLogs) .from(reminderLogs)
.where( .where(
and( and(
eq(reminderLogs.appointmentId, appt.id), eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label), eq(reminderLogs.reminderType, window.label)
eq(reminderLogs.channel, "email")
) )
) )
.limit(1); .limit(1);
const [smsLog] = await db if (existing.length > 0) continue; // already sent
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
// Fetch related records for the email
const [client] = await db const [client] = await db
.select({ .select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phone: clients.phone,
})
.from(clients) .from(clients)
.where(eq(clients.id, appt.clientId)) .where(eq(clients.id, appt.clientId))
.limit(1); .limit(1);
@@ -122,6 +111,8 @@ export async function runReminderCheck(): Promise<void> {
if (!pet || !service) continue; if (!pet || !service) continue;
// Ensure the appointment has a confirmation token before sending the reminder.
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
let confirmationToken = appt.confirmationToken; let confirmationToken = appt.confirmationToken;
if (!confirmationToken) { if (!confirmationToken) {
confirmationToken = randomBytes(32).toString("hex"); confirmationToken = randomBytes(32).toString("hex");
@@ -131,74 +122,39 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id)); .where(eq(appointments.id, appt.id));
} }
if (!emailLog) { const sent = await sendEmail(
const sent = await sendEmail( buildReminderEmail(
buildReminderEmail( client.email,
client.email, {
{ clientName: client.name,
clientName: client.name, petName: pet.name,
petName: pet.name, serviceName: service.name,
serviceName: service.name, groomerName,
groomerName, startTime: appt.startTime,
startTime: appt.startTime, },
}, window.hours,
window.hours, confirmationToken
confirmationToken )
) );
);
if (sent) { if (sent) {
await db // Record send — ignore conflicts (race condition between instances)
.insert(reminderLogs) await db
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" }) .insert(reminderLogs)
.onConflictDoNothing(); .values({ appointmentId: appt.id, reminderType: window.label })
} .onConflictDoNothing();
}
if (!smsLog && client.smsOptIn && client.phone) {
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
const smsBody = [
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
`Confirm: ${confirmUrl}`,
`Cancel: ${cancelUrl}`,
TCPA_OPT_OUT,
].join(". ");
try {
const smsOk = await smsSend(client.phone, smsBody);
if (smsOk) {
await db
.insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
.onConflictDoNothing();
}
} catch (err) {
console.error("[reminders] SMS send failed:", err);
}
} }
} }
} }
} }
// Starts the cron scheduler. Call once at server startup.
export function startReminderScheduler(): void { export function startReminderScheduler(): void {
// Run every minute
cron.schedule("* * * * *", () => { cron.schedule("* * * * *", () => {
runReminderCheck().catch((err) => { runReminderCheck().catch((err) => {
console.error("[reminders] Error during reminder check:", err); console.error("[reminders] Error during reminder check:", err);
}); });
runSessionCleanup().catch((err) => {
console.error("[reminders] Error during session cleanup:", err);
});
}); });
console.log("[reminders] Reminder scheduler started"); console.log("[reminders] Reminder scheduler started");
} }
export async function runSessionCleanup(): Promise<void> {
const db = getDb();
const now = new Date();
await db
.delete(session)
.where(lt(session.expiresAt, now));
}
-142
View File
@@ -1,142 +0,0 @@
import { Telnyx } from "telnyx";
import { createHmac } from "crypto";
export interface SmsProvider {
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
validateWebhookSignature(req: Request): boolean;
}
interface TelnyxSmsResult {
message_id: string;
status: string;
}
function createTelnyxClient(): Telnyx | null {
const apiKey = process.env.TELNYX_API_KEY;
if (!apiKey) return null;
return new Telnyx(apiKey);
}
let _client: Telnyx | null | undefined;
function getClient(): Telnyx | null {
if (_client === undefined) _client = createTelnyxClient();
return _client;
}
function getFromNumber(): string | null {
return process.env.TELNYX_FROM_NUMBER ?? null;
}
function isE164(phone: string): boolean {
return /^\+[1-9]\d{7,14}$/.test(phone);
}
export async function sendSms(
to: string,
body: string,
mediaUrls?: string[]
): Promise<{ messageId: string; status: string }> {
const client = getClient();
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
const from = getFromNumber();
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
const payload: Record<string, unknown> = {
from,
to,
body,
};
if (mediaUrls && mediaUrls.length > 0) {
payload.media_urls = mediaUrls;
}
const result = await client.messages.create(payload as Record<string, string | string[]>);
const smsResult = result.data as unknown as TelnyxSmsResult;
return {
messageId: smsResult.message_id,
status: smsResult.status,
};
}
export class TelnyxProvider implements SmsProvider {
async sendSms(
to: string,
body: string,
mediaUrls?: string[]
): Promise<{ messageId: string; status: string }> {
return sendSms(to, body, mediaUrls);
}
validateWebhookSignature(req: Request): boolean {
const secret = process.env.TELNYX_WEBHOOK_SECRET;
if (!secret) return false;
const signature = req.headers.get("telnyx-signature");
if (!signature) return false;
const payload = JSON.stringify(req.body);
try {
const hmac = createHmac("sha256", secret);
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length) return false;
let diff = 0;
for (let i = 0; i < sigBuf.length; i++) {
const sigByte = sigBuf[i] ?? 0;
const expByte = expBuf[i] ?? 0;
diff |= sigByte ^ expByte;
}
return diff === 0;
} catch {
return false;
}
}
}
let _provider: SmsProvider | null | undefined;
export function createSmsProvider(): SmsProvider | null {
if (_provider === undefined) {
if (process.env.SMS_ENABLED !== "true") {
_provider = null;
return null;
}
switch (process.env.SMS_PROVIDER) {
case "telnyx": {
const client = getClient();
if (!client) {
_provider = null;
return null;
}
_provider = new TelnyxProvider();
break;
}
default:
_provider = null;
}
}
return _provider;
}
export async function smsSend(
to: string,
body: string,
mediaUrls?: string[]
): Promise<boolean> {
const provider = createSmsProvider();
if (!provider) return false;
await provider.sendSms(to, body, mediaUrls);
return true;
}
-19
View File
@@ -1,19 +0,0 @@
declare module "telnyx" {
export interface MessageResult {
data: unknown;
}
export interface MessagesCreateParams {
from: string;
to: string;
body: string;
media_urls?: string[];
}
export class Telnyx {
constructor(apiKey: string);
messages: {
create(params: Record<string, string | string[]>): Promise<MessageResult>;
};
}
}
+1 -5
View File
@@ -44,10 +44,7 @@ test.beforeEach(async ({ page }) => {
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 }, json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
}); });
} }
if (url.includes("/api/invoices")) { // Appointments, clients, services, staff, invoices, book, etc.
return route.fulfill({ json: { data: [], total: 0 } });
}
// Appointments, clients, services, staff, book, etc.
return route.fulfill({ json: [] }); return route.fulfill({ json: [] });
}); });
}); });
@@ -85,7 +82,6 @@ test("admin staff page loads", async ({ page }) => {
test("admin invoices page loads", async ({ page }) => { test("admin invoices page loads", async ({ page }) => {
await page.goto("/admin/invoices"); await page.goto("/admin/invoices");
await page.waitForLoadState("domcontentloaded");
await expect(page.getByText("GroomBook")).toBeVisible(); await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible(); await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
}); });
-2
View File
@@ -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
-12
View File
@@ -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 -3
View File
@@ -14,10 +14,8 @@
}, },
"dependencies": { "dependencies": {
"@groombook/types": "workspace:*", "@groombook/types": "workspace:*",
"@stripe/react-stripe-js": "^6.1.0",
"@stripe/stripe-js": "^9.1.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6", "better-auth": "^1.0.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
+36 -80
View File
@@ -1,4 +1,4 @@
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom"; import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.js"; import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js"; import { ClientsPage } from "./pages/Clients.js";
@@ -18,31 +18,22 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
import { BrandingProvider, useBranding } from "./BrandingContext.js"; import { BrandingProvider, useBranding } from "./BrandingContext.js";
import { GlobalSearch } from "./components/GlobalSearch.js"; import { GlobalSearch } from "./components/GlobalSearch.js";
import { useSession, signIn, signOut } from "./lib/auth-client.js"; import { useSession, signIn } from "./lib/auth-client.js";
function LoginPage() { function LoginPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [providers, setProviders] = useState<string[]>([]); const [providers, setProviders] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetch("/api/auth/providers") fetch("/api/auth/providers")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setProviders(data.providers ?? [])) .then((data) => setProviders(data.providers ?? []))
.catch(() => setProviders([])); .catch(() => setProviders([]));
const params = new URLSearchParams(window.location.search);
const authError = params.get("error");
if (authError) setError(authError.replace(/_/g, " "));
}, []); }, []);
const handleSocialLogin = async (provider: string) => { const handleSocialLogin = async (provider: string) => {
setIsLoading(true); setIsLoading(true);
setError(null); await signIn.social({ provider, callbackURL: window.location.origin });
const result = await signIn.social({ provider, callbackURL: window.location.origin });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
}
}; };
const isGoogle = providers.includes("google"); const isGoogle = providers.includes("google");
@@ -74,11 +65,6 @@ function LoginPage() {
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}> <p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
Sign in to continue Sign in to continue
</p> </p>
{error && (
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}>
{error}
</div>
)}
{isGoogle && ( {isGoogle && (
<button <button
onClick={() => handleSocialLogin("google")} onClick={() => handleSocialLogin("google")}
@@ -181,7 +167,6 @@ const NAV_LINKS = [
function AdminLayout() { function AdminLayout() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const { branding } = useBranding(); const { branding } = useBranding();
const logoSrc = branding.logoBase64 && branding.logoMimeType const logoSrc = branding.logoBase64 && branding.logoMimeType
@@ -210,7 +195,6 @@ function AdminLayout() {
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
marginRight: "1.25rem", marginRight: "1.25rem",
flexShrink: 0,
}}> }}>
{logoSrc && ( {logoSrc && (
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} /> <img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
@@ -224,73 +208,45 @@ function AdminLayout() {
</strong> </strong>
</div> </div>
<GlobalSearch /> <GlobalSearch />
<div style={{ <Link
display: "flex", to="/admin/book"
overflowX: "auto",
flex: 1,
minWidth: 0,
gap: "0.25rem",
}}>
<Link
to="/admin/book"
style={{
padding: "0.4rem 0.85rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: 600,
color: "#fff",
background: branding.primaryColor,
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
flexShrink: 0,
}}
>
Book
</Link>
{NAV_LINKS.map(({ to, label }) => {
const active =
to === "/admin"
? location.pathname === "/admin"
: location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
style={{
padding: "0.4rem 0.75rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? "#2d6a4f" : "#4b5563",
background: active ? "#ecfdf5" : "transparent",
flexShrink: 0,
}}
>
{label}
</Link>
);
})}
</div>
<button
onClick={async () => {
await signOut();
navigate("/login");
}}
style={{ style={{
flexShrink: 0,
padding: "0.4rem 0.85rem", padding: "0.4rem 0.85rem",
borderRadius: 6, borderRadius: 6,
border: "1px solid #e2e8f0", textDecoration: "none",
background: "#fff",
color: "#4b5563",
fontSize: 13, fontSize: 13,
fontWeight: 500, fontWeight: 600,
cursor: "pointer", color: "#fff",
background: branding.primaryColor,
marginRight: "0.5rem",
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
}} }}
> >
Logout Book
</button> </Link>
{NAV_LINKS.map(({ to, label }) => {
const active =
to === "/admin"
? location.pathname === "/admin"
: location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
style={{
padding: "0.4rem 0.75rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? "#2d6a4f" : "#4b5563",
background: active ? "#ecfdf5" : "transparent",
}}
>
{label}
</Link>
);
})}
</nav> </nav>
<main style={{ padding: "1.25rem 1.5rem" }}> <main style={{ padding: "1.25rem 1.5rem" }}>
<Routes> <Routes>
+1 -1
View File
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "", baseURL: import.meta.env.VITE_API_URL ?? "",
}); });
export const { signIn, signOut, useSession, changePassword } = authClient; export const { signIn, signOut, useSession } = authClient;
+1
View File
@@ -226,6 +226,7 @@ export function CustomerPortal() {
)} )}
{showReschedule && rescheduleAppointment && ( {showReschedule && rescheduleAppointment && (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<RescheduleFlow <RescheduleFlow
appointment={rescheduleAppointment as any} appointment={rescheduleAppointment as any}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }} onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react"; import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
import { PetForm } from "./PetForm.js"; import { PetForm } from "./PetForm.js";
import { authClient } from "../../lib/auth-client.js";
interface Props { interface Props {
sessionId: string | null; sessionId: string | null;
@@ -149,11 +148,9 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const passwordsMatch = newPassword === confirmPassword; const passwordsMatch = newPassword === confirmPassword;
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading; const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
if (readOnly) { if (readOnly) {
return ( return (
@@ -163,34 +160,17 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
); );
} }
async function handleSubmit() { function handleSubmit() {
if (!canSubmit) return; if (!canSubmit) return;
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError("Passwords do not match."); setError("Passwords do not match.");
return; return;
} }
// TODO: Wire up to actual password-change API endpoint once backend support exists
setError(null); setError(null);
setLoading(true); setCurrentPassword("");
try { setNewPassword("");
// eslint-disable-next-line @typescript-eslint/no-explicit-any setConfirmPassword("");
const result = await (authClient as any).changePassword({
currentPassword,
newPassword,
});
if (result.error) {
setError(result.error.message ?? "Failed to change password.");
} else {
setSuccess(true);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setTimeout(() => setSuccess(false), 4000);
}
} catch {
setError("An unexpected error occurred.");
} finally {
setLoading(false);
}
} }
return ( return (
@@ -225,13 +205,12 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} disabled={!canSubmit}
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? "Updating..." : "Update Password"} Update Password
</button> </button>
</div> </div>
</div> </div>
+94 -183
View File
@@ -1,6 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { loadStripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { CreditCard, DollarSign, Package, Zap } from "lucide-react"; import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
interface Invoice { interface Invoice {
@@ -12,28 +10,31 @@ interface Invoice {
} }
interface PaymentMethod { interface PaymentMethod {
id: string;
brand: string; brand: string;
last4: string; last4: string;
expiryMonth: number; expiryMonth: number;
expiryYear: number; expiryYear: number;
} }
interface Package {
name: string;
remaining: number;
}
interface BillingPaymentsProps { interface BillingPaymentsProps {
sessionId: string | null; sessionId: string | null;
readOnly: boolean; readOnly: boolean;
} }
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) { export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
const [invoices, setInvoices] = useState<Invoice[]>([]); const [invoices, setInvoices] = useState<Invoice[]>([]);
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]); const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [packages] = useState<{ name: string; remaining: number }[]>([]); const [packages, setPackages] = useState<Package[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices"); const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
const [autopay, setAutopay] = useState(false); const [autopay, setAutopay] = useState(false);
const [showPaymentModal, setShowPaymentModal] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false);
const [publishableKey, setPublishableKey] = useState<string>("");
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
@@ -43,37 +44,20 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
} }
try { try {
const [configRes, invoicesRes, methodsRes] = await Promise.all([ const response = await fetch("/api/portal/invoices", {
fetch("/api/portal/config", { headers: {
headers: { "X-Impersonation-Session-Id": sessionId }, "X-Impersonation-Session-Id": sessionId,
}), },
fetch("/api/portal/invoices", { });
headers: { "X-Impersonation-Session-Id": sessionId },
}),
fetch("/api/portal/payment-methods", {
headers: { "X-Impersonation-Session-Id": sessionId },
}),
]);
if (!configRes.ok) throw new Error("Failed to fetch config"); if (!response.ok) {
const configData = await configRes.json(); throw new Error("Failed to fetch invoices");
setPublishableKey(configData.stripePublishableKey ?? "");
const invoicesData = await invoicesRes.json();
setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []);
if (methodsRes.ok) {
const methodsData = await methodsRes.json();
setPaymentMethods(
(methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({
id: m.id,
brand: m.card?.brand ?? "unknown",
last4: m.card?.last4 ?? "****",
expiryMonth: m.card?.exp_month ?? 0,
expiryYear: m.card?.exp_year ?? 0,
}))
);
} }
const data = await response.json();
setInvoices(Array.isArray(data) ? data : data.invoices || []);
setPaymentMethods(data.paymentMethods || []);
setPackages(data.packages || []);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "An error occurred"); setError(err instanceof Error ? err.message : "An error occurred");
} finally { } finally {
@@ -84,8 +68,12 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
fetchData(); fetchData();
}, [sessionId]); }, [sessionId]);
const formatCents = (cents: number) => const formatCents = (cents: number) => {
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100);
};
const pending = invoices.filter((i) => i.status === "pending"); const pending = invoices.filter((i) => i.status === "pending");
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0); const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
@@ -94,9 +82,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="animate-pulse space-y-4"> <div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/3" /> <div className="h-6 bg-gray-200 rounded w-1/3"></div>
<div className="h-24 bg-gray-200 rounded" /> <div className="h-24 bg-gray-200 rounded"></div>
<div className="h-24 bg-gray-200 rounded" /> <div className="h-24 bg-gray-200 rounded"></div>
</div> </div>
</div> </div>
); );
@@ -112,6 +100,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Outstanding Balance Banner */}
{totalPending > 0 && ( {totalPending > 0 && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> <div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div> <div>
@@ -121,15 +110,16 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""} {pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
</p> </p>
</div> </div>
<button <button
onClick={() => setShowPaymentModal(true)} onClick={() => setShowPaymentModal(true)}
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)" className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
> >
Pay Now Pay Now
</button> </button>
</div> </div>
)} )}
{/* Tabs */}
<div className="flex gap-2"> <div className="flex gap-2">
{([ {([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign }, { id: "invoices" as const, label: "Invoices", icon: DollarSign },
@@ -151,6 +141,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
))} ))}
</div> </div>
{/* Invoices */}
{tab === "invoices" && ( {tab === "invoices" && (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden"> <div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -161,7 +152,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
<th className="px-5 py-3 font-medium">Description</th> <th className="px-5 py-3 font-medium">Description</th>
<th className="px-5 py-3 font-medium">Amount</th> <th className="px-5 py-3 font-medium">Amount</th>
<th className="px-5 py-3 font-medium">Status</th> <th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium" /> <th className="px-5 py-3 font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -169,7 +160,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50"> <tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
<td className="px-5 py-3 text-stone-700"> <td className="px-5 py-3 text-stone-700">
{new Date(inv.date).toLocaleDateString("en-US", { {new Date(inv.date).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric", month: "short",
day: "numeric",
year: "numeric",
})} })}
</td> </td>
<td className="px-5 py-3 text-stone-600"> <td className="px-5 py-3 text-stone-600">
@@ -208,6 +201,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
{/* Payment Methods */}
{tab === "payment" && ( {tab === "payment" && (
<div className="space-y-4"> <div className="space-y-4">
{paymentMethods.length === 0 ? ( {paymentMethods.length === 0 ? (
@@ -216,7 +210,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
<div className="space-y-3"> <div className="space-y-3">
{paymentMethods.map((method) => ( {paymentMethods.map((method) => (
<div <div
key={method.id} key={`${method.brand}-${method.last4}`}
className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white" className="flex items-center justify-between p-4 border border-stone-200 rounded-lg bg-white"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -229,18 +223,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</span> </span>
</div> </div>
{!readOnly && ( {!readOnly && (
<button <button className="text-sm text-blue-600 hover:underline">
onClick={async () => {
const res = await fetch(`/api/portal/payment-methods/${method.id}`, {
method: "DELETE",
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
});
if (res.ok) {
setPaymentMethods((prev) => prev.filter((m) => m.id !== method.id));
}
}}
className="text-sm text-blue-600 hover:underline"
>
Remove Remove
</button> </button>
)} )}
@@ -249,6 +232,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
{/* Autopay */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"> <div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -257,7 +241,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
<div> <div>
<p className="text-sm font-medium text-stone-800">Autopay</p> <p className="text-sm font-medium text-stone-800">Autopay</p>
<p className="text-xs text-stone-500">Automatically charge after each appointment</p> <p className="text-xs text-stone-500">
Automatically charge after each appointment
</p>
</div> </div>
</div> </div>
{!readOnly ? ( {!readOnly ? (
@@ -283,13 +269,17 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
{/* Packages */}
{tab === "packages" && ( {tab === "packages" && (
<div className="space-y-4"> <div className="space-y-4">
{packages.length === 0 ? ( {packages.length === 0 ? (
<p className="text-gray-500 italic">No packages purchased</p> <p className="text-gray-500 italic">No packages purchased</p>
) : ( ) : (
packages.map((pkg, index) => ( packages.map((pkg, index) => (
<div key={index} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"> <div
key={index}
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-stone-800">{pkg.name}</span> <span className="font-medium text-stone-800">{pkg.name}</span>
<span className="text-stone-600">{pkg.remaining} remaining</span> <span className="text-stone-600">{pkg.remaining} remaining</span>
@@ -300,124 +290,60 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
{showPaymentModal && publishableKey && ( {/* Payment Modal */}
<PaymentModalWrapper {showPaymentModal && (
key={Date.now()} <PaymentModal
sessionId={sessionId ?? ""}
publishableKey={publishableKey}
pending={pending} pending={pending}
totalPending={totalPending}
onClose={() => setShowPaymentModal(false)} onClose={() => setShowPaymentModal(false)}
onSuccess={() => {
setInvoices((prev) =>
prev.map((inv) =>
pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv
)
);
setShowPaymentModal(false);
}}
/> />
)} )}
</div> </div>
); );
} }
interface PaymentModalWrapperProps { function PaymentModal({
sessionId: string; pending,
publishableKey: string; totalPending: _totalPending,
onClose,
}: {
pending: Invoice[]; pending: Invoice[];
totalPending: number;
onClose: () => void; onClose: () => void;
onSuccess: () => void; }) {
} const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
new Set(pending.map((i) => i.id))
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
const [stripePromise] = useState(() =>
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
); );
return (
<Elements stripe={stripePromise} options={{ mode: "payment", amount: pending.reduce((s, i) => s + i.totalCents, 0), currency: "usd" }}>
<PaymentModal sessionId={sessionId} pending={pending} onClose={onClose} onSuccess={onSuccess} />
</Elements>
);
}
interface PaymentModalProps {
sessionId: string;
pending: Invoice[];
onClose: () => void;
onSuccess: () => void;
}
function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) {
const stripe = useStripe();
const elements = useElements();
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(pending.map((i) => i.id)));
const [saveCard, setSaveCard] = useState(false);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [isComplete, setIsComplete] = useState(false); const [isComplete, setIsComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const formatCents = (cents: number) => const formatCents = (cents: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100); new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100);
const toggleInvoice = (id: string) => { const toggleInvoice = (id: string) => {
const next = new Set(selectedInvoices); const next = new Set(selectedInvoices);
if (next.has(id)) next.delete(id); if (next.has(id)) {
else next.add(id); next.delete(id);
} else {
next.add(id);
}
setSelectedInvoices(next); setSelectedInvoices(next);
}; };
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
const handlePay = async () => { const handlePay = async () => {
if (!stripe || !elements) return;
setIsProcessing(true); setIsProcessing(true);
setError(null); await new Promise((resolve) => setTimeout(resolve, 1500));
setIsProcessing(false);
try { setIsComplete(true);
const isMulti = selectedInvoices.size > 1;
const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`;
const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {};
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Impersonation-Session-Id": sessionId,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Failed to initialize payment");
}
const { clientSecret } = await res.json();
const { error: stripeError } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: saveCard
? { setup_future_usage: "off_session" }
: undefined,
redirect: "if_required",
});
if (stripeError) {
setError(stripeError.message ?? "Payment failed");
setIsProcessing(false);
return;
}
setIsComplete(true);
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : "An unexpected error occurred");
setIsProcessing(false);
}
}; };
const selectedTotal = pending
.filter((i) => selectedInvoices.has(i.id))
.reduce((sum, i) => sum + i.totalCents, 0);
if (isComplete) { if (isComplete) {
return ( return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
@@ -431,7 +357,10 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
<p className="text-stone-500 text-sm mb-6"> <p className="text-stone-500 text-sm mb-6">
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email. Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
</p> </p>
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"> <button
onClick={onClose}
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
>
Done Done
</button> </button>
</div> </div>
@@ -479,36 +408,22 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
</p> </p>
</div> </div>
</div> </div>
<span className="text-sm font-medium text-stone-800">{formatCents(inv.totalCents)}</span> <span className="text-sm font-medium text-stone-800">
{formatCents(inv.totalCents)}
</span>
</label> </label>
))} ))}
</div> </div>
<div className="border-t border-stone-200 pt-4 mb-6"> <div className="border-t border-stone-200 pt-4 mb-6">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center">
<span className="text-sm text-stone-600">Total</span> <span className="text-sm text-stone-600">Total</span>
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span> <span className="text-lg font-bold text-stone-800">
{formatCents(selectedTotal)}
</span>
</div> </div>
<PaymentElement />
</div> </div>
<label className="flex items-center gap-2 mb-4">
<input
type="checkbox"
checked={saveCard}
onChange={(e) => setSaveCard(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
/>
<span className="text-sm text-stone-600">Save card for future payments</span>
</label>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={onClose} onClick={onClose}
@@ -518,7 +433,7 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
</button> </button>
<button <button
onClick={handlePay} onClick={handlePay}
disabled={selectedInvoices.size === 0 || isProcessing || !stripe} disabled={selectedInvoices.size === 0 || isProcessing}
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isProcessing ? "Processing..." : "Pay Now"} {isProcessing ? "Processing..." : "Pay Now"}
@@ -529,8 +444,4 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
); );
} }
export function BillingPayments(props: BillingPaymentsProps) {
return <BillingPaymentsInner {...props} />;
}
export default BillingPayments; export default BillingPayments;
+2 -2
View File
@@ -41,11 +41,11 @@ export default defineConfig({
workbox: { workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [ navigateFallbackDenylist: [
/^\/api\/auth\//, /^\/api\/auth\/oauth2\/callback\//,
], ],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: /^http.*\/api\/(?!auth\/).*/i, urlPattern: /^http.*\/api\/.*/i,
handler: "NetworkFirst", handler: "NetworkFirst",
options: { options: {
cacheName: "api-cache", cacheName: "api-cache",
+1 -1
Submodule infra updated: b667a3f005...49575eb4f6
@@ -1,6 +0,0 @@
-- Better-Auth rate limiting table (GRO-574)
CREATE TABLE "rate_limit" (
key TEXT NOT NULL PRIMARY KEY,
count INTEGER NOT NULL,
last_request BIGINT NOT NULL
);
@@ -1,6 +0,0 @@
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
-11
View File
@@ -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");
@@ -1,15 +0,0 @@
-- SMS opt-in fields for clients (idempotent)
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp;
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text;
-- Add channel column to reminder_logs with default 'email' (idempotent)
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email';
-- Drop old unique constraints if they exist (idempotent)
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key";
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
-- Add new unique constraint with channel
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
@@ -1,20 +0,0 @@
-- Migration: 0029_db_indexes_constraints.sql
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
-- Backfill NULL emails before setting NOT NULL
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
-- Add indexes on appointments table
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
CREATE INDEX idx_appointments_status ON appointments(status);
-- Add index on pets table
CREATE INDEX idx_pets_client_id ON pets(client_id);
-- Add index on clients table
CREATE INDEX idx_clients_email ON clients(email);
-- Set NOT NULL on clients.email (after backfill)
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
@@ -1,103 +0,0 @@
{
"id": "0026_stripe_payment",
"version": "7",
"dialect": "postgresql",
"tables": {
"authProviderConfig": {
"name": "auth_provider_config",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"businessSettings": {
"name": "business_settings",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
},
"clients": {
"name": "clients",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"name": { "name": "name", "type": "text", "isNullable": false },
"email": { "name": "email", "type": "text", "isNullable": true },
"phone": { "name": "phone", "type": "text", "isNullable": true },
"address": { "name": "address", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
},
"invoices": {
"name": "invoices",
"columns": {
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
"notes": { "name": "notes", "type": "text", "isNullable": true },
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
},
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
"compositePrimaryKeys": {},
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
}
},
"enums": {
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
},
"nativeEnums": {}
}
-28
View File
@@ -176,34 +176,6 @@
"when": 1775396067192, "when": 1775396067192,
"tag": "0024_invoice_indexes", "tag": "0024_invoice_indexes",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1775482467192,
"tag": "0025_rate_limit",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1775568867192,
"tag": "0026_stripe_payment",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1775655267192,
"tag": "0027_refunds",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1775741667192,
"tag": "0028_sms_reminders",
"breakpoints": true
} }
] ]
} }
-5
View File
@@ -71,11 +71,6 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
address: "1 Main St, Springfield, CA 90000", address: "1 Main St, Springfield, CA 90000",
notes: null, notes: null,
emailOptOut: false, emailOptOut: false,
smsOptIn: false,
smsConsentDate: null,
smsOptOutDate: null,
smsConsentText: null,
stripeCustomerId: null,
status: "active", status: "active",
disabledAt: null, disabledAt: null,
createdAt: new Date("2025-01-01T00:00:00Z"), createdAt: new Date("2025-01-01T00:00:00Z"),
+37 -75
View File
@@ -102,55 +102,43 @@ export const verification = pgTable("verification", {
// ─── Tables ─────────────────────────────────────────────────────────────────── // ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable( export const clients = pgTable("clients", {
"clients", id: uuid("id").primaryKey().defaultRandom(),
{ name: text("name").notNull(),
id: uuid("id").primaryKey().defaultRandom(), email: text("email"),
name: text("name").notNull(), phone: text("phone"),
email: text("email").notNull(), address: text("address"),
phone: text("phone"), notes: text("notes"),
address: text("address"), // Set to true if the client has opted out of email reminders/notifications
notes: text("notes"), emailOptOut: boolean("email_opt_out").notNull().default(false),
emailOptOut: boolean("email_opt_out").notNull().default(false), status: clientStatusEnum("status").notNull().default("active"),
smsOptIn: boolean("sms_opt_in").notNull().default(false), disabledAt: timestamp("disabled_at"),
smsConsentDate: timestamp("sms_consent_date"), createdAt: timestamp("created_at").notNull().defaultNow(),
smsOptOutDate: timestamp("sms_opt_out_date"), updatedAt: timestamp("updated_at").notNull().defaultNow(),
smsConsentText: text("sms_consent_text"), });
stripeCustomerId: text("stripe_customer_id"),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_clients_email").on(t.email)]
);
export const pets = pgTable( export const pets = pgTable("pets", {
"pets", id: uuid("id").primaryKey().defaultRandom(),
{ clientId: uuid("client_id")
id: uuid("id").primaryKey().defaultRandom(), .notNull()
clientId: uuid("client_id") .references(() => clients.id, { onDelete: "cascade" }),
.notNull() name: text("name").notNull(),
.references(() => clients.id, { onDelete: "cascade" }), species: text("species").notNull(),
name: text("name").notNull(), breed: text("breed"),
species: text("species").notNull(), weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
breed: text("breed"), dateOfBirth: timestamp("date_of_birth"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }), healthAlerts: text("health_alerts"),
dateOfBirth: timestamp("date_of_birth"), groomingNotes: text("grooming_notes"),
healthAlerts: text("health_alerts"), cutStyle: text("cut_style"),
groomingNotes: text("grooming_notes"), shampooPreference: text("shampoo_preference"),
cutStyle: text("cut_style"), specialCareNotes: text("special_care_notes"),
shampooPreference: text("shampoo_preference"), customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
specialCareNotes: text("special_care_notes"), photoKey: text("photo_key"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}), photoUploadedAt: timestamp("photo_uploaded_at"),
photoKey: text("photo_key"), image: text("image"),
photoUploadedAt: timestamp("photo_uploaded_at"), createdAt: timestamp("created_at").notNull().defaultNow(),
image: text("image"), updatedAt: timestamp("updated_at").notNull().defaultNow(),
createdAt: timestamp("created_at").notNull().defaultNow(), });
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_pets_client_id").on(t.clientId)]
);
export const services = pgTable("services", { export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
@@ -263,9 +251,6 @@ export const invoices = pgTable(
status: invoiceStatusEnum("status").notNull().default("draft"), status: invoiceStatusEnum("status").notNull().default("draft"),
paymentMethod: paymentMethodEnum("payment_method"), paymentMethod: paymentMethodEnum("payment_method"),
paidAt: timestamp("paid_at"), paidAt: timestamp("paid_at"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
stripeRefundId: text("stripe_refund_id"),
paymentFailureReason: text("payment_failure_reason"),
notes: text("notes"), notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
@@ -274,7 +259,6 @@ export const invoices = pgTable(
index("idx_invoices_client_id").on(t.clientId), index("idx_invoices_client_id").on(t.clientId),
index("idx_invoices_status").on(t.status), index("idx_invoices_status").on(t.status),
index("idx_invoices_created_at").on(t.createdAt), index("idx_invoices_created_at").on(t.createdAt),
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
] ]
); );
@@ -312,28 +296,8 @@ 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"
// channel values: "email", "sms"
export const reminderLogs = pgTable( export const reminderLogs = pgTable(
"reminder_logs", "reminder_logs",
{ {
@@ -343,11 +307,9 @@ export const reminderLogs = pgTable(
.references(() => appointments.id, { onDelete: "cascade" }), .references(() => appointments.id, { onDelete: "cascade" }),
// "confirmation" | "24h" | "2h" // "confirmation" | "24h" | "2h"
reminderType: text("reminder_type").notNull(), reminderType: text("reminder_type").notNull(),
// "email" | "sms"
channel: text("channel").notNull().default("email"),
sentAt: timestamp("sent_at").notNull().defaultNow(), sentAt: timestamp("sent_at").notNull().defaultNow(),
}, },
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)] (t) => [unique().on(t.appointmentId, t.reminderType)]
); );
// ─── Impersonation ────────────────────────────────────────────────────────── // ─── Impersonation ──────────────────────────────────────────────────────────
+1 -63
View File
@@ -398,8 +398,6 @@ async function seedKnownUsers() {
id: ADMIN_STAFF_ID, id: ADMIN_STAFF_ID,
name: adminName, name: adminName,
email: adminEmail, email: adminEmail,
oidcSub: adminEmail,
userId: adminEmail,
role: "manager", role: "manager",
isSuperUser: true, isSuperUser: true,
active: true, active: true,
@@ -426,7 +424,6 @@ async function seedKnownUsers() {
name: "UAT Super User", name: "UAT Super User",
email: "uat-super@groombook.dev", email: "uat-super@groombook.dev",
oidcSub: uatSuperOidcSub, oidcSub: uatSuperOidcSub,
userId: uatSuperOidcSub,
role: "manager", role: "manager",
isSuperUser: true, isSuperUser: true,
active: true, active: true,
@@ -453,7 +450,6 @@ async function seedKnownUsers() {
name: "UAT Staff Groomer", name: "UAT Staff Groomer",
email: "uat-groomer@groombook.dev", email: "uat-groomer@groombook.dev",
oidcSub: uatStaffOidcSub, oidcSub: uatStaffOidcSub,
userId: uatStaffOidcSub,
role: "groomer", role: "groomer",
isSuperUser: false, isSuperUser: false,
active: true, active: true,
@@ -462,37 +458,6 @@ async function seedKnownUsers() {
} }
} }
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
for (let i = 0; i < groomerCount; i++) {
const email = groomerEmails[i]!;
const name = groomerNames[i]!;
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
const [existingGroomer] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, email))
.limit(1);
if (existingGroomer) {
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: staffId,
name,
email,
oidcSub: email,
role: "groomer",
isSuperUser: false,
active: true,
});
console.log(`✓ Created staff groomer '${name}' (${email})`);
}
}
// ── Services: idempotent upsert using name as unique key ───────────────────── // ── Services: idempotent upsert using name as unique key ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first. // UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services. // Uses b0000001-... IDs to match main seed servicesDef for same-named services.
@@ -602,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 })
@@ -647,8 +612,6 @@ async function seed() {
id: ADMIN_STAFF_ID, id: ADMIN_STAFF_ID,
name: adminName, name: adminName,
email: adminEmail, email: adminEmail,
oidcSub: adminEmail,
userId: adminEmail,
role: "manager", role: "manager",
isSuperUser: true, isSuperUser: true,
active: true, active: true,
@@ -660,31 +623,6 @@ async function seed() {
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`); console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
} }
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
for (let i = 0; i < groomerCount; i++) {
const email = groomerEmails[i]!;
const name = groomerNames[i]!;
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
await db.insert(schema.staff)
.values({
id: staffId,
name,
email,
oidcSub: email,
role: "groomer",
isSuperUser: false,
active: true,
})
.onConflictDoUpdate({
target: schema.staff.email,
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
});
console.log(`✓ Upserted groomer '${name}' (${email})`);
}
// ── Services ── // ── Services ──
// Upsert services using name as unique key. With deterministic IDs in // Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is // servicesDef and TRUNCATE clearing downstream tables first, this is
+54 -114
View File
@@ -40,12 +40,6 @@ importers:
nodemailer: nodemailer:
specifier: ^6.9.16 specifier: ^6.9.16
version: 6.10.1 version: 6.10.1
stripe:
specifier: ^22.0.0
version: 22.0.1(@types/node@22.19.15)
telnyx:
specifier: ^1.23.0
version: 1.27.0
zod: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6
@@ -89,17 +83,11 @@ importers:
'@groombook/types': '@groombook/types':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/types version: link:../../packages/types
'@stripe/react-stripe-js':
specifier: ^6.1.0
version: 6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@stripe/stripe-js':
specifier: ^9.1.0
version: 9.1.0
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
better-auth: better-auth:
specifier: ^1.5.6 specifier: ^1.0.0
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
lucide-react: lucide-react:
specifier: ^0.577.0 specifier: ^0.577.0
@@ -180,7 +168,7 @@ importers:
version: 22.19.15 version: 22.19.15
drizzle-kit: drizzle-kit:
specifier: ^0.30.4 specifier: ^0.30.4
version: 0.30.4 version: 0.30.6
tsx: tsx:
specifier: ^4.19.0 specifier: ^4.19.0
version: 4.21.0 version: 4.21.0
@@ -1699,6 +1687,9 @@ packages:
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -2118,17 +2109,6 @@ packages:
'@standard-schema/utils@0.3.0': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@stripe/react-stripe-js@6.1.0':
resolution: {integrity: sha512-LbKbRv4+wUSHLb5VNxqiYcKaqXPvTju0bJaF0RrzH0h4+aKWDXAk4RzUBcpNxxj8KtjuxICElANs1Li7aTv1IQ==}
peerDependencies:
'@stripe/stripe-js': '>=9.0.0 <10.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@stripe/stripe-js@9.1.0':
resolution: {integrity: sha512-v51LoEfZNiNS/5DcarWPCYgn24w4dqwwALR4GTbMW/N0DDzzj4DgYNoixX6PYvpt6uIJMucGUabn/BHhylggIQ==}
engines: {node: '>=12.16'}
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -2830,8 +2810,8 @@ packages:
dom-accessibility-api@0.6.3: dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
drizzle-kit@0.30.4: drizzle-kit@0.30.6:
resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==} resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
hasBin: true hasBin: true
drizzle-orm@0.38.4: drizzle-orm@0.38.4:
@@ -2955,6 +2935,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
env-paths@3.0.0:
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
es-abstract@1.24.1: es-abstract@1.24.1:
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3158,6 +3142,11 @@ packages:
functions-have-names@1.2.3: functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
gel@2.2.0:
resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==}
engines: {node: '>= 18.0.0'}
hasBin: true
generator-function@2.0.1: generator-function@2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3425,6 +3414,10 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isexe@3.1.5:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
istanbul-lib-coverage@3.2.2: istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3606,9 +3599,6 @@ packages:
lodash.debounce@4.0.8: lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -3618,10 +3608,6 @@ packages:
lodash@4.17.23: lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.2.1: loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -3713,10 +3699,6 @@ packages:
nwsapi@2.2.23: nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4: object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3834,17 +3816,10 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
randombytes@2.1.0: randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -3853,9 +3828,6 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.4 react: ^19.2.4
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2: react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -4040,6 +4012,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'} engines: {node: '>=8'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
side-channel-list@1.0.0: side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4148,15 +4124,6 @@ packages:
strip-literal@3.1.0: strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
stripe@22.0.1:
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
strnum@2.2.1: strnum@2.2.1:
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==} resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
@@ -4178,10 +4145,6 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} engines: {node: '>=6'}
telnyx@1.27.0:
resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==}
engines: {node: ^6 || >=8}
temp-dir@2.0.0: temp-dir@2.0.0:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4256,9 +4219,6 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -4348,10 +4308,6 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
victory-vendor@37.3.6: victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
@@ -4488,6 +4444,11 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
which@4.0.0:
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
engines: {node: ^16.13.0 || >=18.0.0}
hasBin: true
why-is-node-running@2.3.0: why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -6219,6 +6180,8 @@ snapshots:
'@opentelemetry/semantic-conventions@1.40.0': {} '@opentelemetry/semantic-conventions@1.40.0': {}
'@petamoriken/float16@3.9.3': {}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -6708,15 +6671,6 @@ snapshots:
'@standard-schema/utils@0.3.0': {} '@standard-schema/utils@0.3.0': {}
'@stripe/react-stripe-js@6.1.0(@stripe/stripe-js@9.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@stripe/stripe-js': 9.1.0
prop-types: 15.8.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@stripe/stripe-js@9.1.0': {}
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies: dependencies:
ejs: 3.1.10 ejs: 3.1.10
@@ -7414,12 +7368,13 @@ snapshots:
dom-accessibility-api@0.6.3: {} dom-accessibility-api@0.6.3: {}
drizzle-kit@0.30.4: drizzle-kit@0.30.6:
dependencies: dependencies:
'@drizzle-team/brocli': 0.10.2 '@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5 '@esbuild-kit/esm-loader': 2.6.5
esbuild: 0.19.12 esbuild: 0.19.12
esbuild-register: 3.6.0(esbuild@0.19.12) esbuild-register: 3.6.0(esbuild@0.19.12)
gel: 2.2.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -7456,6 +7411,8 @@ snapshots:
entities@6.0.1: {} entities@6.0.1: {}
env-paths@3.0.0: {}
es-abstract@1.24.1: es-abstract@1.24.1:
dependencies: dependencies:
array-buffer-byte-length: 1.0.2 array-buffer-byte-length: 1.0.2
@@ -7817,6 +7774,17 @@ snapshots:
functions-have-names@1.2.3: {} functions-have-names@1.2.3: {}
gel@2.2.0:
dependencies:
'@petamoriken/float16': 3.9.3
debug: 4.4.3
env-paths: 3.0.0
semver: 7.7.4
shell-quote: 1.8.3
which: 4.0.0
transitivePeerDependencies:
- supports-color
generator-function@2.0.1: {} generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
@@ -8081,6 +8049,8 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
isexe@3.1.5: {}
istanbul-lib-coverage@3.2.2: {} istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1: istanbul-lib-report@3.0.1:
@@ -8249,18 +8219,12 @@ snapshots:
lodash.debounce@4.0.8: {} lodash.debounce@4.0.8: {}
lodash.isplainobject@4.0.6: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash.sortby@4.7.0: {} lodash.sortby@4.7.0: {}
lodash@4.17.23: {} lodash@4.17.23: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {} loupe@3.2.1: {}
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
@@ -8335,8 +8299,6 @@ snapshots:
nwsapi@2.2.23: {} nwsapi@2.2.23: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
object-keys@1.1.1: {} object-keys@1.1.1: {}
@@ -8441,18 +8403,8 @@ snapshots:
ansi-styles: 5.2.0 ansi-styles: 5.2.0
react-is: 17.0.2 react-is: 17.0.2
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
punycode@2.3.1: {} punycode@2.3.1: {}
qs@6.15.1:
dependencies:
side-channel: 1.1.0
randombytes@2.1.0: randombytes@2.1.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -8462,8 +8414,6 @@ snapshots:
react: 19.2.4 react: 19.2.4
scheduler: 0.27.0 scheduler: 0.27.0
react-is@16.13.1: {}
react-is@17.0.2: {} react-is@17.0.2: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
@@ -8687,6 +8637,8 @@ snapshots:
shebang-regex@3.0.0: {} shebang-regex@3.0.0: {}
shell-quote@1.8.3: {}
side-channel-list@1.0.0: side-channel-list@1.0.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -8822,10 +8774,6 @@ snapshots:
dependencies: dependencies:
js-tokens: 9.0.1 js-tokens: 9.0.1
stripe@22.0.1(@types/node@22.19.15):
optionalDependencies:
'@types/node': 22.19.15
strnum@2.2.1: {} strnum@2.2.1: {}
supports-color@7.2.0: supports-color@7.2.0:
@@ -8840,14 +8788,6 @@ snapshots:
tapable@2.3.0: {} tapable@2.3.0: {}
telnyx@1.27.0:
dependencies:
lodash.isplainobject: 4.0.6
qs: 6.15.1
safe-buffer: 5.2.1
tweetnacl: 1.0.3
uuid: 9.0.1
temp-dir@2.0.0: {} temp-dir@2.0.0: {}
tempy@0.6.0: tempy@0.6.0:
@@ -8918,8 +8858,6 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
tweetnacl@1.0.3: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -9016,8 +8954,6 @@ snapshots:
uuid@8.3.2: {} uuid@8.3.2: {}
uuid@9.0.1: {}
victory-vendor@37.3.6: victory-vendor@37.3.6:
dependencies: dependencies:
'@types/d3-array': 3.2.2 '@types/d3-array': 3.2.2
@@ -9195,6 +9131,10 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
which@4.0.0:
dependencies:
isexe: 3.1.5
why-is-node-running@2.3.0: why-is-node-running@2.3.0:
dependencies: dependencies:
siginfo: 2.0.0 siginfo: 2.0.0