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
55 changed files with 543 additions and 2551 deletions
-2
View File
@@ -7,5 +7,3 @@ apps/web/dist
apps/api/dist
packages/db/dist
packages/types/dist
.turbo
screenshots/
-6
View File
@@ -11,12 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com
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 ───────────────────────────────────────────────────────────────────────
PORT=3000
CORS_ORIGIN=http://localhost:8080
+9 -24
View File
@@ -20,8 +20,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -44,8 +42,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -66,8 +62,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -107,8 +101,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -246,6 +238,7 @@ jobs:
echo "Deploying images tagged $TAG to groombook-dev..."
# Run migration with PR image
kubectl delete job migrate-schema -n groombook-dev --ignore-not-found
kubectl delete job "migrate-pr-$PR_NUM" -n groombook-dev --ignore-not-found
cat <<EOF | kubectl apply -n groombook-dev -f -
apiVersion: batch/v1
@@ -310,8 +303,6 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: '9.15.4'
- uses: actions/setup-node@v4
with:
@@ -418,17 +409,11 @@ jobs:
git push -u origin "chore/update-image-tags-${TAG}"
# Check if PR already exists for this branch
EXISTING_PR=$(gh pr list --repo groombook/infra --head "chore/update-image-tags-${TAG}" --state open --json number -q '.[0].number' || true)
if [ -n "$EXISTING_PR" ]; then
echo "PR #$EXISTING_PR already exists for this tag, merging existing PR"
gh pr merge "$EXISTING_PR" --repo groombook/infra --merge
else
PR_URL=$(gh pr create \
--repo groombook/infra \
--base main \
--head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --merge
fi
# Create PR and merge immediately (no required checks on groombook/infra)
PR_URL=$(gh pr create \
--repo groombook/infra \
--base main \
--head "chore/update-image-tags-${TAG}" \
--title "chore: deploy ${TAG} to dev" \
--body "[GRO-178](/GRO/issues/GRO-178) — automated image tag update from main merge")
gh pr merge "$PR_URL" --merge
-22
View File
@@ -14,29 +14,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
steps:
- name: Validate tag format
run: |
TAG="${{ inputs.tag }}"
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
exit 1
fi
echo "Tag format valid: $TAG"
- name: Verify image exists in GHCR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ inputs.tag }}"
# Check that the API image exists — if API was pushed, web/migrate were too
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
exit 1
fi
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
- name: Generate infra repo token
id: infra-token
uses: tibdex/github-app-token@v2
+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.
-90
View File
@@ -1,90 +0,0 @@
# Contributing to GroomBook
## Branch Strategy
GroomBook uses a three-branch GitOps model:
| Branch | Environment | Purpose |
|--------|-------------|---------|
| `dev` | Development | Active development target — all feature/fix PRs target this branch |
| `uat` | UAT / Staging | Promoted from `dev` by the CTO for acceptance testing |
| `main` | Production | Promoted from `uat` by the CEO; triggers production deployment |
**Never open a PR directly to `uat` or `main`.** All work flows through `dev` first.
## Developer Workflow
1. **Branch from `dev`** — create a feature or fix branch:
```bash
git checkout dev
git pull origin dev
git checkout -b feat/my-feature
```
2. **Open a PR targeting `dev`** — include the issue identifier in the title and cc @cpfarhood:
```bash
gh pr create --base dev --title "feat: description (GRO-NNN)" \
--body $'Closes GRO-NNN\n\ncc @cpfarhood'
```
3. **Pipeline gates before merge to `dev`:**
- QA (Lint Roller) reviews first — code quality, test coverage, CI pass
- CTO (The Dogfather) reviews second — architecture and final approval
- Both must approve; 2 approving reviews required by branch protection
## Promotion Flow
### Dev → UAT
After merging to `dev`, the CTO opens a PR from `dev` → `uat`:
```bash
gh pr create --base uat --head dev \
--title "chore: promote dev to uat (YYYY.MM.DD)" \
--body $'Promoting dev to UAT for regression and security review.\n\ncc @cpfarhood'
```
Gates:
- Shedward Scissorhands runs regression/acceptance tests
- Barkley Trimsworth performs security review
- CTO approves and merges (1 approving review required)
### UAT → Main (Production)
After UAT passes, the CTO opens a PR from `uat` → `main` and assigns it to the CEO:
```bash
gh pr create --base main --head uat \
--title "chore: promote uat to main (YYYY.MM.DD)" \
--body $'Promoting UAT to production.\n\ncc @cpfarhood'
```
Gates:
- CEO (Scrubs McBarkley) reviews for business alignment and merges
- 1 approving review required; triggers auto-deploy to Production
## Branch Protection Summary
| Branch | Required Approvals | Who approves |
|--------|--------------------|-------------|
| `dev` | 2 | QA (Lint Roller) + CTO (The Dogfather) |
| `uat` | 1 | CTO (The Dogfather) |
| `main` | 1 | CEO (Scrubs McBarkley) |
Force-pushes and branch deletions are disabled on all three branches.
## Commit Style
Use [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` — new feature
- `fix:` — bug fix
- `chore:` — maintenance (dependency updates, build config, promotions)
- `docs:` — documentation only
- `ci:` — CI/CD changes
- `refactor:` — code restructure without behaviour change
Reference the Paperclip issue in the commit body: `Refs GRO-NNN`.
## Questions?
Open a Paperclip issue in the GRO project or ask in the team channel.
+1 -5
View File
@@ -12,7 +12,6 @@ RUN pnpm install --frozen-lockfile
# Build
FROM deps AS builder
RUN mkdir -p /home/node/.cache/node/corepack
COPY packages/ packages/
COPY apps/api/ apps/api/
RUN pnpm --filter @groombook/types build && \
@@ -35,9 +34,6 @@ COPY --from=builder /app/packages/types/dist packages/types/dist
RUN pnpm install --frozen-lockfile --prod
EXPOSE 3000
RUN apk add --no-cache curl
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "apps/api/dist/index.js"]
# Migrate stage — runs drizzle-kit migrate against the database
@@ -50,4 +46,4 @@ CMD ["pnpm", "db:seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset
CMD ["pnpm", "db:reset"]
CMD ["pnpm", "db:reset"]
-3
View File
@@ -22,9 +22,6 @@
"hono": "^4.6.17",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"telnyx": "^1.23.0",
"zod": "^4.3.6"
},
"devDependencies": {
+3 -17
View File
@@ -27,14 +27,12 @@ const DISABLED_CLIENT = {
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
let selectRows: Record<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
let deletedId: string | null = null;
function resetMock() {
selectRows = [];
appointmentRows = [];
insertedValues = [];
updatedValues = [];
deletedId = null;
@@ -60,19 +58,10 @@ vi.mock("@groombook/db", () => {
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
);
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: unknown) => {
const tableName = (table as { _name?: string })._name;
const rows = tableName === "appointments" ? appointmentRows : selectRows;
return makeChainable(rows);
},
from: () => makeChainable(selectRows),
}),
insert: () => ({
values: (vals: Record<string, unknown>) => {
@@ -106,10 +95,8 @@ vi.mock("@groombook/db", () => {
}),
}),
clients,
appointments,
eq: vi.fn(),
and: vi.fn(),
or: vi.fn(),
};
});
@@ -195,11 +182,10 @@ describe("POST /clients", () => {
expect(insertedValues[0]!.name).toBe("Charlie");
});
it("creates a client with name and email", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
it("creates a client with only required name field", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
expect(res.status).toBe(201);
expect(insertedValues[0]!.name).toBe("Dana");
expect(insertedValues[0]!.email).toBe("dana@example.com");
});
it("rejects empty name", async () => {
@@ -68,7 +68,6 @@ vi.mock("@groombook/db", () => {
}),
appointments,
eq: () => ({}),
and: (..._clauses: unknown[]) => ({}),
};
});
+2 -3
View File
@@ -78,7 +78,6 @@ vi.mock("@groombook/db", () => {
}),
staff,
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");
expect(res.status).toBe(403);
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 () => {
@@ -371,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
const res = await app.request("/test");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/role.*not permitted/i);
expect(body.error).toMatch(/super user privileges required/i);
});
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.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", () => {
+3 -43
View File
@@ -28,31 +28,15 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
const app = new Hono();
// 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(
"/api/*",
cors({
origin: (origin, ctx) => {
if (!origin) {
return ALLOWED_ORIGIN;
}
if (TRUSTED_ORIGINS.includes(origin)) {
return origin;
}
ctx.status(403);
return null;
},
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
credentials: true,
})
);
@@ -66,9 +50,6 @@ app.route("/api/book", bookRouter);
// Public portal routes — client-facing, authenticated via impersonation session header
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
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
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono();
authRouter.all("/*", (c) => {
try {
return getAuth().handler(c.req.raw);
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
});
authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
api.route("/auth", authRouter);
// ── Role guards ────────────────────────────────────────────────────────────────
@@ -202,24 +177,9 @@ api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000);
await initAuth();
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)
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;
+13 -85
View File
@@ -3,7 +3,6 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } 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_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");
authInstance = betterAuth({
database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET!,
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "memory",
},
plugins: [
genericOAuth({
config: [
@@ -177,59 +170,7 @@ export async function initAuth(): Promise<void> {
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 issuerUrlObj = new URL(providerConfig.issuerUrl);
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`);
}
const callbackBase = `${BETTER_AUTH_URL}/api/auth/callback`;
// Build Better-Auth instance using resolved config
authInstance = betterAuth({
@@ -238,28 +179,6 @@ export async function initAuth(): Promise<void> {
}),
secret: BETTER_AUTH_SECRET,
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: [
genericOAuth({
config: [
@@ -267,8 +186,15 @@ export async function initAuth(): Promise<void> {
providerId: providerConfig.providerId,
clientId: providerConfig.clientId,
clientSecret: providerConfig.clientSecret,
discoveryUrl: discoveryUrlStr,
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
...(providerConfig.internalBaseUrl
? {
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),
},
],
@@ -279,12 +205,14 @@ export async function initAuth(): Promise<void> {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectURI: `${callbackBase}/google`,
},
} : {}),
...(hasGitHub ? {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
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) => {
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
if (c.req.path.startsWith("/api/auth/")) {
await next();
return;
@@ -36,14 +37,7 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
return;
}
let auth;
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
const session = await getAuth().api.getSession({
headers: c.req.raw.headers,
});
+29 -27
View File
@@ -1,5 +1,5 @@
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 StaffRow = typeof staff.$inferSelect;
@@ -89,31 +89,33 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.select()
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
if (fallbackRow) {
c.set("staff", fallbackRow);
await next();
return;
}
// Auto-link by email: staff record exists with matching email but no userId
if (jwt.email) {
const [byEmail] = await db
.select()
.from(staff)
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
if (byEmail) {
await db
.update(staff)
.set({ userId: jwt.sub, updatedAt: new Date() })
.where(eq(staff.id, byEmail.id));
c.set("staff", { ...byEmail, userId: jwt.sub });
await next();
return;
if (!fallbackRow) {
// Auto-link: staff record exists with matching email but no userId — link it now
if (jwt.email) {
const [linkedStaff] = await db
.select()
.from(staff)
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
if (linkedStaff) {
await db
.update(staff)
.set({ userId: jwt.sub })
.where(eq(staff.id, linkedStaff.id));
console.log(
`[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
);
c.set("staff", linkedStaff);
await next();
return;
}
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
);
c.set("staff", fallbackRow);
await next();
};
/**
@@ -166,9 +168,9 @@ export function requireRoleOrSuperUser(
}
return c.json(
{
error: hasAllowedRole
? "Forbidden: super user privileges required"
: `Forbidden: role '${staffRow.role}' is not permitted`,
error: staffRow.isSuperUser
? `Forbidden: role '${staffRow.role}' is not permitted`
: "Forbidden: super user privileges required",
},
403
);
+1 -71
View File
@@ -16,9 +16,8 @@ import {
services,
staff,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono<AppEnv>();
export const appointmentGroupsRouter = new Hono();
// ─── Schemas ──────────────────────────────────────────────────────────────────
@@ -50,8 +49,6 @@ appointmentGroupsRouter.get("/", async (c) => {
const clientId = c.req.query("clientId");
const from = c.req.query("from");
const to = c.req.query("to");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const groupConditions = clientId
? [eq(appointmentGroups.clientId, clientId)]
@@ -91,16 +88,6 @@ appointmentGroupsRouter.get("/", async (c) => {
}))
.filter((g) => !from || g.appointments.length > 0);
if (isGroomer) {
return c.json(
result.filter((g) =>
g.appointments.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
)
);
}
return c.json(result);
});
@@ -109,8 +96,6 @@ appointmentGroupsRouter.get("/", async (c) => {
appointmentGroupsRouter.get("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select()
@@ -126,7 +111,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
serviceId: appointments.serviceId,
serviceName: services.name,
staffId: appointments.staffId,
batherStaffId: appointments.batherStaffId,
staffName: staff.name,
status: appointments.status,
startTime: appointments.startTime,
@@ -141,15 +125,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
.where(eq(appointments.groupId, id))
.orderBy(appointments.startTime);
if (
isGroomer &&
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
const [client] = await db
.select({ name: clients.name, email: clients.email })
.from(clients)
@@ -165,13 +140,6 @@ appointmentGroupsRouter.post(
zValidator("json", createGroupSchema),
async (c) => {
const db = getDb();
const staffRow = c.get("staff");
if (staffRow?.role === "groomer") {
return c.json(
{ error: "Forbidden: groomers cannot create group bookings" },
403
);
}
const body = c.req.valid("json");
const startTime = new Date(body.startTime);
@@ -276,28 +244,6 @@ appointmentGroupsRouter.patch(
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select({ id: appointmentGroups.id })
.from(appointmentGroups)
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
if (
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
}
const [updated] = await db
.update(appointmentGroups)
@@ -315,8 +261,6 @@ appointmentGroupsRouter.patch(
appointmentGroupsRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [group] = await db
.select({ id: appointmentGroups.id })
@@ -324,20 +268,6 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
.where(eq(appointmentGroups.id, id));
if (!group) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const groupAppts = await db
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
.from(appointments)
.where(eq(appointments.groupId, id));
if (
!groupAppts.some(
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
)
) {
return c.json({ error: "Forbidden" }, 403);
}
}
await db
.update(appointments)
.set({ status: "cancelled", updatedAt: new Date() })
+43 -249
View File
@@ -23,27 +23,6 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import type { AppEnv } from "../middleware/rbac.js";
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries: number,
delayMs: number,
context: string
): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await fn();
return;
} catch (err) {
lastError = err;
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
console.error(`[appointments] ${context}: ${lastError}`);
}
export const appointmentsRouter = new Hono<AppEnv>();
const createAppointmentSchema = z.object({
@@ -62,10 +41,6 @@ const createAppointmentSchema = z.object({
frequencyWeeks: z.number().int().min(1).max(52),
count: z.number().int().min(2).max(52),
})
.refine(
(r) => r.frequencyWeeks * r.count <= 52,
{ message: "Recurrence series must not exceed 1 year" }
)
.optional(),
});
@@ -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) {
// Single appointment
const [inserted] = await tx
@@ -233,54 +186,11 @@ appointmentsRouter.post(
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
let first: typeof appointments.$inferSelect | undefined;
const conflictingInstances: number[] = [];
for (let i = 0; i < recurrence.count; i++) {
const instanceStart = new Date(start.getTime() + i * intervalMs);
const instanceEnd = new Date(
instanceStart.getTime() + durationMs
);
if (apptFields.staffId) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, apptFields.staffId),
lt(appointments.startTime, instanceEnd),
gte(appointments.endTime, instanceStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (conflicts.length > 0) {
conflictingInstances.push(i);
}
}
if (apptFields.batherStaffId) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, apptFields.batherStaffId),
eq(appointments.batherStaffId, apptFields.batherStaffId)
),
lt(appointments.startTime, instanceEnd),
gte(appointments.endTime, instanceStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
)
)
.limit(1);
if (conflicts.length > 0) {
conflictingInstances.push(i);
}
}
const [inserted] = await tx
.insert(appointments)
.values({
@@ -291,19 +201,9 @@ appointmentsRouter.post(
seriesIndex: i,
})
.returning();
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
if (i === 0) first = inserted;
}
if (conflictingInstances.length > 0) {
throw Object.assign(
new Error(
`Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}`
),
{ statusCode: 409 }
);
}
if (!first) throw new Error("No appointments created");
return first;
});
@@ -321,12 +221,9 @@ appointmentsRouter.post(
}
// Send confirmation email (fire-and-forget — never fails the request)
withRetry(
() => sendConfirmationEmail(db, firstRow),
2,
1000,
`Failed to send confirmation email for appointment ${firstRow.id}`
);
sendConfirmationEmail(db, firstRow).catch((err) => {
console.error("[appointments] Failed to send confirmation email:", err);
});
return c.json(firstRow, 201);
}
@@ -338,35 +235,44 @@ async function sendConfirmationEmail(
db: ReturnType<typeof getDb>,
appt: typeof appointments.$inferSelect
): Promise<void> {
const [row] = await db
.select({
clientName: clients.name,
clientEmail: clients.email,
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))
const [client] = await db
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
if (!row) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
if (!client || !client.email || client.emailOptOut) return;
if (!clientEmail || clientEmailOptOut) return;
if (!petName || !serviceName) return;
const [pet] = await db
.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(
buildConfirmationEmail(clientEmail, {
clientName,
petName,
serviceName,
groomerName: groomerName ?? null,
buildConfirmationEmail(client.email, {
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
})
);
@@ -446,76 +352,6 @@ appointmentsRouter.patch(
let firstUpdated: typeof appointments.$inferSelect | undefined;
for (const appt of affected) {
const newStart =
startDeltaMs !== 0
? new Date(appt.startTime.getTime() + startDeltaMs)
: appt.startTime;
const newEnd =
endDeltaMs !== 0
? new Date(appt.endTime.getTime() + endDeltaMs)
: appt.endTime;
const newStaffId =
updateFields.staffId !== undefined
? updateFields.staffId
: appt.staffId;
const newBatherStaffId =
updateFields.batherStaffId !== undefined
? updateFields.batherStaffId
: appt.batherStaffId;
if (
newStaffId &&
(startDeltaMs !== 0 ||
endDeltaMs !== 0 ||
updateFields.staffId !== undefined)
) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, newStaffId),
lt(appointments.startTime, newEnd),
gte(appointments.endTime, newStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, appt.id),
)
)
.limit(1);
if (conflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
if (
newBatherStaffId &&
(startDeltaMs !== 0 ||
endDeltaMs !== 0 ||
updateFields.batherStaffId !== undefined)
) {
const conflicts = await tx
.select({ id: appointments.id })
.from(appointments)
.where(
and(
or(
eq(appointments.staffId, newBatherStaffId),
eq(appointments.batherStaffId, newBatherStaffId)
),
lt(appointments.startTime, newEnd),
gte(appointments.endTime, newStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, appt.id),
)
)
.limit(1);
if (conflicts.length > 0) {
throw Object.assign(new Error("conflict"), { statusCode: 409 });
}
}
const apptUpdate: Record<string, unknown> = {
updatedAt: new Date(),
};
@@ -551,13 +387,6 @@ appointmentsRouter.patch(
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
if (statusCode === 422)
return c.json({ error: "endTime must be after startTime" }, 422);
if (statusCode === 409)
return c.json(
{
error: "Staff member has a conflicting appointment at this time",
},
409
);
throw err;
}
@@ -569,8 +398,7 @@ appointmentsRouter.patch(
const needsConflictCheck =
updateFields.startTime !== undefined ||
updateFields.endTime !== undefined ||
updateFields.staffId !== undefined ||
updateFields.batherStaffId !== undefined;
updateFields.staffId !== undefined;
const update: Record<string, unknown> = {
...updateFields,
@@ -606,11 +434,6 @@ appointmentsRouter.patch(
updateFields.staffId !== undefined
? updateFields.staffId
: current.staffId;
// Use provided batherStaffId (may be null to unassign); fall back to existing
const batherStaffId =
updateFields.batherStaffId !== undefined
? updateFields.batherStaffId
: current.batherStaffId;
if (end <= start) {
throw Object.assign(new Error("end before start"), {
@@ -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
.update(appointments)
.set(update)
@@ -735,12 +535,9 @@ appointmentsRouter.delete("/:id", async (c) => {
const apptDate = current.startTime.toISOString().slice(0, 10);
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
withRetry(
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
2,
1000,
`Failed to notify waitlist for appointment ${id}`
);
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
console.error("[appointments] Failed to notify waitlist:", err);
});
return c.json({ ok: true });
}
@@ -763,12 +560,9 @@ appointmentsRouter.delete("/:id", async (c) => {
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
withRetry(
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
2,
1000,
`Failed to notify waitlist for appointment ${id}`
);
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
console.error("[appointments] Failed to notify waitlist:", err);
});
return c.json({ ok: true });
});
+12 -28
View File
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
const bookingSchema = z.object({
serviceId: z.string().uuid(),
startTime: z.string().datetime().refine(
(dt) => new Date(dt) > new Date(),
{ message: "Appointment must be in the future" }
),
startTime: z.string().datetime(),
clientName: z.string().min(1).max(200),
clientEmail: z.string().email(),
clientPhone: z.string().max(50).optional(),
@@ -268,36 +265,29 @@ bookRouter.get("/confirm/:token", async (c) => {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if appointment is in the past
if (appt.startTime < new Date()) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Idempotent confirm: if already confirmed, redirect to success
if (appt.confirmationStatus === "confirmed") {
return c.redirect(`${BASE_URL()}/booking/confirmed`);
}
// Reject if already cancelled
if (appt.confirmationStatus === "cancelled") {
return c.redirect(`${BASE_URL()}/booking/error`);
}
const updated = await db
await db
.update(appointments)
.set({
confirmationStatus: "confirmed",
confirmedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(appointments.confirmationToken, token),
eq(appointments.confirmationStatus, "pending")
)
)
.returning();
if (updated.length === 0) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
.where(eq(appointments.id, appt.id));
return c.redirect(`${BASE_URL()}/booking/confirmed`);
});
@@ -319,15 +309,19 @@ bookRouter.get("/cancel/:token", async (c) => {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if appointment is in the past
if (appt.startTime < new Date()) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
// Reject if already cancelled (token was nullified — this path won't normally hit,
// but guard against edge cases where token lookup still works)
if (appt.confirmationStatus === "cancelled") {
return c.redirect(`${BASE_URL()}/booking/error`);
}
const updated = await db
// Single-use cancellation: nullify token after use
await db
.update(appointments)
.set({
confirmationStatus: "cancelled",
@@ -335,17 +329,7 @@ bookRouter.get("/cancel/:token", async (c) => {
confirmationToken: null,
updatedAt: new Date(),
})
.where(
and(
eq(appointments.confirmationToken, token),
eq(appointments.confirmationStatus, "pending")
)
)
.returning();
if (updated.length === 0) {
return c.redirect(`${BASE_URL()}/booking/error`);
}
.where(eq(appointments.id, appt.id));
return c.redirect(`${BASE_URL()}/booking/cancelled`);
});
+2 -13
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { randomBytes } from "node:crypto";
import {
and,
eq,
@@ -84,18 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
.where(eq(staff.id, staffId))
.limit(1);
if (!staffMember || !staffMember.icalToken) {
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)
) {
if (!staffMember || staffMember.icalToken !== token) {
return c.text("Unauthorized", 401);
}
+3 -27
View File
@@ -8,12 +8,10 @@ export const clientsRouter = new Hono<AppEnv>();
const createClientSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().email(),
email: z.string().email().optional(),
phone: z.string().max(50).optional(),
address: z.string().max(500).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)
const patchClientSchema = createClientSchema.partial().extend({
status: z.enum(["active", "disabled"]).optional(),
smsOptOut: z.boolean().optional(),
});
clientsRouter.patch(
@@ -110,19 +107,13 @@ clientsRouter.patch(
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
// When disabling, set disabledAt; when re-enabling, clear it
if (body.status === "disabled") {
setValues.disabledAt = now;
} else if (body.status === "active") {
setValues.disabledAt = null;
}
if (body.smsOptOut === true) {
setValues.smsOptIn = false;
setValues.smsOptOutDate = now;
delete setValues.smsOptOut;
}
delete setValues.smsOptOut;
const [row] = await db
.update(clients)
.set(setValues)
@@ -144,24 +135,9 @@ clientsRouter.delete("/:id", async (c) => {
}
const db = getDb();
const clientId = c.req.param("id");
const [existingAppt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(eq(appointments.clientId, clientId))
.limit(1);
if (existingAppt) {
return c.json(
{ error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." },
409
);
}
const [row] = await db
.delete(clients)
.where(eq(clients.id, clientId))
.where(eq(clients.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
+6 -93
View File
@@ -1,10 +1,9 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
export const groomingLogsRouter = new Hono<AppEnv>();
export const groomingLogsRouter = new Hono();
const createLogSchema = z.object({
petId: z.string().uuid(),
@@ -21,26 +20,6 @@ groomingLogsRouter.get("/", async (c) => {
const db = getDb();
const petId = c.req.query("petId");
if (!petId) return c.json({ error: "petId is required" }, 400);
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
const rows = await db
.select()
.from(groomingVisitLogs)
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
zValidator("json", createLogSchema),
async (c) => {
const db = getDb();
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
if (isGroomer) {
if (appointmentId) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.id, appointmentId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
} else {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
}
const { groomedAt, ...rest } = c.req.valid("json");
const [row] = await db
.insert(groomingVisitLogs)
.values({
...rest,
petId,
appointmentId: appointmentId ?? null,
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
})
.returning();
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
const staffRow = c.get("staff");
const isGroomer = staffRow?.role === "groomer";
const [log] = await db
.select()
.from(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id))
.limit(1);
if (!log) return c.json({ error: "Not found" }, 404);
if (isGroomer) {
const [appt] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.petId, log.petId),
or(
eq(appointments.staffId, staffRow.id),
eq(appointments.batherStaffId, staffRow.id)
)
)
)
.limit(1);
if (!appt) return c.json({ error: "Forbidden" }, 403);
}
await db
const [row] = await db
.delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, id))
.where(eq(groomingVisitLogs.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
+55 -137
View File
@@ -8,15 +8,13 @@ import {
invoices,
invoiceLineItems,
invoiceTipSplits,
refunds,
appointments,
services,
clients,
sql,
} 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({
appointmentId: z.string().uuid().optional(),
@@ -45,61 +43,53 @@ const updateInvoiceSchema = z.object({
});
// List invoices
const listInvoicesQuerySchema = z.object({
clientId: z.string().uuid().optional(),
appointmentId: z.string().uuid().optional(),
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
invoicesRouter.get("/", async (c) => {
const db = getDb();
const clientId = c.req.query("clientId");
const appointmentId = c.req.query("appointmentId");
const status = c.req.query("status");
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
const offset = parseInt(c.req.query("offset") || "0", 10);
const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
});
invoicesRouter.get(
"/",
zValidator("query", listInvoicesQuerySchema),
async (c) => {
const db = getDb();
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
const conditions = [];
if (clientId) conditions.push(eq(invoices.clientId, clientId));
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db
.select({ count: sql<number>`count(*)` })
.from(invoices)
.where(whereClause);
const rows = await db
.select({
id: invoices.id,
appointmentId: invoices.appointmentId,
clientId: invoices.clientId,
clientName: clients.name,
subtotalCents: invoices.subtotalCents,
taxCents: invoices.taxCents,
tipCents: invoices.tipCents,
totalCents: invoices.totalCents,
status: invoices.status,
paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt,
notes: invoices.notes,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(whereClause)
.orderBy(invoices.createdAt)
.limit(limit)
.offset(offset);
return c.json({ data: rows, total: totalResult?.count ?? 0 });
}
);
// Get single invoice with line items and tip splits
invoicesRouter.get("/:id", async (c) => {
const db = getDb();
@@ -126,8 +116,8 @@ const tipSplitSchema = z.object({
})
).min(1).refine(
(splits) => {
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
return totalBps === 10000;
const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
return Math.abs(total - 100) < 0.01;
},
{ 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 [lineItems, tipSplits] = await Promise.all([
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]);
const splits = await db
.select()
.from(invoiceTipSplits)
.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);
});
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
draft: ["pending", "void"],
pending: ["draft", "paid", "void"],
paid: ["void"],
void: [],
};
// Update invoice
invoicesRouter.patch(
"/:id",
@@ -324,14 +306,8 @@ invoicesRouter.patch(
.where(eq(invoices.id, id));
if (!current) return c.json({ error: "Not found" }, 404);
if (body.status !== undefined) {
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
if (!allowed.includes(body.status)) {
return c.json(
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
422
);
}
if (current.status === "void") {
return c.json({ error: "Cannot modify a voided invoice" }, 422);
}
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
@@ -362,61 +338,3 @@ invoicesRouter.patch(
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 });
});
}
);
+1 -114
View File
@@ -35,12 +35,6 @@ portalRouter.get("/me", async (c) => {
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) => {
const db = getDb();
const allServices = await db.select().from(services).where(eq(services.active, true));
@@ -129,7 +123,7 @@ portalRouter.get("/invoices", async (c) => {
id: inv.id,
status: inv.status,
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 })),
})));
});
@@ -454,113 +448,6 @@ portalRouter.delete("/waitlist/:id", async (c) => {
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 sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const invoiceRows = await db
.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 sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const methods = await listPaymentMethods(clientId);
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
return c.json(methods);
});
portalRouter.post("/payment-methods", async (c) => {
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
const 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 sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const paymentMethodId = c.req.param("id");
const 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 ──────────────────────────────────────────────
// Allows the dev login selector to vend an impersonation session for a client
// 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);
const ninetyDaysAgoISO = ninetyDaysAgo.toISOString();
const page = Math.max(1, parseInt(c.req.query("page") ?? "1", 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(c.req.query("limit") ?? "20", 10) || 20));
const offset = (page - 1) * limit;
const churnRisk = await db
.select({
clientId: clients.id,
@@ -302,34 +298,15 @@ reportsRouter.get("/clients", async (c) => {
.having(
sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL`
)
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`)
.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;
.orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`);
return c.json({
from: from.toISOString(),
to: to.toISOString(),
newClients,
activeInPeriodCount: activeInPeriod.length,
churnRisk,
churnRiskTotal,
page,
limit,
churnRisk: churnRisk.slice(0, 20), // top 20 at-risk clients
churnRiskTotal: churnRisk.length,
});
});
+1 -1
View File
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
basePriceCents: z.number().int().positive(),
durationMinutes: z.number().int().positive().max(480),
durationMinutes: z.number().int().positive(),
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 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>();
// GET /api/setup/status — public (no auth), returns whether setup is needed
// and whether the auth provider bootstrap step should be shown
setupRouter.get("/status", async (c) => {
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();
// Check if any super user exists
@@ -203,74 +174,52 @@ const authProviderTestSchema = z.object({
* After setup completes, this endpoint permanently returns 403.
*/
setupRouter.post("/auth-provider", async (c) => {
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
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();
let row: typeof authProviderConfig.$inferSelect;
try {
row = await db.transaction(async (tx) => {
const [superUser] = await tx
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
// Guard: only allow during fresh install (no super user yet)
const [superUser] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
if (superUser) {
throw Object.assign(new Error("setup-complete"), { code: 403 });
}
if (superUser) {
// 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
.select({ id: authProviderConfig.id })
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
// Guard: ensure no DB config already exists (should be redundant with status check but defensive)
const [existingConfig] = await db
.select({ id: authProviderConfig.id })
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
if (existingConfig) {
throw Object.assign(new Error("config-exists"), { code: 409 });
}
if (existingConfig) {
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
.insert(authProviderConfig)
.values({
providerId: body.providerId,
displayName: body.displayName,
issuerUrl: body.issuerUrl,
internalBaseUrl: body.internalBaseUrl ?? null,
clientId: body.clientId,
clientSecret: encryptedSecret,
scopes: body.scopes,
enabled: true,
})
.returning();
const [row] = await db
.insert(authProviderConfig)
.values({
providerId: body.providerId,
displayName: body.displayName,
issuerUrl: body.issuerUrl,
internalBaseUrl: body.internalBaseUrl ?? null,
clientId: body.clientId,
clientSecret: encryptedSecret,
scopes: body.scopes,
enabled: true,
})
.returning();
if (!configRow) {
throw Object.assign(new Error("insert-failed"), { code: 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;
if (!row) {
return c.json({ error: "Failed to save auth provider configuration." }, 500);
}
return c.json({
@@ -294,13 +243,6 @@ setupRouter.post("/auth-provider", async (c) => {
* Only available when needsSetup is true (no super user = fresh install).
*/
setupRouter.post("/auth-provider/test", async (c) => {
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
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();
// 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 linkUserSchema = z.object({
userId: z.string().min(1),
});
staffRouter.get("/me", async (c) => {
const staffRow = c.get("staff");
return c.json(staffRow);
@@ -110,32 +106,6 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
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) => {
const db = getDb();
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,
staff,
reminderLogs,
session,
} from "@groombook/db";
import {
buildReminderEmail,
sendEmail,
} 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 }[] {
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
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> {
const db = getDb();
const now = new Date();
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 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
.select({
id: appointments.id,
@@ -60,38 +64,23 @@ export async function runReminderCheck(): Promise<void> {
);
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 })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
eq(reminderLogs.reminderType, window.label)
)
)
.limit(1);
const [smsLog] = await db
.select({ id: reminderLogs.id })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "sms")
)
)
.limit(1);
if (existing.length > 0) continue; // already sent
// Fetch related records for the email
const [client] = await db
.select({
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phone: clients.phone,
})
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
@@ -122,6 +111,8 @@ export async function runReminderCheck(): Promise<void> {
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;
if (!confirmationToken) {
confirmationToken = randomBytes(32).toString("hex");
@@ -131,74 +122,39 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id));
}
if (!emailLog) {
const sent = await sendEmail(
buildReminderEmail(
client.email,
{
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
},
window.hours,
confirmationToken
)
);
const sent = await sendEmail(
buildReminderEmail(
client.email,
{
clientName: client.name,
petName: pet.name,
serviceName: service.name,
groomerName,
startTime: appt.startTime,
},
window.hours,
confirmationToken
)
);
if (sent) {
await db
.insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
.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);
}
if (sent) {
// Record send — ignore conflicts (race condition between instances)
await db
.insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label })
.onConflictDoNothing();
}
}
}
}
// Starts the cron scheduler. Call once at server startup.
export function startReminderScheduler(): void {
// Run every minute
cron.schedule("* * * * *", () => {
runReminderCheck().catch((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");
}
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 },
});
}
if (url.includes("/api/invoices")) {
return route.fulfill({ json: { data: [], total: 0 } });
}
// Appointments, clients, services, staff, book, etc.
// Appointments, clients, services, staff, invoices, book, etc.
return route.fulfill({ json: [] });
});
});
@@ -85,7 +82,6 @@ test("admin staff page loads", async ({ page }) => {
test("admin invoices page loads", async ({ page }) => {
await page.goto("/admin/invoices");
await page.waitForLoadState("domcontentloaded");
await expect(page.getByText("GroomBook")).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 --from=builder /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
-12
View File
@@ -3,22 +3,10 @@ server {
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Cache static assets
location ~* \.(js|css|png|svg|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}
# Proxy API calls to the API service
+1 -3
View File
@@ -14,10 +14,8 @@
},
"dependencies": {
"@groombook/types": "workspace:*",
"@stripe/react-stripe-js": "^6.1.0",
"@stripe/stripe-js": "^9.1.0",
"@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6",
"better-auth": "^1.0.0",
"lucide-react": "^0.577.0",
"react": "^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 { AppointmentsPage } from "./pages/Appointments.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 { BrandingProvider, useBranding } from "./BrandingContext.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() {
const [isLoading, setIsLoading] = useState(false);
const [providers, setProviders] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/auth/providers")
.then((r) => r.json())
.then((data) => setProviders(data.providers ?? []))
.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) => {
setIsLoading(true);
setError(null);
const result = await signIn.social({ provider, callbackURL: window.location.origin });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
}
await signIn.social({ provider, callbackURL: window.location.origin });
};
const isGoogle = providers.includes("google");
@@ -74,11 +65,6 @@ function LoginPage() {
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
Sign in to continue
</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 && (
<button
onClick={() => handleSocialLogin("google")}
@@ -181,7 +167,6 @@ const NAV_LINKS = [
function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const { branding } = useBranding();
const logoSrc = branding.logoBase64 && branding.logoMimeType
@@ -210,7 +195,6 @@ function AdminLayout() {
alignItems: "center",
gap: 8,
marginRight: "1.25rem",
flexShrink: 0,
}}>
{logoSrc && (
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
@@ -224,73 +208,45 @@ function AdminLayout() {
</strong>
</div>
<GlobalSearch />
<div style={{
display: "flex",
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");
}}
<Link
to="/admin/book"
style={{
flexShrink: 0,
padding: "0.4rem 0.85rem",
borderRadius: 6,
border: "1px solid #e2e8f0",
background: "#fff",
color: "#4b5563",
textDecoration: "none",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
fontWeight: 600,
color: "#fff",
background: branding.primaryColor,
marginRight: "0.5rem",
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
}}
>
Logout
</button>
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",
}}
>
{label}
</Link>
);
})}
</nav>
<main style={{ padding: "1.25rem 1.5rem" }}>
<Routes>
+1 -1
View File
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
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 && (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<RescheduleFlow
appointment={rescheduleAppointment as any}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from "react";
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
import { PetForm } from "./PetForm.js";
import { authClient } from "../../lib/auth-client.js";
interface Props {
sessionId: string | null;
@@ -149,11 +148,9 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const passwordsMatch = newPassword === confirmPassword;
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch;
if (readOnly) {
return (
@@ -163,34 +160,17 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
);
}
async function handleSubmit() {
function handleSubmit() {
if (!canSubmit) return;
if (newPassword !== confirmPassword) {
setError("Passwords do not match.");
return;
}
// TODO: Wire up to actual password-change API endpoint once backend support exists
setError(null);
setLoading(true);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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);
}
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}
return (
@@ -225,13 +205,12 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
<button
onClick={handleSubmit}
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"
>
{loading ? "Updating..." : "Update Password"}
Update Password
</button>
</div>
</div>
+94 -183
View File
@@ -1,6 +1,4 @@
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";
interface Invoice {
@@ -12,28 +10,31 @@ interface Invoice {
}
interface PaymentMethod {
id: string;
brand: string;
last4: string;
expiryMonth: number;
expiryYear: number;
}
interface Package {
name: string;
remaining: number;
}
interface BillingPaymentsProps {
sessionId: string | null;
readOnly: boolean;
}
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [packages] = useState<{ name: string; remaining: number }[]>([]);
const [packages, setPackages] = useState<Package[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
const [autopay, setAutopay] = useState(false);
const [showPaymentModal, setShowPaymentModal] = useState(false);
const [publishableKey, setPublishableKey] = useState<string>("");
useEffect(() => {
async function fetchData() {
@@ -43,37 +44,20 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
}
try {
const [configRes, invoicesRes, methodsRes] = await Promise.all([
fetch("/api/portal/config", {
headers: { "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 },
}),
]);
const response = await fetch("/api/portal/invoices", {
headers: {
"X-Impersonation-Session-Id": sessionId,
},
});
if (!configRes.ok) throw new Error("Failed to fetch config");
const configData = await configRes.json();
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,
}))
);
if (!response.ok) {
throw new Error("Failed to fetch invoices");
}
const data = await response.json();
setInvoices(Array.isArray(data) ? data : data.invoices || []);
setPaymentMethods(data.paymentMethods || []);
setPackages(data.packages || []);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
@@ -84,8 +68,12 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
fetchData();
}, [sessionId]);
const formatCents = (cents: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
const formatCents = (cents: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100);
};
const pending = invoices.filter((i) => i.status === "pending");
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
@@ -94,9 +82,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/3" />
<div className="h-24 bg-gray-200 rounded" />
<div className="h-24 bg-gray-200 rounded" />
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
<div className="h-24 bg-gray-200 rounded"></div>
<div className="h-24 bg-gray-200 rounded"></div>
</div>
</div>
);
@@ -112,6 +100,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
return (
<div className="space-y-6">
{/* Outstanding Balance Banner */}
{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>
@@ -121,15 +110,16 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
</p>
</div>
<button
onClick={() => setShowPaymentModal(true)}
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Pay Now
</button>
<button
onClick={() => setShowPaymentModal(true)}
className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Pay Now
</button>
</div>
)}
{/* Tabs */}
<div className="flex gap-2">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
@@ -151,6 +141,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
))}
</div>
{/* Invoices */}
{tab === "invoices" && (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<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">Amount</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>
</thead>
<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">
<td className="px-5 py-3 text-stone-700">
{new Date(inv.date).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
month: "short",
day: "numeric",
year: "numeric",
})}
</td>
<td className="px-5 py-3 text-stone-600">
@@ -208,6 +201,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Payment Methods */}
{tab === "payment" && (
<div className="space-y-4">
{paymentMethods.length === 0 ? (
@@ -216,7 +210,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
<div className="space-y-3">
{paymentMethods.map((method) => (
<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"
>
<div className="flex items-center gap-3">
@@ -229,18 +223,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</span>
</div>
{!readOnly && (
<button
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"
>
<button className="text-sm text-blue-600 hover:underline">
Remove
</button>
)}
@@ -249,6 +232,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Autopay */}
<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 gap-3">
@@ -257,7 +241,9 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
<div>
<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>
{!readOnly ? (
@@ -283,13 +269,17 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{/* Packages */}
{tab === "packages" && (
<div className="space-y-4">
{packages.length === 0 ? (
<p className="text-gray-500 italic">No packages purchased</p>
) : (
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">
<span className="font-medium text-stone-800">{pkg.name}</span>
<span className="text-stone-600">{pkg.remaining} remaining</span>
@@ -300,124 +290,60 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
{showPaymentModal && publishableKey && (
<PaymentModalWrapper
key={Date.now()}
sessionId={sessionId ?? ""}
publishableKey={publishableKey}
{/* Payment Modal */}
{showPaymentModal && (
<PaymentModal
pending={pending}
totalPending={totalPending}
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>
);
}
interface PaymentModalWrapperProps {
sessionId: string;
publishableKey: string;
function PaymentModal({
pending,
totalPending: _totalPending,
onClose,
}: {
pending: Invoice[];
totalPending: number;
onClose: () => void;
onSuccess: () => void;
}
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
const [stripePromise] = useState(() =>
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
}) {
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
new Set(pending.map((i) => i.id))
);
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 [isComplete, setIsComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
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 next = new Set(selectedInvoices);
if (next.has(id)) next.delete(id);
else next.add(id);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
setSelectedInvoices(next);
};
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
const handlePay = async () => {
if (!stripe || !elements) return;
setIsProcessing(true);
setError(null);
try {
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);
}
await new Promise((resolve) => setTimeout(resolve, 1500));
setIsProcessing(false);
setIsComplete(true);
};
const selectedTotal = pending
.filter((i) => selectedInvoices.has(i.id))
.reduce((sum, i) => sum + i.totalCents, 0);
if (isComplete) {
return (
<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">
Your payment of {formatCents(selectedTotal)} has been processed. A receipt has been sent to your email.
</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
</button>
</div>
@@ -479,36 +408,22 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
</p>
</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>
))}
</div>
<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-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
<span className="text-lg font-bold text-stone-800">
{formatCents(selectedTotal)}
</span>
</div>
<PaymentElement />
</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">
<button
onClick={onClose}
@@ -518,7 +433,7 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
</button>
<button
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"
>
{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;
+2 -2
View File
@@ -41,11 +41,11 @@ export default defineConfig({
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [
/^\/api\/auth\//,
/^\/api\/auth\/oauth2\/callback\//,
],
runtimeCaching: [
{
urlPattern: /^http.*\/api\/(?!auth\/).*/i,
urlPattern: /^http.*\/api\/.*/i,
handler: "NetworkFirst",
options: {
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,
"tag": "0024_invoice_indexes",
"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",
notes: null,
emailOptOut: false,
smsOptIn: false,
smsConsentDate: null,
smsOptOutDate: null,
smsConsentText: null,
stripeCustomerId: null,
status: "active",
disabledAt: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
+37 -75
View File
@@ -102,55 +102,43 @@ export const verification = pgTable("verification", {
// ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable(
"clients",
{
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email").notNull(),
phone: text("phone"),
address: text("address"),
notes: text("notes"),
emailOptOut: boolean("email_opt_out").notNull().default(false),
smsOptIn: boolean("sms_opt_in").notNull().default(false),
smsConsentDate: timestamp("sms_consent_date"),
smsOptOutDate: timestamp("sms_opt_out_date"),
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 clients = pgTable("clients", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
email: text("email"),
phone: text("phone"),
address: text("address"),
notes: text("notes"),
// Set to true if the client has opted out of email reminders/notifications
emailOptOut: boolean("email_opt_out").notNull().default(false),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const pets = pgTable(
"pets",
{
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
name: text("name").notNull(),
species: text("species").notNull(),
breed: text("breed"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
dateOfBirth: timestamp("date_of_birth"),
healthAlerts: text("health_alerts"),
groomingNotes: text("grooming_notes"),
cutStyle: text("cut_style"),
shampooPreference: text("shampoo_preference"),
specialCareNotes: text("special_care_notes"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
image: text("image"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(t) => [index("idx_pets_client_id").on(t.clientId)]
);
export const pets = pgTable("pets", {
id: uuid("id").primaryKey().defaultRandom(),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
name: text("name").notNull(),
species: text("species").notNull(),
breed: text("breed"),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
dateOfBirth: timestamp("date_of_birth"),
healthAlerts: text("health_alerts"),
groomingNotes: text("grooming_notes"),
cutStyle: text("cut_style"),
shampooPreference: text("shampoo_preference"),
specialCareNotes: text("special_care_notes"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
photoKey: text("photo_key"),
photoUploadedAt: timestamp("photo_uploaded_at"),
image: text("image"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const services = pgTable("services", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -263,9 +251,6 @@ export const invoices = pgTable(
status: invoiceStatusEnum("status").notNull().default("draft"),
paymentMethod: paymentMethodEnum("payment_method"),
paidAt: timestamp("paid_at"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
stripeRefundId: text("stripe_refund_id"),
paymentFailureReason: text("payment_failure_reason"),
notes: text("notes"),
createdAt: timestamp("created_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_status").on(t.status),
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)]
);
// Refund records with idempotency key support
export const refunds = pgTable(
"refunds",
{
id: uuid("id").primaryKey().defaultRandom(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "restrict" }),
stripeRefundId: text("stripe_refund_id").notNull(),
idempotencyKey: text("idempotency_key").unique(),
amountCents: integer("amount_cents"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
index("idx_refunds_invoice_id").on(t.invoiceId),
index("idx_refunds_idempotency_key").on(t.idempotencyKey),
]
);
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
// reminder_type values: "confirmation", "24h", "2h"
// channel values: "email", "sms"
export const reminderLogs = pgTable(
"reminder_logs",
{
@@ -343,11 +307,9 @@ export const reminderLogs = pgTable(
.references(() => appointments.id, { onDelete: "cascade" }),
// "confirmation" | "24h" | "2h"
reminderType: text("reminder_type").notNull(),
// "email" | "sms"
channel: text("channel").notNull().default("email"),
sentAt: timestamp("sent_at").notNull().defaultNow(),
},
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
(t) => [unique().on(t.appointmentId, t.reminderType)]
);
// ─── Impersonation ──────────────────────────────────────────────────────────
+1 -63
View File
@@ -398,8 +398,6 @@ async function seedKnownUsers() {
id: ADMIN_STAFF_ID,
name: adminName,
email: adminEmail,
oidcSub: adminEmail,
userId: adminEmail,
role: "manager",
isSuperUser: true,
active: true,
@@ -426,7 +424,6 @@ async function seedKnownUsers() {
name: "UAT Super User",
email: "uat-super@groombook.dev",
oidcSub: uatSuperOidcSub,
userId: uatSuperOidcSub,
role: "manager",
isSuperUser: true,
active: true,
@@ -453,7 +450,6 @@ async function seedKnownUsers() {
name: "UAT Staff Groomer",
email: "uat-groomer@groombook.dev",
oidcSub: uatStaffOidcSub,
userId: uatStaffOidcSub,
role: "groomer",
isSuperUser: false,
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 ─────────────────────
// UNIQUE constraint on services.name (migration 0020) must exist first.
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
@@ -602,7 +567,7 @@ async function seed() {
// ── Staff ──
const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) =>
({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: 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) =>
({ 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,
name: adminName,
email: adminEmail,
oidcSub: adminEmail,
userId: adminEmail,
role: "manager",
isSuperUser: true,
active: true,
@@ -660,31 +623,6 @@ async function seed() {
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 ──
// Upsert services using name as unique key. With deterministic IDs in
// servicesDef and TRUNCATE clearing downstream tables first, this is
+54 -114
View File
@@ -40,12 +40,6 @@ importers:
nodemailer:
specifier: ^6.9.16
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:
specifier: ^4.3.6
version: 4.3.6
@@ -89,17 +83,11 @@ importers:
'@groombook/types':
specifier: workspace:*
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':
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))
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))
lucide-react:
specifier: ^0.577.0
@@ -180,7 +168,7 @@ importers:
version: 22.19.15
drizzle-kit:
specifier: ^0.30.4
version: 0.30.4
version: 0.30.6
tsx:
specifier: ^4.19.0
version: 4.21.0
@@ -1699,6 +1687,9 @@ packages:
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
engines: {node: '>=14'}
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -2118,17 +2109,6 @@ packages:
'@standard-schema/utils@0.3.0':
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':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -2830,8 +2810,8 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
drizzle-kit@0.30.4:
resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==}
drizzle-kit@0.30.6:
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
hasBin: true
drizzle-orm@0.38.4:
@@ -2955,6 +2935,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
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:
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
engines: {node: '>= 0.4'}
@@ -3158,6 +3142,11 @@ packages:
functions-have-names@1.2.3:
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:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'}
@@ -3425,6 +3414,10 @@ packages:
isexe@2.0.0:
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:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@@ -3606,9 +3599,6 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -3618,10 +3608,6 @@ packages:
lodash@4.17.23:
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:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -3713,10 +3699,6 @@ packages:
nwsapi@2.2.23:
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:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -3834,17 +3816,10 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
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:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -3853,9 +3828,6 @@ packages:
peerDependencies:
react: ^19.2.4
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -4040,6 +4012,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -4148,15 +4124,6 @@ packages:
strip-literal@3.1.0:
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:
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
@@ -4178,10 +4145,6 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
telnyx@1.27.0:
resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==}
engines: {node: ^6 || >=8}
temp-dir@2.0.0:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'}
@@ -4256,9 +4219,6 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -4348,10 +4308,6 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
@@ -4488,6 +4444,11 @@ packages:
engines: {node: '>= 8'}
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:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -6219,6 +6180,8 @@ snapshots:
'@opentelemetry/semantic-conventions@1.40.0': {}
'@petamoriken/float16@3.9.3': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -6708,15 +6671,6 @@ snapshots:
'@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':
dependencies:
ejs: 3.1.10
@@ -7414,12 +7368,13 @@ snapshots:
dom-accessibility-api@0.6.3: {}
drizzle-kit@0.30.4:
drizzle-kit@0.30.6:
dependencies:
'@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5
esbuild: 0.19.12
esbuild-register: 3.6.0(esbuild@0.19.12)
gel: 2.2.0
transitivePeerDependencies:
- supports-color
@@ -7456,6 +7411,8 @@ snapshots:
entities@6.0.1: {}
env-paths@3.0.0: {}
es-abstract@1.24.1:
dependencies:
array-buffer-byte-length: 1.0.2
@@ -7817,6 +7774,17 @@ snapshots:
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: {}
gensync@1.0.0-beta.2: {}
@@ -8081,6 +8049,8 @@ snapshots:
isexe@2.0.0: {}
isexe@3.1.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
@@ -8249,18 +8219,12 @@ snapshots:
lodash.debounce@4.0.8: {}
lodash.isplainobject@4.0.6: {}
lodash.merge@4.6.2: {}
lodash.sortby@4.7.0: {}
lodash@4.17.23: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
lru-cache@10.4.3: {}
@@ -8335,8 +8299,6 @@ snapshots:
nwsapi@2.2.23: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -8441,18 +8403,8 @@ snapshots:
ansi-styles: 5.2.0
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: {}
qs@6.15.1:
dependencies:
side-channel: 1.1.0
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@@ -8462,8 +8414,6 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-is@16.13.1: {}
react-is@17.0.2: {}
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: {}
shell-quote@1.8.3: {}
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -8822,10 +8774,6 @@ snapshots:
dependencies:
js-tokens: 9.0.1
stripe@22.0.1(@types/node@22.19.15):
optionalDependencies:
'@types/node': 22.19.15
strnum@2.2.1: {}
supports-color@7.2.0:
@@ -8840,14 +8788,6 @@ snapshots:
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: {}
tempy@0.6.0:
@@ -8918,8 +8858,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tweetnacl@1.0.3: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -9016,8 +8954,6 @@ snapshots:
uuid@8.3.2: {}
uuid@9.0.1: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
@@ -9195,6 +9131,10 @@ snapshots:
dependencies:
isexe: 2.0.0
which@4.0.0:
dependencies:
isexe: 3.1.5
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0