Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d407b895be | |||
| 53ab415713 | |||
| a330e342e1 | |||
| 0f841e27fc | |||
| a7bcce8b80 | |||
| 5f1582a3b6 | |||
| c76ea93c29 | |||
| cd25d98384 | |||
| e9fceb78b3 | |||
| 0cae8adef8 | |||
| 674626ba1e | |||
| aa5686bed1 | |||
| 903fbf55d5 | |||
| 775e2e544b | |||
| fb9c922182 | |||
| 1cc48f0b88 | |||
| 1b8d7087c0 | |||
| d65d121a5d | |||
| b8fd7ec18f | |||
| 7bf9cf9734 | |||
| bf159f8b1f | |||
| 2f3d4d8d01 | |||
| db9bb31702 | |||
| b38db65dde | |||
| 3178f81b99 | |||
| 544d65959d | |||
| fe2e093b92 | |||
| 2af1671891 | |||
| ad80722eee | |||
| c811b58c62 | |||
| 1dfcdcc2cb | |||
| f74e034495 | |||
| 4c46cec4e3 | |||
| f38bb244a4 | |||
| 251b36b863 | |||
| 3c366ccc46 | |||
| ff149f75dc | |||
| 03bd2d0235 | |||
| 10ad5e7b04 | |||
| 4f85a4a432 | |||
| 560d33edf8 | |||
| 50e9e70935 | |||
| d59cb1ab1d | |||
| 740e46baf2 | |||
| b1b89966d9 | |||
| 25fd3308e0 | |||
| be07c8b758 | |||
| ff2851eda2 | |||
| abee344ca4 | |||
| 460ba78112 | |||
| ffe8aef035 | |||
| 2153505875 | |||
| 4aaf2a3b3f | |||
| 20ca93b36d | |||
| 9793283021 | |||
| 1cc6d53546 | |||
| bfe099deda | |||
| 47ccd1395c | |||
| ef79ac748c | |||
| 06846952a1 | |||
| d72485c08a | |||
| 4001691ae7 | |||
| b980e4177c | |||
| 6141dcb77d | |||
| 8ecbfbeee4 | |||
| 1da61fb466 | |||
| 77971a1ac9 | |||
| e539b6c904 | |||
| b797ac3ab1 | |||
| 6bddd6203d | |||
| 3c7820d785 | |||
| 9eb86004fc | |||
| 6046594a15 | |||
| b683c57d6c | |||
| 89505a2363 | |||
| 8e1e51be59 | |||
| ea7bf4f49b | |||
| 6e1e51fba7 | |||
| 5a8ea2fd14 | |||
| b00d6a8ca0 | |||
| f8ea417799 | |||
| 772f4df62f | |||
| edf2ef8f7e | |||
| 8182870d38 | |||
| 7f715ecdfc | |||
| 5df8837b5f | |||
| 0abb79010d | |||
| eab97b2ebd | |||
| f301b1a5a0 | |||
| c786544369 | |||
| 85c76b5209 | |||
| d8dbec1be1 | |||
| 4a65c30d40 | |||
| cab17e0230 |
@@ -2,9 +2,9 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
@@ -340,7 +340,7 @@ jobs:
|
|||||||
name: Update Infra Image Tags
|
name: Update Infra Image Tags
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [docker]
|
needs: [docker]
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|||||||
+13
@@ -8,3 +8,16 @@ dist/
|
|||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
minimax-output/
|
minimax-output/
|
||||||
|
|
||||||
|
# Agent runtime artifacts — never commit
|
||||||
|
.gh-token
|
||||||
|
*.gh-token
|
||||||
|
.config/gh/
|
||||||
|
**/.config/gh/
|
||||||
|
infra-repo
|
||||||
|
infra-repo/
|
||||||
|
**/instructions/.gh-token
|
||||||
|
**/AGENT_HOME/**
|
||||||
|
$AGENT_HOME/**
|
||||||
|
.claude/
|
||||||
|
.codex/
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# 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,218 +1,43 @@
|
|||||||
# GroomBook
|
# GroomBook Monorepo — Archived
|
||||||
|
|
||||||
> **The open-source scheduling and client management platform built specifically for independent pet groomers** — giving you the tools of enterprise software without the enterprise price tag or vendor lock-in.
|
> **This repository has been archived and replaced by standalone repositories.**
|
||||||
|
|
||||||
**Built for groomers, not corporations.**
|
## Successor Repositories
|
||||||
|
|
||||||
---
|
| Repository | Description |
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
**Stop chasing confirmations**
|
|
||||||
- **Customer portal** — Clients confirm or cancel appointments on their own. Reduce no-shows with an automated waitlist.
|
|
||||||
|
|
||||||
**Your calendar, your way**
|
|
||||||
- **iCal calendar feed** — Push GroomBook appointments directly into Google Calendar or Apple Calendar. No app switching.
|
|
||||||
|
|
||||||
**Know every pet at a glance**
|
|
||||||
- **Client & pet records** — Detailed profiles with grooming history, preferences, and breed-specific notes. Full appointment notes for context on every regular.
|
|
||||||
- **Quick-find search** — Find clients and pets instantly without digging through spreadsheets.
|
|
||||||
|
|
||||||
**Staff access without stress**
|
|
||||||
- **Role-based access control (RBAC)** — Front desk sees bookings; only you see financials. Right access for every role.
|
|
||||||
|
|
||||||
**Everything else**
|
|
||||||
- **Appointment scheduling** — Calendar management for single or multiple groomers
|
|
||||||
- **Service management** — Pricing, duration, and service catalog
|
|
||||||
- **POS & invoicing** — Payments, tips, and receipt generation
|
|
||||||
- **Automated reminders** — SMS and email notifications
|
|
||||||
- **Reporting dashboard** — Revenue, utilization, and trend analytics
|
|
||||||
- **Staff impersonation** — Managers can view the customer portal as any client, with full audit logging and session controls
|
|
||||||
- **PWA** — Installable on mobile devices, works offline
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Try the Demo
|
|
||||||
|
|
||||||
[**Live Demo**](https://demo.groombook.app) — explore GroomBook without installing anything.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Docker Compose (recommended for indie groomers)
|
|
||||||
|
|
||||||
Run GroomBook on your own hardware in minutes. Everything you need is in the box — no subscription, no vendor lock-in.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/groombook/groombook.git
|
|
||||||
cd groombook
|
|
||||||
|
|
||||||
# Start everything (Postgres + database migrations + API + web UI)
|
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Web UI**: http://localhost:8080
|
|
||||||
- **API**: http://localhost:3000
|
|
||||||
|
|
||||||
The default `docker-compose.yml` sets `AUTH_DISABLED=true` so you can explore the app without configuring an OIDC provider. **Important:** Disable this in any internet-facing deployment.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Backend | [Hono](https://hono.dev/) (TypeScript, Node.js) |
|
| [groombook/api](https://github.com/groombook/api) | Hono REST API (TypeScript, Node.js) |
|
||||||
| Frontend | React 19 + Vite + [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) |
|
| [groombook/web](https://github.com/groombook/web) | React PWA frontend |
|
||||||
| Database | PostgreSQL via [CNPG](https://cloudnative-pg.io/) + [Drizzle ORM](https://orm.drizzle.team/) |
|
| [groombook/charts](https://github.com/groombook/charts) | Helm charts for Kubernetes deployment |
|
||||||
| Auth | OIDC via [Authentik](https://goauthentik.io/) |
|
|
||||||
| Infra | Kubernetes (namespace: `groombook`), Flux GitOps |
|
|
||||||
| CI | GitHub Actions (self-hosted `groombook-runners`) |
|
|
||||||
|
|
||||||
## Repository Structure
|
## What Changed
|
||||||
|
|
||||||
```
|
- **Monorepo split complete** — The former `apps/api`, `apps/web`, and `packages/*` are now standalone repos
|
||||||
groombook/
|
- **`@groombook/types`** — Inlined directly into `groombook/api` and `groombook/web`
|
||||||
├── apps/
|
- **E2E testing** — Now via Playwright MCP, no standalone repo needed
|
||||||
│ ├── api/ # Hono REST API
|
- **CI/CD** — Each repo has its own pipeline; see individual repos for status
|
||||||
│ └── web/ # React PWA
|
|
||||||
├── packages/
|
|
||||||
│ ├── db/ # Drizzle schema + migrations
|
|
||||||
│ └── types/ # Shared TypeScript types
|
|
||||||
├── .github/
|
|
||||||
│ └── workflows/ # CI/CD pipelines
|
|
||||||
└── docker-compose.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getting Started
|
## Migration Notes
|
||||||
|
|
||||||
### Prerequisites
|
If you were cloning `groombook/groombook` for local development:
|
||||||
|
|
||||||
- Node.js >= 20
|
|
||||||
- pnpm >= 9 (`npm install -g pnpm`)
|
|
||||||
- Docker & Docker Compose (for local Postgres)
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
# API
|
||||||
git clone https://github.com/groombook/groombook.git
|
git clone https://github.com/groombook/api.git
|
||||||
cd groombook
|
cd api && pnpm install && pnpm dev
|
||||||
|
|
||||||
# Install dependencies
|
# Web (in a new terminal)
|
||||||
pnpm install
|
git clone https://github.com/groombook/web.git
|
||||||
|
cd web && pnpm install && pnpm dev
|
||||||
# Start local Postgres
|
|
||||||
docker compose up postgres -d
|
|
||||||
|
|
||||||
# Run database migrations
|
|
||||||
DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook pnpm db:migrate
|
|
||||||
|
|
||||||
# Start API and Web in parallel
|
|
||||||
pnpm dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
API will be available at http://localhost:3000
|
For full Docker Compose setup, see each repo's README.
|
||||||
Web will be available at http://localhost:5173
|
|
||||||
|
|
||||||
### Environment Variables
|
## Archive Info
|
||||||
|
|
||||||
#### API (`apps/api/.env`)
|
This repository was archived on 2026-05-14 as part of the monorepo decommission ([GRO-1081]).
|
||||||
|
The history is preserved but the repo is read-only.
|
||||||
```env
|
|
||||||
DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook
|
|
||||||
OIDC_ISSUER=https://authentik.example.com
|
|
||||||
OIDC_AUDIENCE=groombook
|
|
||||||
CORS_ORIGIN=http://localhost:5173
|
|
||||||
PORT=3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Unit tests (vitest)
|
|
||||||
pnpm test
|
|
||||||
|
|
||||||
# E2E tests (Playwright) — requires the full Docker Compose stack to be running
|
|
||||||
docker compose up -d --wait
|
|
||||||
pnpm --filter @groombook/e2e test
|
|
||||||
|
|
||||||
# Open the Playwright UI (interactive test runner)
|
|
||||||
pnpm --filter @groombook/e2e test:ui
|
|
||||||
|
|
||||||
# View the last E2E test report
|
|
||||||
pnpm --filter @groombook/e2e test:report
|
|
||||||
```
|
|
||||||
|
|
||||||
E2E tests target the Docker Compose stack (`http://localhost:8080`). They use API route mocking where needed so happy-path tests are deterministic without requiring seed data.
|
|
||||||
|
|
||||||
### Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Self-Hosting
|
|
||||||
|
|
||||||
### Production Configuration
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and configure:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Key variables to update for production:
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|---|---|
|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string |
|
|
||||||
| `AUTH_DISABLED` | Set to `false` in production |
|
|
||||||
| `OIDC_ISSUER` | Authentik issuer URL |
|
|
||||||
| `OIDC_AUDIENCE` | OAuth2 audience (default: `groombook`) |
|
|
||||||
| `CORS_ORIGIN` | Public URL of the web frontend |
|
|
||||||
|
|
||||||
To use your `.env` file with Docker Compose:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file .env up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kubernetes (production-grade deployments)
|
|
||||||
|
|
||||||
See the [groombook/infra](https://github.com/groombook/infra) repository for Kubernetes manifests and Flux configuration.
|
|
||||||
|
|
||||||
Groom Book is deployed in the `groombook` Kubernetes namespace using:
|
|
||||||
- **CNPG** for PostgreSQL
|
|
||||||
- **Authentik** for OIDC authentication
|
|
||||||
- **Flux** for GitOps-managed deployments
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
*For Kubernetes deployments, see [groombook/infra](https://github.com/groombook/infra) (private).*
|
||||||
|
|
||||||
GroomBook thrives on contributions from the grooming community. Whether you're a groomer with a feature request, a developer fixing a bug, or someone improving docs — we'd love your help.
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
|
||||||
3. Commit your changes
|
|
||||||
4. Open a pull request
|
|
||||||
|
|
||||||
All PRs require CI to pass before merge. See [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why GroomBook?
|
|
||||||
|
|
||||||
- **Open source** — You own your data. No vendor lock-in.
|
|
||||||
- **Purpose-built** — Features designed for grooming workflows, not generic scheduling.
|
|
||||||
- **Self-hosted or managed** — Run it yourself for free, or pay for hosted support (coming soon).
|
|
||||||
- **Community-driven** — Used and built by actual groomers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
AGPL-3.0
|
|
||||||
|
|
||||||
+83
-12
@@ -19,7 +19,7 @@ import { impersonationRouter } from "./routes/impersonation.js";
|
|||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { authProviderRouter } from "./routes/authProvider.js";
|
import { authProviderRouter } from "./routes/authProvider.js";
|
||||||
import { searchRouter } from "./routes/search.js";
|
import { searchRouter } from "./routes/search.js";
|
||||||
import { getPresignedGetUrl } from "./lib/s3.js";
|
import { getObject } from "./lib/s3.js";
|
||||||
import { calendarRouter } from "./routes/calendar.js";
|
import { calendarRouter } from "./routes/calendar.js";
|
||||||
import { setupRouter } from "./routes/setup.js";
|
import { setupRouter } from "./routes/setup.js";
|
||||||
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
import { getDb, businessSettings, eq, staff } from "@groombook/db";
|
||||||
@@ -72,28 +72,99 @@ app.route("/api/webhooks/stripe", webhooksRouter);
|
|||||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||||
app.route("/api/dev", devRouter);
|
app.route("/api/dev", devRouter);
|
||||||
|
|
||||||
|
// Magic bytes for allowed image types
|
||||||
|
const ALLOWED_IMAGE_TYPES: Record<string, Uint8Array> = {
|
||||||
|
"image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
"image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]),
|
||||||
|
"image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]),
|
||||||
|
"image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the given base64 content matches the declared MIME type
|
||||||
|
* by checking magic bytes. Returns null if valid, or the field to clear if not.
|
||||||
|
*/
|
||||||
|
function validateLogoMagicBytes(
|
||||||
|
logoBase64: string | null,
|
||||||
|
logoMimeType: string | null
|
||||||
|
): "logoBase64" | "logoMimeType" | null {
|
||||||
|
if (!logoBase64 || !logoMimeType) return null;
|
||||||
|
|
||||||
|
const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType];
|
||||||
|
if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binary = Buffer.from(logoBase64, "base64");
|
||||||
|
// WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4)
|
||||||
|
if (logoMimeType === "image/webp") {
|
||||||
|
if (binary.length < 12) return "logoBase64";
|
||||||
|
const webpMagic = binary.slice(0, 4);
|
||||||
|
const webpSig = binary.slice(8, 12);
|
||||||
|
if (
|
||||||
|
webpMagic[0] !== 0x52 ||
|
||||||
|
webpMagic[1] !== 0x49 ||
|
||||||
|
webpMagic[2] !== 0x46 ||
|
||||||
|
webpMagic[3] !== 0x46 ||
|
||||||
|
webpSig[0] !== 0x57 ||
|
||||||
|
webpSig[1] !== 0x45 ||
|
||||||
|
webpSig[2] !== 0x42 ||
|
||||||
|
webpSig[3] !== 0x50
|
||||||
|
) {
|
||||||
|
return "logoBase64";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other types: check prefix
|
||||||
|
if (binary.length < expectedMagic.length) return "logoBase64";
|
||||||
|
for (let i = 0; i < expectedMagic.length; i++) {
|
||||||
|
if (binary[i] !== expectedMagic[i]) return "logoBase64";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return "logoBase64";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public logo proxy — no auth required, streams logo from S3 so browser never sees raw S3 URL
|
||||||
|
app.get("/api/branding/logo", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const [row] = await db.select().from(businessSettings).limit(1);
|
||||||
|
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||||
|
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||||
|
|
||||||
|
const { body, contentType } = await getObject(row.logoKey);
|
||||||
|
return new Response(Buffer.from(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Public branding endpoint — no auth required, returns business name/colors/logo
|
// Public branding endpoint — no auth required, returns business name/colors/logo
|
||||||
app.get("/api/branding", async (c) => {
|
app.get("/api/branding", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const [row] = await db.select().from(businessSettings).limit(1);
|
const [row] = await db.select().from(businessSettings).limit(1);
|
||||||
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
|
const settings = row ?? { businessName: "GroomBook", primaryColor: "#4f8a6f", accentColor: "#8b7355", logoBase64: null, logoMimeType: null, logoKey: null };
|
||||||
|
|
||||||
let logoUrl: string | null = null;
|
// Return the public proxy path so browser never sees a raw S3 URL
|
||||||
if (settings.logoKey) {
|
const logoUrl = settings.logoKey ? "/api/branding/logo" : null;
|
||||||
try {
|
|
||||||
logoUrl = await getPresignedGetUrl(settings.logoKey);
|
// Defensive: validate magic bytes to prevent MIME type confusion attacks
|
||||||
} catch {
|
// via the legacy base64 logo fields
|
||||||
// If S3 URL generation fails, fall back to legacy base64
|
const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null);
|
||||||
}
|
const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64;
|
||||||
}
|
const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType;
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
businessName: settings.businessName,
|
businessName: settings.businessName,
|
||||||
primaryColor: settings.primaryColor,
|
primaryColor: settings.primaryColor,
|
||||||
accentColor: settings.accentColor,
|
accentColor: settings.accentColor,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
logoBase64: settings.logoBase64,
|
logoBase64: safeLogoBase64,
|
||||||
logoMimeType: settings.logoMimeType,
|
logoMimeType: safeLogoMimeType,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,7 +213,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
|
|||||||
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
api.use("/admin/*", requireRoleOrSuperUser("manager"));
|
||||||
api.use("/admin/settings/*", requireSuperUser());
|
api.use("/admin/settings/*", requireSuperUser());
|
||||||
api.use("/reports/*", requireRole("manager"));
|
api.use("/reports/*", requireRole("manager"));
|
||||||
api.use("/invoices/*", requireRole("manager"));
|
api.use("/invoices/*", requireRole("manager", "groomer"));
|
||||||
api.use("/impersonation/*", requireRole("manager"));
|
api.use("/impersonation/*", requireRole("manager"));
|
||||||
|
|
||||||
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
|
||||||
|
|||||||
@@ -93,9 +93,12 @@ export async function initAuth(): Promise<void> {
|
|||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
max: 10,
|
max: 100,
|
||||||
window: 60,
|
window: 10,
|
||||||
storage: "memory",
|
storage: "memory",
|
||||||
|
customRules: {
|
||||||
|
"/get-session": false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
@@ -240,9 +243,12 @@ export async function initAuth(): Promise<void> {
|
|||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
max: 10,
|
max: 100,
|
||||||
window: 60,
|
window: 10,
|
||||||
storage: "memory",
|
storage: "memory",
|
||||||
|
customRules: {
|
||||||
|
"/get-session": false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
storeStateStrategy: "cookie" as const,
|
storeStateStrategy: "cookie" as const,
|
||||||
|
|||||||
@@ -67,3 +67,41 @@ export async function deleteObject(key: string): Promise<void> {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read an object from S3 and return its body buffer and content type. */
|
||||||
|
export async function getObject(key: string): Promise<{ body: Buffer; contentType: string }> {
|
||||||
|
const client = getS3Client();
|
||||||
|
const response = await client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: getBucket(),
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
// response.Body is a Readable stream; collect chunks into a buffer
|
||||||
|
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
const contentType = response.ContentType ?? "application/octet-stream";
|
||||||
|
return { body, contentType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload an object directly to S3 (server-side only, not a pre-signed URL). */
|
||||||
|
export async function putObject(
|
||||||
|
key: string,
|
||||||
|
body: Buffer | Uint8Array | string,
|
||||||
|
contentType: string,
|
||||||
|
contentLength: number
|
||||||
|
): Promise<void> {
|
||||||
|
const client = getS3Client();
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: getBucket(),
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentType,
|
||||||
|
ContentLength: contentLength,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+168
-19
@@ -18,6 +18,14 @@ import type { AppEnv } from "../middleware/rbac.js";
|
|||||||
|
|
||||||
export const invoicesRouter = new Hono<AppEnv>();
|
export const invoicesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// Convert Zod validation errors from 422 to 400
|
||||||
|
invoicesRouter.onError((err, c) => {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
return c.json({ error: "Validation failed", issues: err.issues }, 400);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
const createInvoiceSchema = z.object({
|
const createInvoiceSchema = z.object({
|
||||||
appointmentId: z.string().uuid().optional(),
|
appointmentId: z.string().uuid().optional(),
|
||||||
clientId: z.string().uuid(),
|
clientId: z.string().uuid(),
|
||||||
@@ -42,6 +50,13 @@ const updateInvoiceSchema = z.object({
|
|||||||
taxCents: z.number().int().nonnegative().optional(),
|
taxCents: z.number().int().nonnegative().optional(),
|
||||||
tipCents: z.number().int().nonnegative().optional(),
|
tipCents: z.number().int().nonnegative().optional(),
|
||||||
notes: z.string().max(2000).nullable().optional(),
|
notes: z.string().max(2000).nullable().optional(),
|
||||||
|
tipSplits: z.array(
|
||||||
|
z.object({
|
||||||
|
staffId: z.string().uuid().nullable(),
|
||||||
|
staffName: z.string().min(1).max(200),
|
||||||
|
sharePct: z.number().min(0).max(100),
|
||||||
|
})
|
||||||
|
).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// List invoices
|
// List invoices
|
||||||
@@ -86,6 +101,8 @@ invoicesRouter.get(
|
|||||||
paymentMethod: invoices.paymentMethod,
|
paymentMethod: invoices.paymentMethod,
|
||||||
paidAt: invoices.paidAt,
|
paidAt: invoices.paidAt,
|
||||||
notes: invoices.notes,
|
notes: invoices.notes,
|
||||||
|
stripePaymentIntentId: invoices.stripePaymentIntentId,
|
||||||
|
stripeRefundId: invoices.stripeRefundId,
|
||||||
createdAt: invoices.createdAt,
|
createdAt: invoices.createdAt,
|
||||||
updatedAt: invoices.updatedAt,
|
updatedAt: invoices.updatedAt,
|
||||||
})
|
})
|
||||||
@@ -113,7 +130,17 @@ invoicesRouter.get("/:id", async (c) => {
|
|||||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return c.json({ ...invoice, lineItems, tipSplits });
|
let cardLast4: string | null = null;
|
||||||
|
let paymentStatus: string | null = null;
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||||
|
if (details) {
|
||||||
|
cardLast4 = details.cardLast4;
|
||||||
|
paymentStatus = details.paymentStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save tip splits for an invoice (replaces existing splits)
|
// Save tip splits for an invoice (replaces existing splits)
|
||||||
@@ -334,7 +361,23 @@ invoicesRouter.patch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
const tipCents = body.tipCents ?? current.tipCents;
|
||||||
|
|
||||||
|
// Validate tip splits when marking invoice as paid
|
||||||
|
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||||
|
if (body.tipSplits.length === 0) {
|
||||||
|
return c.json({ error: "Tip splits are required when tip amount is greater than zero" }, 400);
|
||||||
|
}
|
||||||
|
const totalPct = body.tipSplits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||||
|
if (Math.abs(totalPct - 100) > 0.01) {
|
||||||
|
return c.json({ error: "Tip split percentages must sum to 100%" }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructure tipSplits out — it belongs to a separate table, not the invoices column
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { tipSplits: _tipSplits, ...updateBody } = body as Record<string, unknown>;
|
||||||
|
const update: Record<string, unknown> = { ...updateBody, updatedAt: new Date() };
|
||||||
|
|
||||||
// Auto-set paidAt when marking as paid
|
// Auto-set paidAt when marking as paid
|
||||||
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
if (body.status === "paid" && !body.paidAt && !current.paidAt) {
|
||||||
@@ -348,16 +391,42 @@ invoicesRouter.patch(
|
|||||||
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
update.totalCents = current.subtotalCents + newTaxCents + newTipCents;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
// Wrap tip split persistence and invoice update in a single atomic transaction
|
||||||
.update(invoices)
|
const [updated, lineItems] = await db.transaction(async (tx) => {
|
||||||
.set(update)
|
if (body.status === "paid" && tipCents > 0 && body.tipSplits !== undefined) {
|
||||||
.where(eq(invoices.id, id))
|
await tx.delete(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id));
|
||||||
.returning();
|
const splits = body.tipSplits;
|
||||||
|
if (splits.length > 0) {
|
||||||
|
let remaining = tipCents;
|
||||||
|
const rows = splits.map((s, i) => {
|
||||||
|
const isLast = i === splits.length - 1;
|
||||||
|
const shareCents = isLast ? remaining : Math.round((s.sharePct / 100) * tipCents);
|
||||||
|
if (!isLast) remaining -= shareCents;
|
||||||
|
return {
|
||||||
|
invoiceId: id,
|
||||||
|
staffId: s.staffId,
|
||||||
|
staffName: s.staffName,
|
||||||
|
sharePct: s.sharePct.toFixed(2),
|
||||||
|
shareCents,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await tx.insert(invoiceTipSplits).values(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lineItems = await db
|
const [updatedInvoice] = await tx
|
||||||
.select()
|
.update(invoices)
|
||||||
.from(invoiceLineItems)
|
.set(update)
|
||||||
.where(eq(invoiceLineItems.invoiceId, id));
|
.where(eq(invoices.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const lineItems = await tx
|
||||||
|
.select()
|
||||||
|
.from(invoiceLineItems)
|
||||||
|
.where(eq(invoiceLineItems.invoiceId, id));
|
||||||
|
|
||||||
|
return [updatedInvoice, lineItems];
|
||||||
|
});
|
||||||
|
|
||||||
return c.json({ ...updated, lineItems });
|
return c.json({ ...updated, lineItems });
|
||||||
}
|
}
|
||||||
@@ -365,7 +434,7 @@ invoicesRouter.patch(
|
|||||||
|
|
||||||
// ─── Refund ───────────────────────────────────────────────────────────────────
|
// ─── Refund ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { processRefund } from "../services/payment.js";
|
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
|
||||||
|
|
||||||
const refundSchema = z.object({
|
const refundSchema = z.object({
|
||||||
amountCents: z.number().int().nonnegative().optional(),
|
amountCents: z.number().int().nonnegative().optional(),
|
||||||
@@ -391,9 +460,6 @@ invoicesRouter.post(
|
|||||||
if (invoice.status !== "paid") {
|
if (invoice.status !== "paid") {
|
||||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
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) => {
|
return await db.transaction(async (tx) => {
|
||||||
if (body.idempotencyKey) {
|
if (body.idempotencyKey) {
|
||||||
@@ -406,17 +472,100 @@ invoicesRouter.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await processRefund(id, body.amountCents);
|
let refundId: string;
|
||||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const result = await processRefund(id, body.amountCents);
|
||||||
|
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||||
|
refundId = result.refundId;
|
||||||
|
} else {
|
||||||
|
// Manual refund — no Stripe call needed
|
||||||
|
refundId = `manual_${id}_${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
await tx.insert(refunds).values({
|
await tx.insert(refunds).values({
|
||||||
invoiceId: id,
|
invoiceId: id,
|
||||||
stripeRefundId: result.refundId,
|
stripeRefundId: refundId,
|
||||||
idempotencyKey: body.idempotencyKey ?? null,
|
idempotencyKey: body.idempotencyKey ?? null,
|
||||||
amountCents: body.amountCents ?? null,
|
amountCents: body.amountCents ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ refundId: result.refundId });
|
return c.json({ refundId });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Payment stats for admin dashboard
|
||||||
|
invoicesRouter.get("/stats/summary", async (c) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
|
const [revenueResult] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
|
||||||
|
|
||||||
|
const [outstandingResult] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.status, "pending"));
|
||||||
|
|
||||||
|
const [refundsResult] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
|
||||||
|
.from(refunds)
|
||||||
|
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
|
||||||
|
|
||||||
|
const methodBreakdown = await db
|
||||||
|
.select({
|
||||||
|
method: invoices.paymentMethod,
|
||||||
|
total: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
|
||||||
|
.groupBy(invoices.paymentMethod);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
revenueThisMonth: revenueResult?.total ?? 0,
|
||||||
|
outstanding: outstandingResult?.total ?? 0,
|
||||||
|
refundsThisMonth: refundsResult?.total ?? 0,
|
||||||
|
methodBreakdown,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("stats/summary error:", err);
|
||||||
|
return c.json({
|
||||||
|
revenueThisMonth: 0,
|
||||||
|
outstanding: 0,
|
||||||
|
refundsThisMonth: 0,
|
||||||
|
methodBreakdown: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
|
||||||
|
invoicesRouter.get("/:id/stripe-details", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||||
|
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
let cardLast4: string | null = null;
|
||||||
|
let paymentStatus: string | null = null;
|
||||||
|
|
||||||
|
if (invoice.stripePaymentIntentId) {
|
||||||
|
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||||
|
if (details) {
|
||||||
|
cardLast4 = details.cardLast4;
|
||||||
|
paymentStatus = details.paymentStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
stripePaymentIntentId: invoice.stripePaymentIntentId,
|
||||||
|
stripeRefundId: invoice.stripeRefundId,
|
||||||
|
cardLast4,
|
||||||
|
paymentStatus,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -213,7 +213,11 @@ petsRouter.post(
|
|||||||
|
|
||||||
// Delete the previous photo from storage to avoid orphaned objects
|
// Delete the previous photo from storage to avoid orphaned objects
|
||||||
if (pet.photoKey) {
|
if (pet.photoKey) {
|
||||||
await deleteObject(pet.photoKey);
|
try {
|
||||||
|
await deleteObject(pet.photoKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -240,7 +244,11 @@ petsRouter.delete("/:petId/photo", async (c) => {
|
|||||||
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
if (!pet) return c.json({ error: "Pet not found" }, 404);
|
||||||
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404);
|
||||||
|
|
||||||
await deleteObject(pet.photoKey);
|
try {
|
||||||
|
await deleteObject(pet.photoKey);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err);
|
||||||
|
}
|
||||||
await db
|
await db
|
||||||
.update(pets)
|
.update(pets)
|
||||||
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
.set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() })
|
||||||
|
|||||||
@@ -9,6 +9,68 @@ import type { PortalEnv } from "../middleware/portalSession.js";
|
|||||||
|
|
||||||
export const portalRouter = new Hono<PortalEnv>();
|
export const portalRouter = new Hono<PortalEnv>();
|
||||||
|
|
||||||
|
// Dev-mode session creation — must be registered BEFORE the /* middleware so it is
|
||||||
|
// NOT subject to validatePortalSession/portalAudit (GRO-778 fix). This endpoint creates
|
||||||
|
// the impersonation session and has no X-Impersonation-Session-Id header yet.
|
||||||
|
const devSessionSchema = z.object({
|
||||||
|
clientId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.post(
|
||||||
|
"/dev-session",
|
||||||
|
zValidator("json", devSessionSchema),
|
||||||
|
async (c) => {
|
||||||
|
if (process.env.AUTH_DISABLED !== "true") {
|
||||||
|
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, body.clientId))
|
||||||
|
.limit(1);
|
||||||
|
if (!client) {
|
||||||
|
return c.json({ error: "Client not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
|
let staffId = DEMO_STAFF_ID;
|
||||||
|
const [demoStaff] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, DEMO_STAFF_ID))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!demoStaff) {
|
||||||
|
const [firstStaff] = await db
|
||||||
|
.select({ id: staff.id })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.active, true))
|
||||||
|
.limit(1);
|
||||||
|
if (!firstStaff) {
|
||||||
|
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
||||||
|
}
|
||||||
|
staffId = firstStaff.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.insert(impersonationSessions)
|
||||||
|
.values({
|
||||||
|
staffId,
|
||||||
|
clientId: body.clientId,
|
||||||
|
reason: "dev-mode-client-portal",
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(session, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Apply middleware to all portal routes
|
// Apply middleware to all portal routes
|
||||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
portalRouter.use("/*", validatePortalSession, portalAudit);
|
||||||
|
|
||||||
@@ -40,7 +102,6 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const allAppts = await db
|
const allAppts = await db
|
||||||
.select({
|
.select({
|
||||||
id: appointments.id,
|
id: appointments.id,
|
||||||
@@ -80,10 +141,7 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
|
return c.json({ appointments: appts });
|
||||||
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
|
|
||||||
|
|
||||||
return c.json({ upcoming, past });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.get("/pets", async (c) => {
|
portalRouter.get("/pets", async (c) => {
|
||||||
@@ -91,7 +149,7 @@ portalRouter.get("/pets", async (c) => {
|
|||||||
const clientId = c.get("portalClientId");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.get("/invoices", async (c) => {
|
portalRouter.get("/invoices", async (c) => {
|
||||||
@@ -460,73 +518,4 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
|
|||||||
const ok = await detachPaymentMethod(paymentMethodId);
|
const ok = await detachPaymentMethod(paymentMethodId);
|
||||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||||
return c.json({ ok: true });
|
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.
|
|
||||||
|
|
||||||
const devSessionSchema = z.object({
|
|
||||||
clientId: z.string().uuid(),
|
|
||||||
});
|
|
||||||
|
|
||||||
portalRouter.post(
|
|
||||||
"/dev-session",
|
|
||||||
zValidator("json", devSessionSchema),
|
|
||||||
async (c) => {
|
|
||||||
if (process.env.AUTH_DISABLED !== "true") {
|
|
||||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
// Verify client exists
|
|
||||||
const [client] = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.id, body.clientId))
|
|
||||||
.limit(1);
|
|
||||||
if (!client) {
|
|
||||||
return c.json({ error: "Client not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find a staff record to associate with the dev impersonation session.
|
|
||||||
// Use the demo-manager if it exists (created by seed with known ID),
|
|
||||||
// otherwise fall back to the first active staff record.
|
|
||||||
// This avoids hardcoding a UUID that may not exist in all environments.
|
|
||||||
const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001";
|
|
||||||
|
|
||||||
let staffId = DEMO_STAFF_ID;
|
|
||||||
const [demoStaff] = await db
|
|
||||||
.select({ id: staff.id })
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.id, DEMO_STAFF_ID))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!demoStaff) {
|
|
||||||
// Fall back to any active staff member
|
|
||||||
const [firstStaff] = await db
|
|
||||||
.select({ id: staff.id })
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.active, true))
|
|
||||||
.limit(1);
|
|
||||||
if (!firstStaff) {
|
|
||||||
return c.json({ error: "No staff records found. Run the database seed." }, 500);
|
|
||||||
}
|
|
||||||
staffId = firstStaff.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [session] = await db
|
|
||||||
.insert(impersonationSessions)
|
|
||||||
.values({
|
|
||||||
staffId,
|
|
||||||
clientId: body.clientId,
|
|
||||||
reason: "dev-mode-client-portal",
|
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return c.json(session, 201);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -2,7 +2,7 @@ import { Hono } from "hono";
|
|||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { eq, getDb, businessSettings } from "@groombook/db";
|
import { eq, getDb, businessSettings } from "@groombook/db";
|
||||||
import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject } from "../lib/s3.js";
|
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
|
||||||
import { requireSuperUser } from "../middleware/rbac.js";
|
import { requireSuperUser } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const settingsRouter = new Hono();
|
export const settingsRouter = new Hono();
|
||||||
@@ -100,6 +100,77 @@ settingsRouter.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/settings/logo/upload
|
||||||
|
* Proxy upload through the API server to avoid mixed-content issues with
|
||||||
|
* pre-signed URLs that use the internal HTTP endpoint. The file is uploaded
|
||||||
|
* directly to S3 from the server using the internal endpoint.
|
||||||
|
*/
|
||||||
|
settingsRouter.post("/logo/upload", requireSuperUser(), async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Parse multipart form data (file field)
|
||||||
|
const body = await c.req.parseBody({ all: true });
|
||||||
|
const file = body["file"];
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return c.json({ error: "No file provided" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = file.type;
|
||||||
|
if (!ALLOWED_LOGO_TYPES.has(contentType)) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"contentType must be one of: image/png, image/svg+xml, image/jpeg, image/webp",
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSizeBytes = file.size;
|
||||||
|
if (fileSizeBytes > MAX_LOGO_SIZE) {
|
||||||
|
return c.json({ error: "File must not exceed 512 KB" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.select().from(businessSettings).limit(1);
|
||||||
|
if (!rows[0]) {
|
||||||
|
return c.json({ error: "Settings not found" }, 404);
|
||||||
|
}
|
||||||
|
const settingsId = rows[0].id;
|
||||||
|
|
||||||
|
const ext = contentType.split("/")[1] ?? "png";
|
||||||
|
const key = `logos/${settingsId}/${Date.now()}.${ext}`;
|
||||||
|
|
||||||
|
// Read file into buffer and upload directly to S3 (bypasses pre-signed URL)
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
await putObject(key, buffer, contentType, fileSizeBytes);
|
||||||
|
|
||||||
|
// Delete previous S3 object if any
|
||||||
|
if (rows[0].logoKey) {
|
||||||
|
await deleteObject(rows[0].logoKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database with new logo key
|
||||||
|
const [updated] = await db
|
||||||
|
.update(businessSettings)
|
||||||
|
.set({
|
||||||
|
logoKey: key,
|
||||||
|
logoBase64: null,
|
||||||
|
logoMimeType: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(businessSettings.id, settingsId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return c.json({ error: "Settings not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, logoKey: updated.logoKey });
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/settings/logo/confirm
|
* POST /api/admin/settings/logo/confirm
|
||||||
* Called after the client has successfully uploaded to the presigned URL.
|
* Called after the client has successfully uploaded to the presigned URL.
|
||||||
@@ -144,7 +215,8 @@ settingsRouter.post(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/settings/logo
|
* GET /api/admin/settings/logo
|
||||||
* Returns a presigned GET URL for the logo.
|
* Proxies the logo from S3 so the browser never sees an S3 URL.
|
||||||
|
* Returns the image bytes with proper Content-Type.
|
||||||
*/
|
*/
|
||||||
settingsRouter.get("/logo", async (c) => {
|
settingsRouter.get("/logo", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -153,8 +225,14 @@ settingsRouter.get("/logo", async (c) => {
|
|||||||
if (!row) return c.json({ error: "Settings not found" }, 404);
|
if (!row) return c.json({ error: "Settings not found" }, 404);
|
||||||
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
if (!row.logoKey) return c.json({ error: "No logo on file" }, 404);
|
||||||
|
|
||||||
const url = await getPresignedGetUrl(row.logoKey);
|
const { body, contentType } = await getObject(row.logoKey);
|
||||||
return c.json({ url, logoKey: row.logoKey });
|
return new Response(Buffer.from(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const RATE_LIMIT_MAX = 10;
|
|||||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
|
||||||
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
|
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
|
||||||
const now = Date.now();
|
|
||||||
const entry = rateLimitMap.get(ip);
|
const entry = rateLimitMap.get(ip);
|
||||||
|
const now = Date.now();
|
||||||
if (!entry || now > entry.resetAt) {
|
if (!entry || now > entry.resetAt) {
|
||||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||||||
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
|
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
|
||||||
|
|||||||
@@ -162,3 +162,19 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec
|
|||||||
|
|
||||||
return { clientSecret: setupIntent.client_secret! };
|
return { clientSecret: setupIntent.client_secret! };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPaymentIntentDetails(
|
||||||
|
paymentIntentId: string
|
||||||
|
): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return null;
|
||||||
|
|
||||||
|
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] });
|
||||||
|
const cardLast4 = pi.payment_method
|
||||||
|
? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
cardLast4,
|
||||||
|
paymentStatus: pi.status ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
eq,
|
eq,
|
||||||
getDb,
|
getDb,
|
||||||
gte,
|
gte,
|
||||||
|
inArray,
|
||||||
lt,
|
lt,
|
||||||
appointments,
|
appointments,
|
||||||
clients,
|
clients,
|
||||||
@@ -59,68 +60,77 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
||||||
|
if (appointmentIds.length === 0) continue;
|
||||||
|
|
||||||
|
// Bulk check: which appointments already have email and SMS reminders sent?
|
||||||
|
const sentRows = await db
|
||||||
|
.select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel })
|
||||||
|
.from(reminderLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminderLogs.reminderType, window.label),
|
||||||
|
appointmentIds.length === 1
|
||||||
|
? eq(reminderLogs.appointmentId, appointmentIds[0]!)
|
||||||
|
: inArray(reminderLogs.appointmentId, appointmentIds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sentEmail = new Set(
|
||||||
|
sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId)
|
||||||
|
);
|
||||||
|
const sentSms = new Set(
|
||||||
|
sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bulk JOIN: fetch all client/pet/service/staff data in one query
|
||||||
|
const joinedRows = await db
|
||||||
|
.select({
|
||||||
|
appointmentId: appointments.id,
|
||||||
|
startTime: appointments.startTime,
|
||||||
|
clientId: appointments.clientId,
|
||||||
|
petId: appointments.petId,
|
||||||
|
serviceId: appointments.serviceId,
|
||||||
|
staffId: appointments.staffId,
|
||||||
|
confirmationToken: appointments.confirmationToken,
|
||||||
|
clientName: clients.name,
|
||||||
|
clientEmail: clients.email,
|
||||||
|
clientEmailOptOut: clients.emailOptOut,
|
||||||
|
clientSmsOptIn: clients.smsOptIn,
|
||||||
|
clientPhone: clients.phone,
|
||||||
|
petName: pets.name,
|
||||||
|
serviceName: services.name,
|
||||||
|
staffName: staff.name,
|
||||||
|
})
|
||||||
|
.from(appointments)
|
||||||
|
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||||
|
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||||
|
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||||
|
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(appointments.startTime, windowStart),
|
||||||
|
lt(appointments.startTime, windowEnd),
|
||||||
|
eq(appointments.status, "scheduled")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const appointmentMap = new Map<string, typeof joinedRows[number]>();
|
||||||
|
for (const row of joinedRows) {
|
||||||
|
appointmentMap.set(row.appointmentId, row);
|
||||||
|
}
|
||||||
|
|
||||||
for (const appt of upcoming) {
|
for (const appt of upcoming) {
|
||||||
const [emailLog] = await db
|
const joined = appointmentMap.get(appt.id as string);
|
||||||
.select({ id: reminderLogs.id })
|
if (!joined) continue;
|
||||||
.from(reminderLogs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
|
||||||
eq(reminderLogs.reminderType, window.label),
|
|
||||||
eq(reminderLogs.channel, "email")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [smsLog] = await db
|
const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined;
|
||||||
.select({ id: reminderLogs.id })
|
|
||||||
.from(reminderLogs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
|
||||||
eq(reminderLogs.reminderType, window.label),
|
|
||||||
eq(reminderLogs.channel, "sms")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [client] = await db
|
if (!clientEmail || clientEmailOptOut) continue;
|
||||||
.select({
|
if (!petName || !serviceName) continue;
|
||||||
name: clients.name,
|
|
||||||
email: clients.email,
|
|
||||||
emailOptOut: clients.emailOptOut,
|
|
||||||
smsOptIn: clients.smsOptIn,
|
|
||||||
phone: clients.phone,
|
|
||||||
})
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.id, appt.clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!client || !client.email || client.emailOptOut) continue;
|
const emailSent = sentEmail.has(appt.id as string);
|
||||||
|
const smsSent = sentSms.has(appt.id as string);
|
||||||
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) continue;
|
|
||||||
|
|
||||||
let confirmationToken = appt.confirmationToken;
|
let confirmationToken = appt.confirmationToken;
|
||||||
if (!confirmationToken) {
|
if (!confirmationToken) {
|
||||||
@@ -131,15 +141,15 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
.where(eq(appointments.id, appt.id));
|
.where(eq(appointments.id, appt.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emailLog) {
|
if (!emailSent) {
|
||||||
const sent = await sendEmail(
|
const sent = await sendEmail(
|
||||||
buildReminderEmail(
|
buildReminderEmail(
|
||||||
client.email,
|
clientEmail,
|
||||||
{
|
{
|
||||||
clientName: client.name,
|
clientName,
|
||||||
petName: pet.name,
|
petName,
|
||||||
serviceName: service.name,
|
serviceName,
|
||||||
groomerName,
|
groomerName: staffName,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
},
|
},
|
||||||
window.hours,
|
window.hours,
|
||||||
@@ -155,20 +165,20 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!smsLog && client.smsOptIn && client.phone) {
|
if (!smsSent && clientSmsOptIn && clientPhone) {
|
||||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
||||||
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
||||||
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
||||||
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
||||||
const smsBody = [
|
const smsBody = [
|
||||||
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
|
`Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`,
|
||||||
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
|
`Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`,
|
||||||
`Confirm: ${confirmUrl}`,
|
`Confirm: ${confirmUrl}`,
|
||||||
`Cancel: ${cancelUrl}`,
|
`Cancel: ${cancelUrl}`,
|
||||||
TCPA_OPT_OUT,
|
TCPA_OPT_OUT,
|
||||||
].join(". ");
|
].join(". ");
|
||||||
try {
|
try {
|
||||||
const smsOk = await smsSend(client.phone, smsBody);
|
const smsOk = await smsSend(clientPhone, smsBody);
|
||||||
if (smsOk) {
|
if (smsOk) {
|
||||||
await db
|
await db
|
||||||
.insert(reminderLogs)
|
.insert(reminderLogs)
|
||||||
|
|||||||
@@ -63,3 +63,52 @@ test("clicking a client shows their details", async ({ page }) => {
|
|||||||
// Email appears in both the list row and the detail panel once selected
|
// Email appears in both the list row and the detail panel once selected
|
||||||
await expect(page.getByText("alice@example.com")).toHaveCount(2);
|
await expect(page.getByText("alice@example.com")).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("direct URL navigation to client detail fetches data and renders client name", async ({ page }) => {
|
||||||
|
// Mock individual client fetch for direct navigation
|
||||||
|
await page.route("/api/clients/client-1", (route) =>
|
||||||
|
route.fulfill({ json: MOCK_CLIENTS[0] })
|
||||||
|
);
|
||||||
|
// Mock pets for this client
|
||||||
|
await page.route("/api/pets**", (route) =>
|
||||||
|
route.fulfill({ json: [] })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/admin/clients/client-1");
|
||||||
|
// Client name must be visible without any clicking
|
||||||
|
await expect(page.getByText("Alice Johnson")).toBeVisible();
|
||||||
|
// Should show back to list link
|
||||||
|
await expect(page.getByText("← Back to list")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct URL navigation shows loading then client", async ({ page }) => {
|
||||||
|
let resolvePets: (value: unknown) => void;
|
||||||
|
const petsPromise = new Promise((resolve) => { resolvePets = resolve; });
|
||||||
|
|
||||||
|
await page.route("/api/clients/client-1", (route) =>
|
||||||
|
route.fulfill({ json: MOCK_CLIENTS[0] })
|
||||||
|
);
|
||||||
|
await page.route("/api/pets**", async (route) => {
|
||||||
|
await petsPromise;
|
||||||
|
await route.fulfill({ json: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigationPromise = page.goto("/admin/clients/client-1");
|
||||||
|
// Should show loading state briefly
|
||||||
|
await expect(page.getByText("Loading client…")).toBeVisible();
|
||||||
|
// Resolve pets and wait for navigation
|
||||||
|
resolvePets!();
|
||||||
|
await navigationPromise;
|
||||||
|
// After data loads, client name is shown
|
||||||
|
await expect(page.getByText("Alice Johnson")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("direct URL navigation shows error state on failure", async ({ page }) => {
|
||||||
|
await page.route("/api/clients/nonexistent", (route) =>
|
||||||
|
route.fulfill({ status: 404, json: { error: "Client not found" } })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/admin/clients/nonexistent");
|
||||||
|
await expect(page.getByText(/client not found/i)).toBeVisible();
|
||||||
|
await expect(page.getByText("← Back to clients")).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ test.beforeEach(async ({ page }) => {
|
|||||||
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (url.includes("/api/invoices/stats/summary")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
revenueThisMonth: 0,
|
||||||
|
outstanding: 0,
|
||||||
|
refundsThisMonth: 0,
|
||||||
|
methodBreakdown: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (url.includes("/api/invoices")) {
|
if (url.includes("/api/invoices")) {
|
||||||
return route.fulfill({ json: { data: [], total: 0 } });
|
return route.fulfill({ json: { data: [], total: 0 } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
/**
|
/**
|
||||||
* Playwright configuration for GroomBook Web E2E tests.
|
* Playwright configuration for GroomBook Web E2E tests.
|
||||||
*
|
*
|
||||||
* Targets the deployed dev environment at groombook.dev.farh.net.
|
* Targets the deployed dev environment at dev.groombook.dev.
|
||||||
* Uses the dev login selector (/login) for authentication — no hardcoded credentials.
|
* Uses the dev login selector (/login) for authentication — no hardcoded credentials.
|
||||||
*
|
*
|
||||||
* Run locally:
|
* Run locally:
|
||||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
reporter: process.env.CI ? "github" : "list",
|
reporter: process.env.CI ? "github" : "list",
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: "https://groombook.dev.farh.net",
|
baseURL: "https://dev.groombook.dev",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
serviceWorkers: "block",
|
serviceWorkers: "block",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-r
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||||
import { ClientsPage } from "./pages/Clients.js";
|
import { ClientsPage } from "./pages/Clients.js";
|
||||||
|
import { ClientDetailPage } from "./pages/ClientDetailPage.js";
|
||||||
import { ServicesPage } from "./pages/Services.js";
|
import { ServicesPage } from "./pages/Services.js";
|
||||||
import { StaffPage } from "./pages/Staff.js";
|
import { StaffPage } from "./pages/Staff.js";
|
||||||
import { InvoicesPage } from "./pages/Invoices.js";
|
import { InvoicesPage } from "./pages/Invoices.js";
|
||||||
@@ -12,7 +13,7 @@ import { SettingsPage } from "./pages/Settings.js";
|
|||||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||||
import { SetupWizard } from "./pages/SetupWizard.jsx";
|
import { SetupWizard } from "./pages/SetupWizard.tsx";
|
||||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
@@ -296,6 +297,7 @@ function AdminLayout() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppointmentsPage />} />
|
<Route path="/" element={<AppointmentsPage />} />
|
||||||
<Route path="/clients" element={<ClientsPage />} />
|
<Route path="/clients" element={<ClientsPage />} />
|
||||||
|
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
|
||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
<Route path="/staff" element={<StaffPage />} />
|
<Route path="/staff" element={<StaffPage />} />
|
||||||
<Route path="/invoices" element={<InvoicesPage />} />
|
<Route path="/invoices" element={<InvoicesPage />} />
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ describe("CustomerNotesSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/notes",
|
"/api/portal/appointments/appt-1/notes",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"Authorization": "Bearer test-session-id",
|
"X-Impersonation-Session-Id": "test-session-id",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -269,7 +269,7 @@ describe("ConfirmationSection", () => {
|
|||||||
"/api/portal/appointments/appt-1/confirm",
|
"/api/portal/appointments/appt-1/confirm",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
"Authorization": "Bearer test-session-id",
|
"X-Impersonation-Session-Id": "test-session-id",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function GlobalSearch() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<SearchResults | null>(null);
|
const [results, setResults] = useState<SearchResults | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -45,15 +46,18 @@ export function GlobalSearch() {
|
|||||||
|
|
||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data: SearchResults = await res.json();
|
const data: SearchResults = await res.json();
|
||||||
setResults(data);
|
setResults(data);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
} else {
|
||||||
|
setError("Search failed. Please try again.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.warn("GlobalSearch: fetch error", err);
|
setError("Search failed. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -160,7 +164,13 @@ export function GlobalSearch() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !hasResults && (
|
{!loading && error && (
|
||||||
|
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && !hasResults && (
|
||||||
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
|
||||||
No results found
|
No results found
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ export function PetPhotoUpload({ petId, onUploaded }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||||
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -112,9 +112,17 @@ export function AppointmentsPage() {
|
|||||||
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
|
||||||
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
// null key = unassigned; staffId string = that groomer; undefined set = all visible
|
||||||
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
|
||||||
|
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||||
|
|
||||||
const weekEnd = addDays(weekStart, 6);
|
const weekEnd = addDays(weekStart, 6);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/invoices/stats/summary")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((data) => { if (data) setPaymentStats(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadAppointments = useCallback(() => {
|
const loadAppointments = useCallback(() => {
|
||||||
const from = weekStart.toISOString();
|
const from = weekStart.toISOString();
|
||||||
const to = addDays(weekStart, 7).toISOString();
|
const to = addDays(weekStart, 7).toISOString();
|
||||||
@@ -273,7 +281,15 @@ export function AppointmentsPage() {
|
|||||||
cascade !== "this_only"
|
cascade !== "this_only"
|
||||||
? `/api/appointments/${id}?cascade=${cascade}`
|
? `/api/appointments/${id}?cascade=${cascade}`
|
||||||
: `/api/appointments/${id}`;
|
: `/api/appointments/${id}`;
|
||||||
await fetch(url, { method: "DELETE" });
|
try {
|
||||||
|
const res = await fetch(url, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : "Failed to delete appointment");
|
||||||
|
}
|
||||||
setSelectedAppt(null);
|
setSelectedAppt(null);
|
||||||
await loadAppointments();
|
await loadAppointments();
|
||||||
}
|
}
|
||||||
@@ -306,6 +322,24 @@ export function AppointmentsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Stats Summary */}
|
||||||
|
{paymentStats && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
||||||
|
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>${(paymentStats.outstanding / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── View Mode + Groomer Filters ── */}
|
{/* ── View Mode + Groomer Filters ── */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
|
||||||
@@ -819,8 +853,49 @@ function AppointmentDetail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement;
|
||||||
|
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const firstFocusable = focusableElements?.[0];
|
||||||
|
firstFocusable?.focus();
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
if (!modalRef.current) return;
|
||||||
|
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
previouslyFocused?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -833,6 +908,7 @@ function Modal({ children, onClose }: { children: React.ReactNode; onClose: () =
|
|||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={modalRef}
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useParams, Link } from "react-router-dom";
|
||||||
|
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||||
|
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||||
|
import { PetPhotoUpload } from "../components/PetPhotoUpload.js";
|
||||||
|
|
||||||
|
export function ClientDetailPage() {
|
||||||
|
const { clientId } = useParams<{ clientId: string }>();
|
||||||
|
const [client, setClient] = useState<Client | null>(null);
|
||||||
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
|
const [visitLogs, setVisitLogs] = useState<Record<string, GroomingVisitLog[]>>({});
|
||||||
|
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [photoRevisions, setPhotoRevisions] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
const handlePhotoUploaded = useCallback((petId: string) => {
|
||||||
|
setPhotoRevisions((prev) => ({ ...prev, [petId]: (prev[petId] ?? 0) + 1 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientId) {
|
||||||
|
setError("No client ID provided");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const id = clientId!;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [clientRes, petsRes] = await Promise.all([
|
||||||
|
fetch(`/api/clients/${encodeURIComponent(id)}`),
|
||||||
|
fetch(`/api/pets?clientId=${encodeURIComponent(id)}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!clientRes.ok) {
|
||||||
|
const err = await clientRes.json().catch(() => ({})) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `Client fetch failed: ${clientRes.status}`);
|
||||||
|
}
|
||||||
|
if (!petsRes.ok) {
|
||||||
|
throw new Error(`Pets fetch failed: ${petsRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setClient(await clientRes.json() as Client);
|
||||||
|
setPets(await petsRes.json() as Pet[]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load client");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
async function loadVisitLogs(petId: string) {
|
||||||
|
setLogsLoading((prev) => ({ ...prev, [petId]: true }));
|
||||||
|
const r = await fetch(`/api/grooming-logs?petId=${encodeURIComponent(petId)}`);
|
||||||
|
if (r.ok) {
|
||||||
|
const logs = await r.json() as GroomingVisitLog[];
|
||||||
|
setVisitLogs((prev) => ({ ...prev, [petId]: logs }));
|
||||||
|
}
|
||||||
|
setLogsLoading((prev) => ({ ...prev, [petId]: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "2rem", textAlign: "center", color: "#6b7280", fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
Loading client…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !client) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<Link to="/admin/clients" style={{ color: "#4f8a6f", fontSize: 13 }}>← Back to clients</Link>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "1rem", color: "#991b1b" }}>
|
||||||
|
{error ?? "Client not found"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: "system-ui, sans-serif" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", marginBottom: "1.5rem", gap: "1rem" }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 22 }}>{client.name}</h1>
|
||||||
|
{client.status === "disabled" && (
|
||||||
|
<span style={{ fontSize: 12, background: "#fef2f2", color: "#dc2626", padding: "0.15rem 0.5rem", borderRadius: 4, fontWeight: 500 }}>
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{client.email && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.email}</div>}
|
||||||
|
{client.phone && <div style={{ fontSize: 14, color: "#6b7280" }}>{client.phone}</div>}
|
||||||
|
{client.address && <div style={{ fontSize: 13, color: "#6b7280" }}>{client.address}</div>}
|
||||||
|
{client.notes && (
|
||||||
|
<div style={{ fontSize: 13, marginTop: "0.4rem", background: "#fef9c3", padding: "0.4rem 0.6rem", borderRadius: 4, maxWidth: 500 }}>
|
||||||
|
{client.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/admin/clients"
|
||||||
|
style={{
|
||||||
|
padding: "0.4rem 0.85rem",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "#fff",
|
||||||
|
color: "#374151",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back to list
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pets */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18 }}>Pets</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pets.length === 0 ? (
|
||||||
|
<p style={{ color: "#6b7280", fontSize: 14 }}>No pets on file for this client.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "0.75rem" }}>
|
||||||
|
{pets.map((p) => (
|
||||||
|
<div key={p.id} style={{ border: "1px solid #e5e7eb", borderRadius: 10, padding: "0.85rem", background: "#fff", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)" }}>
|
||||||
|
{/* Photo + header */}
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.4rem" }}>
|
||||||
|
<PetPhotoDisplay
|
||||||
|
petId={p.id}
|
||||||
|
size={56}
|
||||||
|
key={`${p.id}-photo-${photoRevisions[p.id] ?? 0}`}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<strong style={{ fontSize: 15 }}>{p.name}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280", marginTop: "0.15rem" }}>
|
||||||
|
{p.species}{p.breed ? ` · ${p.breed}` : ""}
|
||||||
|
</div>
|
||||||
|
{p.weightKg != null && <div style={{ fontSize: 12, color: "#6b7280" }}>{p.weightKg} kg</div>}
|
||||||
|
{p.dateOfBirth && <div style={{ fontSize: 12, color: "#6b7280" }}>Born {new Date(p.dateOfBirth).toLocaleDateString()}</div>}
|
||||||
|
<div style={{ marginTop: "0.3rem" }}>
|
||||||
|
<PetPhotoUpload petId={p.id} onUploaded={() => handlePhotoUploaded(p.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.healthAlerts && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: "0.35rem", background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#dc2626" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>⚠ Health alerts:</span> {p.healthAlerts}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grooming preferences */}
|
||||||
|
{(p.cutStyle || p.shampooPreference || p.specialCareNotes || p.groomingNotes) && (
|
||||||
|
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
|
||||||
|
{p.cutStyle && (
|
||||||
|
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Cut:</span> {p.cutStyle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.shampooPreference && (
|
||||||
|
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Shampoo:</span> {p.shampooPreference}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.specialCareNotes && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: "0.2rem", background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 4, padding: "0.3rem 0.5rem", color: "#92400e" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Special care:</span> {p.specialCareNotes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.groomingNotes && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: "0.2rem", color: "#374151" }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Notes:</span> {p.groomingNotes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visit history */}
|
||||||
|
{(() => {
|
||||||
|
const logs = visitLogs[p.id];
|
||||||
|
const loadingLogs = logsLoading[p.id];
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: "0.5rem", borderTop: "1px solid #f3f4f6", paddingTop: "0.4rem" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.25rem" }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: "#6b7280" }}>VISIT HISTORY</div>
|
||||||
|
{!logs && !loadingLogs && (
|
||||||
|
<button
|
||||||
|
onClick={() => { void loadVisitLogs(p.id); }}
|
||||||
|
style={{ fontSize: 11, color: "#4f8a6f", background: "none", border: "none", cursor: "pointer", padding: 0 }}
|
||||||
|
>
|
||||||
|
Load history
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loadingLogs && <div style={{ fontSize: 11, color: "#9ca3af" }}>Loading…</div>}
|
||||||
|
{logs && logs.length === 0 && <div style={{ fontSize: 11, color: "#9ca3af" }}>No visits yet</div>}
|
||||||
|
{logs && logs.length > 0 && (
|
||||||
|
<>
|
||||||
|
{logs.slice(0, 3).map((log) => (
|
||||||
|
<div key={log.id} style={{ fontSize: 11, color: "#374151", marginBottom: "0.2rem", borderLeft: "2px solid #e2e8f0", paddingLeft: "0.4rem" }}>
|
||||||
|
<span style={{ color: "#6b7280" }}>{new Date(log.groomedAt).toLocaleDateString()}</span>
|
||||||
|
{log.cutStyle && <span> · {log.cutStyle}</span>}
|
||||||
|
{log.notes && <span> · {log.notes}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{logs.length > 3 && (
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>+{logs.length - 3} more visits</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef, useId } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||||
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js";
|
||||||
@@ -647,8 +647,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Client modal ── */}
|
{/* ── Client modal ── */}
|
||||||
{showClientForm && (
|
{showClientForm && (
|
||||||
<Modal onClose={() => setShowClientForm(false)}>
|
<Modal title={editingClient ? "Edit Client" : "New Client"} onClose={() => setShowClientForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>{editingClient ? "Edit Client" : "New Client"}</h2>
|
|
||||||
<form onSubmit={submitClient}>
|
<form onSubmit={submitClient}>
|
||||||
<Field label="Full name">
|
<Field label="Full name">
|
||||||
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
<input value={clientForm.name} onChange={(e) => setClientForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||||
@@ -678,8 +677,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Pet modal ── */}
|
{/* ── Pet modal ── */}
|
||||||
{showPetForm && (
|
{showPetForm && (
|
||||||
<Modal onClose={() => setShowPetForm(false)}>
|
<Modal title={editingPet ? "Edit Pet" : "Add Pet"} onClose={() => setShowPetForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>{editingPet ? "Edit Pet" : "Add Pet"}</h2>
|
|
||||||
<form onSubmit={submitPet}>
|
<form onSubmit={submitPet}>
|
||||||
<Field label="Pet name">
|
<Field label="Pet name">
|
||||||
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
<input value={petForm.name} onChange={(e) => setPetForm((f) => ({ ...f, name: e.target.value }))} required style={inputStyle} />
|
||||||
@@ -753,8 +751,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Visit log modal ── */}
|
{/* ── Visit log modal ── */}
|
||||||
{showLogForm && logPetId && (
|
{showLogForm && logPetId && (
|
||||||
<Modal onClose={() => setShowLogForm(false)}>
|
<Modal title="Log Grooming Visit" onClose={() => setShowLogForm(false)}>
|
||||||
<h2 style={{ marginTop: 0 }}>Log Grooming Visit</h2>
|
|
||||||
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
{logsLoading[logPetId] && <p style={{ fontSize: 13, color: "#6b7280" }}>Loading history…</p>}
|
||||||
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
{visitLogs[logPetId] && visitLogs[logPetId].length > 0 && (
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
@@ -817,8 +814,7 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
{/* ── Delete confirmation modal ── */}
|
{/* ── Delete confirmation modal ── */}
|
||||||
{showDeleteConfirm && selectedClient && (
|
{showDeleteConfirm && selectedClient && (
|
||||||
<Modal onClose={() => setShowDeleteConfirm(false)}>
|
<Modal title="Permanently Delete Client" titleStyle={{ color: "#dc2626" }} onClose={() => setShowDeleteConfirm(false)}>
|
||||||
<h2 style={{ marginTop: 0, color: "#dc2626" }}>Permanently Delete Client</h2>
|
|
||||||
<p style={{ fontSize: 14, color: "#374151" }}>
|
<p style={{ fontSize: 14, color: "#374151" }}>
|
||||||
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
This will permanently delete <strong>{selectedClient.name}</strong> and all their pets. This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
@@ -856,13 +852,60 @@ export function ClientsPage() {
|
|||||||
|
|
||||||
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
// ─── Shared UI ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
function Modal({ children, onClose, title, titleStyle }: { children: React.ReactNode; onClose: () => void; title: string; titleStyle?: React.CSSProperties }) {
|
||||||
|
const titleId = useId();
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement;
|
||||||
|
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const firstFocusable = focusableElements?.[0];
|
||||||
|
firstFocusable?.focus();
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
if (!modalRef.current) return;
|
||||||
|
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
previouslyFocused?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 }}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}>
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
style={{ background: "#fff", borderRadius: 8, padding: "1.5rem", maxWidth: 480, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.3)" }}
|
||||||
|
>
|
||||||
|
<h2 id={titleId} style={{ marginTop: 0, ...titleStyle }}>{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+223
-29
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -173,6 +173,21 @@ function InvoiceDetailModal({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
||||||
|
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||||
|
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
||||||
|
const [refundAmount, setRefundAmount] = useState("");
|
||||||
|
const [refundError, setRefundError] = useState<string | null>(null);
|
||||||
|
const [refunding, setRefunding] = useState(false);
|
||||||
|
|
||||||
|
// Fetch current staff role to determine manager access
|
||||||
|
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/staff/me")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setStaffMe(d))
|
||||||
|
.catch(() => setStaffMe(null));
|
||||||
|
}, []);
|
||||||
|
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser);
|
||||||
|
|
||||||
// Tip split state: array of {staffId, staffName, pct}
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
const linkedAppt = invoice.appointmentId
|
const linkedAppt = invoice.appointmentId
|
||||||
@@ -211,36 +226,41 @@ function InvoiceDetailModal({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
|
const tipCents = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||||
|
// Real-time validation: prevent submit if tip splits don't sum to 100%
|
||||||
|
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||||
|
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
|
||||||
|
if (Math.abs(totalPct - 100) >= 0.01) {
|
||||||
|
setError("Tip split percentages must sum to 100%");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
|
const patchBody: {
|
||||||
|
status: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
tipCents: number;
|
||||||
|
tipSplits?: Array<{ staffId: string | null; staffName: string; sharePct: number }>;
|
||||||
|
} = { status: "paid", paymentMethod, tipCents };
|
||||||
|
|
||||||
|
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
||||||
|
patchBody.tipSplits = tipSplits.map((r) => ({
|
||||||
|
staffId: r.staffId,
|
||||||
|
staffName: r.staffName,
|
||||||
|
sharePct: r.pct,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
const res = await fetch(`/api/invoices/${invoice.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ status: "paid", paymentMethod, tipCents }),
|
body: JSON.stringify(patchBody),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = (await res.json()) as { error?: string };
|
const err = (await res.json()) as { error?: string };
|
||||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save tip splits if applicable and tip > 0
|
|
||||||
if (showSplits && tipCents > 0 && tipSplits.length > 0) {
|
|
||||||
const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0);
|
|
||||||
if (Math.abs(totalPct - 100) < 0.01) {
|
|
||||||
const splitsRes = await fetch(`/api/invoices/${invoice.id}/tip-splits`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
splits: tipSplits.map((r) => ({
|
|
||||||
staffId: r.staffId,
|
|
||||||
staffName: r.staffName,
|
|
||||||
sharePct: r.pct,
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!splitsRes.ok) console.warn("Tip split save failed (non-blocking)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdated();
|
onUpdated();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to update");
|
setError(e instanceof Error ? e.message : "Failed to update");
|
||||||
@@ -330,6 +350,19 @@ function InvoiceDetailModal({
|
|||||||
/>
|
/>
|
||||||
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
||||||
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
||||||
|
{invoice.stripePaymentIntentId && (
|
||||||
|
<>
|
||||||
|
{invoice.cardLast4 && (
|
||||||
|
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} />
|
||||||
|
)}
|
||||||
|
{invoice.paymentStatus && (
|
||||||
|
<SummaryRow label="Stripe status" value={invoice.paymentStatus} />
|
||||||
|
)}
|
||||||
|
{invoice.stripeRefundId && (
|
||||||
|
<SummaryRow label="Refund" value="Refunded" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tip Distribution ── */}
|
{/* ── Tip Distribution ── */}
|
||||||
@@ -447,11 +480,92 @@ function InvoiceDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(invoice.status === "paid" || invoice.status === "void") && (
|
{(invoice.status === "paid" || invoice.status === "void") && (
|
||||||
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}>
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
{invoice.stripeRefundId && (
|
||||||
|
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||||
|
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
|
||||||
|
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
|
||||||
|
Refund
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
|
||||||
|
{showRefundDialog && (
|
||||||
|
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}>
|
||||||
|
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p>
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||||
|
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} />
|
||||||
|
Full refund
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}>
|
||||||
|
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} />
|
||||||
|
Partial refund
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{refundType === "partial" && (
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Amount ($)"
|
||||||
|
value={refundAmount}
|
||||||
|
onChange={(e) => setRefundAmount(e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: 100 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setRefunding(true);
|
||||||
|
setRefundError(null);
|
||||||
|
try {
|
||||||
|
if (refundType === "partial") {
|
||||||
|
const parsed = parseFloat(refundAmount);
|
||||||
|
if (isNaN(parsed) || parsed <= 0) {
|
||||||
|
setRefundError("Please enter a valid amount greater than zero.");
|
||||||
|
setRefunding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
|
||||||
|
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setShowRefundDialog(false);
|
||||||
|
onUpdated();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setRefundError(e instanceof Error ? e.message : "Refund failed");
|
||||||
|
} finally {
|
||||||
|
setRefunding(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={refunding}
|
||||||
|
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
|
||||||
|
>
|
||||||
|
{refunding ? "Processing…" : "Process Refund"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,9 +606,17 @@ export function InvoicesPage() {
|
|||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/invoices/stats/summary")
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((data) => { if (data) setPaymentStats(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function loadInvoices(newOffset: number) {
|
async function loadInvoices(newOffset: number) {
|
||||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
|
||||||
if (statusFilter) params.set("status", statusFilter);
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
@@ -573,6 +695,34 @@ export function InvoicesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Stats Summary */}
|
||||||
|
{paymentStats && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
|
||||||
|
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>{fmtMoney(paymentStats.revenueThisMonth)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>{fmtMoney(paymentStats.outstanding)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
|
||||||
|
</div>
|
||||||
|
{paymentStats.methodBreakdown.length > 0 && (
|
||||||
|
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#64748b" }}>
|
||||||
|
{paymentStats.methodBreakdown.map((b) => (
|
||||||
|
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{invoiceList.length === 0 ? (
|
{invoiceList.length === 0 ? (
|
||||||
<p style={{ color: "#6b7280" }}>
|
<p style={{ color: "#6b7280" }}>
|
||||||
No invoices yet. Create one from a completed appointment.
|
No invoices yet. Create one from a completed appointment.
|
||||||
@@ -677,19 +827,63 @@ export function InvoicesPage() {
|
|||||||
// ─── Shared UI helpers ────────────────────────────────────────────────────────
|
// ─── Shared UI helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement;
|
||||||
|
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const firstFocusable = focusableElements?.[0];
|
||||||
|
firstFocusable?.focus();
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
if (!modalRef.current) return;
|
||||||
|
const focusables = modalRef.current.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
previouslyFocused?.focus();
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div
|
||||||
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
ref={modalRef}
|
||||||
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
style={{
|
||||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
||||||
}}>
|
maxWidth: 520, width: "calc(100% - 2rem)", maxHeight: "90vh", overflowY: "auto",
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -199,11 +199,11 @@ export function ReportsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
const [summData, revData, apptData, svcData, clientData] = await Promise.all([
|
||||||
summRes.json() as Promise<Summary>,
|
summRes.ok ? summRes.json() as Promise<Summary> : summRes.text().then(() => { throw new Error("summary response not ok"); }),
|
||||||
revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>,
|
revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }),
|
||||||
apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>,
|
apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }),
|
||||||
svcRes.json() as Promise<{ rows: ServiceRow[] }>,
|
svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }),
|
||||||
clientRes.json() as Promise<ClientReport>,
|
clientRes.ok ? clientRes.json() as Promise<ClientReport> : clientRes.text().then(() => { throw new Error("clients response not ok"); }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setSummary(summData);
|
setSummary(summData);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface AuthProviderForm {
|
|||||||
|
|
||||||
const REDACTED = "••••••••";
|
const REDACTED = "••••••••";
|
||||||
|
|
||||||
|
const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||||
|
|
||||||
interface CurrentUser {
|
interface CurrentUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,24 +89,14 @@ export function SettingsPage() {
|
|||||||
fetch("/api/admin/settings")
|
fetch("/api/admin/settings")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(async (data) => {
|
.then(async (data) => {
|
||||||
let logoUrl: string | null = null;
|
// The logo is now proxied through the API server so the browser
|
||||||
if (data.logoKey) {
|
// never receives an S3 URL — use the proxy path directly as the src.
|
||||||
try {
|
|
||||||
const logoRes = await fetch("/api/admin/settings/logo");
|
|
||||||
if (logoRes.ok) {
|
|
||||||
const logoData = await logoRes.json();
|
|
||||||
logoUrl = logoData.url;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setForm({
|
setForm({
|
||||||
businessName: data.businessName ?? "GroomBook",
|
businessName: data.businessName ?? "GroomBook",
|
||||||
primaryColor: data.primaryColor ?? "#4f8a6f",
|
primaryColor: data.primaryColor ?? "#4f8a6f",
|
||||||
accentColor: data.accentColor ?? "#8b7355",
|
accentColor: data.accentColor ?? "#8b7355",
|
||||||
logoKey: data.logoKey ?? null,
|
logoKey: data.logoKey ?? null,
|
||||||
logoUrl,
|
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
|
||||||
logoBase64: data.logoBase64 ?? null,
|
logoBase64: data.logoBase64 ?? null,
|
||||||
logoMimeType: data.logoMimeType ?? null,
|
logoMimeType: data.logoMimeType ?? null,
|
||||||
});
|
});
|
||||||
@@ -149,54 +141,28 @@ export function SettingsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"];
|
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." });
|
setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get presigned upload URL
|
// Upload directly through the API server to avoid mixed-content issues
|
||||||
const uploadRes = await fetch("/api/admin/settings/logo/upload-url", {
|
// with pre-signed URLs that use the internal HTTP endpoint
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const uploadRes = await fetch("/api/admin/settings/logo/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
body: formData,
|
||||||
body: JSON.stringify({ contentType: file.type, fileSizeBytes: file.size }),
|
|
||||||
});
|
});
|
||||||
if (!uploadRes.ok) {
|
if (!uploadRes.ok) {
|
||||||
const err = await uploadRes.json().catch(() => null);
|
const err = await uploadRes.json().catch(() => null);
|
||||||
throw new Error(err?.error ?? "Failed to get upload URL");
|
throw new Error(err?.error ?? "Failed to upload logo");
|
||||||
}
|
|
||||||
const { uploadUrl, key } = await uploadRes.json();
|
|
||||||
|
|
||||||
// Step 2: PUT the file directly to S3
|
|
||||||
const putRes = await fetch(uploadUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": file.type },
|
|
||||||
body: file,
|
|
||||||
});
|
|
||||||
if (!putRes.ok) {
|
|
||||||
throw new Error("Failed to upload logo to storage");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Confirm the upload
|
|
||||||
const confirmRes = await fetch("/api/admin/settings/logo/confirm", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ key }),
|
|
||||||
});
|
|
||||||
if (!confirmRes.ok) {
|
|
||||||
const err = await confirmRes.json().catch(() => null);
|
|
||||||
throw new Error(err?.error ?? "Failed to confirm logo upload");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Fetch the presigned GET URL for display
|
|
||||||
const logoRes = await fetch("/api/admin/settings/logo");
|
|
||||||
if (logoRes.ok) {
|
|
||||||
const logoData = await logoRes.json();
|
|
||||||
setForm((f) => ({ ...f, logoKey: key, logoUrl: logoData.url, logoBase64: null, logoMimeType: null }));
|
|
||||||
} else {
|
|
||||||
setForm((f) => ({ ...f, logoKey: key, logoUrl: null, logoBase64: null, logoMimeType: null }));
|
|
||||||
}
|
}
|
||||||
|
const { logoKey } = await uploadRes.json();
|
||||||
|
setForm((f) => ({ ...f, logoKey, logoUrl: `/api/admin/settings/logo?t=${Date.now()}`, logoBase64: null, logoMimeType: null }));
|
||||||
setMessage({ type: "success", text: "Logo uploaded." });
|
setMessage({ type: "success", text: "Logo uploaded." });
|
||||||
refresh();
|
refresh();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -326,7 +292,7 @@ issuerUrl: authForm.issuerUrl,
|
|||||||
|
|
||||||
if (!loaded) return <p>Loading settings...</p>;
|
if (!loaded) return <p>Loading settings...</p>;
|
||||||
|
|
||||||
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 600 }}>
|
<div style={{ maxWidth: 600 }}>
|
||||||
@@ -393,7 +359,7 @@ issuerUrl: authForm.issuerUrl,
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
onChange={handleLogoChange}
|
onChange={handleLogoChange}
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
export { SetupWizard } from "./SetupWizard.jsx";
|
export { SetupWizard } from "./SetupWizard.tsx";
|
||||||
|
|||||||
@@ -2,16 +2,39 @@ import { useState, useEffect } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
|
|
||||||
export function SetupWizard({ onSetupComplete }) {
|
interface SetupStatus {
|
||||||
|
showAuthProviderStep?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthFormState {
|
||||||
|
providerId: string;
|
||||||
|
displayName: string;
|
||||||
|
issuerUrl: string;
|
||||||
|
internalBaseUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scopes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { refresh: refreshBranding } = useBranding();
|
const { refresh: refreshBranding } = useBranding();
|
||||||
|
|
||||||
// Fetch setup status to determine if auth provider step is needed
|
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null);
|
||||||
const [setupStatus, setSetupStatus] = useState(null); // null = loading
|
|
||||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||||
|
|
||||||
// Auth provider form state
|
const [authForm, setAuthForm] = useState<AuthFormState>({
|
||||||
const [authForm, setAuthForm] = useState({
|
|
||||||
providerId: "authentik",
|
providerId: "authentik",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
issuerUrl: "",
|
issuerUrl: "",
|
||||||
@@ -21,16 +44,16 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
scopes: "openid profile email",
|
scopes: "openid profile email",
|
||||||
});
|
});
|
||||||
const [testingConnection, setTestingConnection] = useState(false);
|
const [testingConnection, setTestingConnection] = useState(false);
|
||||||
const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string}
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [businessName, setBusinessName] = useState("");
|
const [businessName, setBusinessName] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/setup/status")
|
fetch("/api/setup/status")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json() as Promise<SetupStatus>)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setSetupStatus(data);
|
setSetupStatus(data);
|
||||||
setLoadingStatus(false);
|
setLoadingStatus(false);
|
||||||
@@ -40,8 +63,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Build steps dynamically based on setup status
|
const STEPS: Step[] = setupStatus?.showAuthProviderStep
|
||||||
const STEPS = setupStatus?.showAuthProviderStep
|
|
||||||
? [
|
? [
|
||||||
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
{ id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
|
||||||
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
|
{ id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." },
|
||||||
@@ -63,9 +85,8 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
const isFirst = step === 0;
|
const isFirst = step === 0;
|
||||||
const canGoBack = step > 0 && step < STEPS.length - 1;
|
const canGoBack = step > 0 && step < STEPS.length - 1;
|
||||||
|
|
||||||
// Determine if we can proceed - depends on which step we're on
|
|
||||||
const canGoNext = (() => {
|
const canGoNext = (() => {
|
||||||
if (step === STEPS.length - 1) return true; // done step
|
if (step === STEPS.length - 1) return true;
|
||||||
if (current?.id === "business") return businessName.trim().length > 0;
|
if (current?.id === "business") return businessName.trim().length > 0;
|
||||||
if (current?.id === "auth") {
|
if (current?.id === "auth") {
|
||||||
return (
|
return (
|
||||||
@@ -94,9 +115,9 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
scopes: authForm.scopes,
|
scopes: authForm.scopes,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = (await res.json()) as TestResult;
|
||||||
setTestResult(data);
|
setTestResult(data);
|
||||||
} catch (e) {
|
} catch {
|
||||||
setTestResult({ ok: false, error: "Network error. Please try again." });
|
setTestResult({ ok: false, error: "Network error. Please try again." });
|
||||||
} finally {
|
} finally {
|
||||||
setTestingConnection(false);
|
setTestingConnection(false);
|
||||||
@@ -105,12 +126,10 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (step === STEPS.length - 1) {
|
if (step === STEPS.length - 1) {
|
||||||
// Done - redirect to admin
|
|
||||||
navigate("/admin");
|
navigate("/admin");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit auth provider config
|
|
||||||
if (current?.id === "auth") {
|
if (current?.id === "auth") {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -129,12 +148,12 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = (await res.json()) as { error?: string };
|
||||||
setError(data.error || "Failed to save auth provider configuration. Please try again.");
|
setError(data.error || "Failed to save auth provider configuration. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
setError("Network error. Please try again.");
|
setError("Network error. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -142,7 +161,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit business name and complete setup
|
|
||||||
if (current?.id === "business" && businessName.trim()) {
|
if (current?.id === "business" && businessName.trim()) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -153,16 +171,14 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
body: JSON.stringify({ businessName: businessName.trim() }),
|
body: JSON.stringify({ businessName: businessName.trim() }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = (await res.json()) as { error?: string };
|
||||||
setError(data.error || "Setup failed. Please try again.");
|
setError(data.error || "Setup failed. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Refresh branding so the nav bar shows the new business name
|
|
||||||
refreshBranding();
|
refreshBranding();
|
||||||
// Clear needsSetup state in App so the redirect to /admin sticks
|
|
||||||
if (onSetupComplete) onSetupComplete();
|
if (onSetupComplete) onSetupComplete();
|
||||||
} catch (e) {
|
} catch {
|
||||||
setError("Network error. Please try again.");
|
setError("Network error. Please try again.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -192,7 +208,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "0.6rem 0.85rem",
|
padding: "0.6rem 0.85rem",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@@ -220,7 +236,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
maxWidth: 480,
|
maxWidth: 480,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}>
|
}}>
|
||||||
{/* Progress dots */}
|
|
||||||
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
|
||||||
{STEPS.map((_, i) => (
|
{STEPS.map((_, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -237,38 +252,32 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step indicator */}
|
|
||||||
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
|
||||||
Step {step + 1} of {STEPS.length}
|
Step {step + 1} of {STEPS.length}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
|
||||||
{current?.title}
|
{current?.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
|
||||||
{current?.description}
|
{current?.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Step: Business name input */}
|
|
||||||
{current?.id === "business" && (
|
{current?.id === "business" && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Happy Paws Grooming"
|
placeholder="e.g. Happy Paws Grooming"
|
||||||
value={businessName}
|
value={businessName}
|
||||||
onChange={(e) => setBusinessName(e.target.value)}
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
|
onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Auth provider config form */}
|
|
||||||
{current?.id === "auth" && (
|
{current?.id === "auth" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.85rem" }}>
|
||||||
{/* Provider ID */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Provider ID
|
Provider ID
|
||||||
@@ -282,7 +291,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Display Name
|
Display Name
|
||||||
@@ -296,7 +304,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Issuer URL */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Issuer URL
|
Issuer URL
|
||||||
@@ -310,7 +317,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Internal Base URL (optional) */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
Internal Base URL <span style={{ fontWeight: 400, color: "#6b7280" }}>(optional, for hairpin NAT)</span>
|
||||||
@@ -324,7 +330,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client ID */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Client ID
|
Client ID
|
||||||
@@ -338,7 +343,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client Secret */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Client Secret
|
Client Secret
|
||||||
@@ -352,7 +356,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scopes */}
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
<label style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
Scopes
|
Scopes
|
||||||
@@ -366,10 +369,9 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Test Connection button */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTestConnection}
|
onClick={() => { void handleTestConnection(); }}
|
||||||
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
disabled={testingConnection || !authForm.issuerUrl || !authForm.clientId}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.45rem 0.85rem",
|
padding: "0.45rem 0.85rem",
|
||||||
@@ -387,7 +389,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
{testingConnection ? "Testing..." : "Test Connection"}
|
{testingConnection ? "Testing..." : "Test Connection"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Test result */}
|
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: "0.5rem 0.75rem",
|
padding: "0.5rem 0.75rem",
|
||||||
@@ -405,7 +406,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Super user info */}
|
|
||||||
{current?.id === "superuser" && (
|
{current?.id === "superuser" && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "#f0fdf4",
|
background: "#f0fdf4",
|
||||||
@@ -420,7 +420,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Second admin info */}
|
|
||||||
{current?.id === "admin" && (
|
{current?.id === "admin" && (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: "#fffbeb",
|
background: "#fffbeb",
|
||||||
@@ -434,7 +433,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<p style={{
|
<p style={{
|
||||||
margin: "0.5rem 0 0",
|
margin: "0.5rem 0 0",
|
||||||
@@ -449,7 +447,6 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation buttons */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
@@ -476,7 +473,7 @@ export function SetupWizard({ onSetupComplete }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={() => { void handleNext(); }}
|
||||||
disabled={(!canGoNext && !isLast) || loading}
|
disabled={(!canGoNext && !isLast) || loading}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.55rem 1.25rem",
|
padding: "0.55rem 1.25rem",
|
||||||
@@ -16,6 +16,7 @@ import { AuditLogViewer } from "./AuditLogViewer.js";
|
|||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||||
import type { ImpersonationSession } from "@groombook/types";
|
import type { ImpersonationSession } from "@groombook/types";
|
||||||
|
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||||
|
|
||||||
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings";
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export function CustomerPortal() {
|
|||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
const [showReschedule, setShowReschedule] = useState(false);
|
const [showReschedule, setShowReschedule] = useState(false);
|
||||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
|
const [rescheduleAppointment, setRescheduleAppointment] = useState<PortalAppointment | null>(null);
|
||||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||||
const [sessionExtended, setSessionExtended] = useState(false);
|
const [sessionExtended, setSessionExtended] = useState(false);
|
||||||
const [clientName, setClientName] = useState<string>("");
|
const [clientName, setClientName] = useState<string>("");
|
||||||
@@ -149,7 +150,7 @@ export function CustomerPortal() {
|
|||||||
const handleReschedule = useCallback((appointmentId: string) => {
|
const handleReschedule = useCallback((appointmentId: string) => {
|
||||||
// Look up the full appointment from Dashboard's displayed data
|
// Look up the full appointment from Dashboard's displayed data
|
||||||
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
// The appointment was already fetched by Dashboard, so we use the ID to find it
|
||||||
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
|
setRescheduleAppointment({ id: appointmentId } as PortalAppointment);
|
||||||
setShowReschedule(true);
|
setShowReschedule(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -227,7 +228,7 @@ export function CustomerPortal() {
|
|||||||
|
|
||||||
{showReschedule && rescheduleAppointment && (
|
{showReschedule && rescheduleAppointment && (
|
||||||
<RescheduleFlow
|
<RescheduleFlow
|
||||||
appointment={rescheduleAppointment as any}
|
appointment={rescheduleAppointment}
|
||||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
sessionId={session?.id ?? null}
|
sessionId={session?.id ?? null}
|
||||||
/>
|
/>
|
||||||
@@ -325,7 +326,7 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 min-h-screen">
|
<main className="flex-1 min-h-screen overflow-hidden">
|
||||||
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-stone-800">
|
<h1 className="text-lg font-semibold text-stone-800">
|
||||||
@@ -339,7 +340,7 @@ export function CustomerPortal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 md:p-8 max-w-6xl">
|
<div className="p-4 md:p-8 max-w-6xl w-full overflow-hidden">
|
||||||
{renderSection()}
|
{renderSection()}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface Appointment {
|
export interface Appointment {
|
||||||
id: string;
|
id: string;
|
||||||
petId: string;
|
petId: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@@ -379,7 +379,7 @@ export function ConfirmationSection({
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -455,7 +455,7 @@ function CancelAppointmentButton({
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -507,7 +507,7 @@ export function CustomerNotesSection({
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers['Authorization'] = `Bearer ${sessionId}`;
|
headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -600,7 +600,7 @@ export function RescheduleFlow({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (sessionId) headers['Authorization'] = `Bearer ${sessionId}`;
|
if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId ?? '';
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -784,7 +784,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${sessionId}`,
|
'X-Impersonation-Session-Id': sessionId ?? '',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
petId: selectedPet.id,
|
petId: selectedPet.id,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap overflow-x-auto">
|
||||||
{([
|
{([
|
||||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||||
@@ -356,6 +356,48 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
const [isComplete, setIsComplete] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const completeModalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const paymentModalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Focus trap + Escape-to-close for both inline modals
|
||||||
|
useEffect(() => {
|
||||||
|
const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current;
|
||||||
|
if (!modalRef) return;
|
||||||
|
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement;
|
||||||
|
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
const focusableElements = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const firstFocusable = focusableElements[0];
|
||||||
|
firstFocusable?.focus();
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !modalRef) return;
|
||||||
|
const focusables = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
previouslyFocused?.focus();
|
||||||
|
};
|
||||||
|
}, [isComplete, onClose]);
|
||||||
|
|
||||||
const formatCents = (cents: number) =>
|
const formatCents = (cents: number) =>
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
|
||||||
@@ -420,8 +462,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
|
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
<div ref={completeModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center">
|
||||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
@@ -440,8 +482,8 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
<div ref={paymentModalRef} className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
||||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ interface Appointment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AppointmentsResponse {
|
interface AppointmentsResponse {
|
||||||
upcoming: Appointment[];
|
appointments: Appointment[];
|
||||||
past: Appointment[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -46,7 +45,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
|
|||||||
|
|
||||||
export function PetProfiles({ sessionId, readOnly }: Props) {
|
export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||||
const [pets, setPets] = useState<Pet[]>([]);
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
|
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
|
||||||
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
const [selectedPetId, setSelectedPetId] = useState<string>("");
|
||||||
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
|
||||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||||
@@ -90,7 +89,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
|
||||||
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
|
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
|
||||||
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
|
||||||
|
|
||||||
function handlePetSave(updatedPet: Pet) {
|
function handlePetSave(updatedPet: Pet) {
|
||||||
|
|||||||
@@ -119,3 +119,10 @@ uri
|
|||||||
database-url
|
database-url
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Auth secret name — always use groombook-auth (sealed secret name)
|
||||||
|
*/}}
|
||||||
|
{{- define "groombook.authSecretName" -}}
|
||||||
|
{{- printf "%s" "groombook-auth" }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ spec:
|
|||||||
- name: OIDC_AUDIENCE
|
- name: OIDC_AUDIENCE
|
||||||
value: {{ .Values.api.env.oidcAudience | quote }}
|
value: {{ .Values.api.env.oidcAudience | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.api.env.internalBaseUrl }}
|
||||||
|
- name: OIDC_INTERNAL_BASE
|
||||||
|
value: {{ .Values.api.env.internalBaseUrl | quote }}
|
||||||
|
{{- end }}
|
||||||
|
- name: BETTER_AUTH_URL
|
||||||
|
value: {{ .Values.api.env.betterAuthUrl | quote }}
|
||||||
|
- name: OIDC_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: OIDC_CLIENT_ID
|
||||||
|
- name: OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: OIDC_CLIENT_SECRET
|
||||||
|
- name: BETTER_AUTH_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "groombook.authSecretName" . }}
|
||||||
|
key: BETTER_AUTH_SECRET
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ api:
|
|||||||
corsOrigin: ""
|
corsOrigin: ""
|
||||||
oidcIssuer: ""
|
oidcIssuer: ""
|
||||||
oidcAudience: groombook
|
oidcAudience: groombook
|
||||||
|
betterAuthUrl: ""
|
||||||
|
internalBaseUrl: ""
|
||||||
port: "3000"
|
port: "3000"
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
|
|||||||
+54
-45
@@ -200,51 +200,60 @@ export const appointmentGroups = pgTable("appointment_groups", {
|
|||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const appointments = pgTable("appointments", {
|
export const appointments = pgTable(
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
"appointments",
|
||||||
clientId: uuid("client_id")
|
{
|
||||||
.notNull()
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
.references(() => clients.id, { onDelete: "restrict" }),
|
clientId: uuid("client_id")
|
||||||
petId: uuid("pet_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => clients.id, { onDelete: "restrict" }),
|
||||||
.references(() => pets.id, { onDelete: "restrict" }),
|
petId: uuid("pet_id")
|
||||||
serviceId: uuid("service_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => pets.id, { onDelete: "restrict" }),
|
||||||
.references(() => services.id, { onDelete: "restrict" }),
|
serviceId: uuid("service_id")
|
||||||
staffId: uuid("staff_id").references(() => staff.id, {
|
.notNull()
|
||||||
onDelete: "set null",
|
.references(() => services.id, { onDelete: "restrict" }),
|
||||||
}),
|
staffId: uuid("staff_id").references(() => staff.id, {
|
||||||
// Optional secondary staff (bather/assistant) for tip-split tracking
|
onDelete: "set null",
|
||||||
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
}),
|
||||||
onDelete: "set null",
|
// Optional secondary staff (bather/assistant) for tip-split tracking
|
||||||
}),
|
batherStaffId: uuid("bather_staff_id").references(() => staff.id, {
|
||||||
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
onDelete: "set null",
|
||||||
startTime: timestamp("start_time").notNull(),
|
}),
|
||||||
endTime: timestamp("end_time").notNull(),
|
status: appointmentStatusEnum("status").notNull().default("scheduled"),
|
||||||
notes: text("notes"),
|
startTime: timestamp("start_time").notNull(),
|
||||||
// Override price at time of booking (null = use service base price)
|
endTime: timestamp("end_time").notNull(),
|
||||||
priceCents: integer("price_cents"),
|
notes: text("notes"),
|
||||||
// Recurring series support
|
// Override price at time of booking (null = use service base price)
|
||||||
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
priceCents: integer("price_cents"),
|
||||||
onDelete: "set null",
|
// Recurring series support
|
||||||
}),
|
seriesId: uuid("series_id").references(() => recurringSeries.id, {
|
||||||
seriesIndex: integer("series_index"),
|
onDelete: "set null",
|
||||||
// Multi-pet group booking: links this appointment to others in the same visit
|
}),
|
||||||
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
seriesIndex: integer("series_index"),
|
||||||
onDelete: "set null",
|
// Multi-pet group booking: links this appointment to others in the same visit
|
||||||
}),
|
groupId: uuid("group_id").references(() => appointmentGroups.id, {
|
||||||
// Customer confirmation/cancellation tracking
|
onDelete: "set null",
|
||||||
// Values: "pending" | "confirmed" | "cancelled"
|
}),
|
||||||
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
// Customer confirmation/cancellation tracking
|
||||||
confirmedAt: timestamp("confirmed_at"),
|
// Values: "pending" | "confirmed" | "cancelled"
|
||||||
cancelledAt: timestamp("cancelled_at"),
|
confirmationStatus: text("confirmation_status").notNull().default("pending"),
|
||||||
// Token for tokenized email confirm/cancel links (no auth required)
|
confirmedAt: timestamp("confirmed_at"),
|
||||||
confirmationToken: text("confirmation_token").unique(),
|
cancelledAt: timestamp("cancelled_at"),
|
||||||
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
// Token for tokenized email confirm/cancel links (no auth required)
|
||||||
customerNotes: text("customer_notes"),
|
confirmationToken: text("confirmation_token").unique(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
// Customer-provided note visible to groomer (500 char max, editable until appointment starts)
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
customerNotes: text("customer_notes"),
|
||||||
});
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_appointments_client_id").on(t.clientId),
|
||||||
|
index("idx_appointments_staff_id").on(t.staffId),
|
||||||
|
index("idx_appointments_start_time").on(t.startTime),
|
||||||
|
index("idx_appointments_status").on(t.status),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const invoices = pgTable(
|
export const invoices = pgTable(
|
||||||
"invoices",
|
"invoices",
|
||||||
|
|||||||
+10
-5
@@ -399,7 +399,6 @@ async function seedKnownUsers() {
|
|||||||
name: adminName,
|
name: adminName,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
oidcSub: adminEmail,
|
oidcSub: adminEmail,
|
||||||
userId: adminEmail,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -426,7 +425,6 @@ async function seedKnownUsers() {
|
|||||||
name: "UAT Super User",
|
name: "UAT Super User",
|
||||||
email: "uat-super@groombook.dev",
|
email: "uat-super@groombook.dev",
|
||||||
oidcSub: uatSuperOidcSub,
|
oidcSub: uatSuperOidcSub,
|
||||||
userId: uatSuperOidcSub,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -453,7 +451,6 @@ async function seedKnownUsers() {
|
|||||||
name: "UAT Staff Groomer",
|
name: "UAT Staff Groomer",
|
||||||
email: "uat-groomer@groombook.dev",
|
email: "uat-groomer@groombook.dev",
|
||||||
oidcSub: uatStaffOidcSub,
|
oidcSub: uatStaffOidcSub,
|
||||||
userId: uatStaffOidcSub,
|
|
||||||
role: "groomer",
|
role: "groomer",
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -648,7 +645,6 @@ async function seed() {
|
|||||||
name: adminName,
|
name: adminName,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
oidcSub: adminEmail,
|
oidcSub: adminEmail,
|
||||||
userId: adminEmail,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -887,6 +883,7 @@ async function seed() {
|
|||||||
let appointmentCount = 0;
|
let appointmentCount = 0;
|
||||||
let invoiceCount = 0;
|
let invoiceCount = 0;
|
||||||
let visitLogCount = 0;
|
let visitLogCount = 0;
|
||||||
|
let paidInvoiceCounter = 0;
|
||||||
|
|
||||||
// Process in batches per client to keep memory manageable
|
// Process in batches per client to keep memory manageable
|
||||||
const apptBatchSize = 100;
|
const apptBatchSize = 100;
|
||||||
@@ -981,6 +978,10 @@ async function seed() {
|
|||||||
|
|
||||||
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
||||||
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||||
|
paidInvoiceCounter++;
|
||||||
|
const stripePaymentIntentId = invoiceStatus === "paid"
|
||||||
|
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
|
||||||
|
: null;
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId,
|
id: invoiceId,
|
||||||
@@ -993,6 +994,7 @@ async function seed() {
|
|||||||
status: invoiceStatus,
|
status: invoiceStatus,
|
||||||
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
||||||
paidAt,
|
paidAt,
|
||||||
|
stripePaymentIntentId,
|
||||||
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1096,13 +1098,16 @@ async function seed() {
|
|||||||
const taxCents = Math.round(effectivePrice * 0.08);
|
const taxCents = Math.round(effectivePrice * 0.08);
|
||||||
const totalCents = effectivePrice + taxCents + tipCents;
|
const totalCents = effectivePrice + taxCents + tipCents;
|
||||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||||
|
paidInvoiceCounter++;
|
||||||
|
|
||||||
invoiceBatch.push({
|
invoiceBatch.push({
|
||||||
id: invoiceId, appointmentId: apptId, clientId,
|
id: invoiceId, appointmentId: apptId, clientId,
|
||||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||||
status: "paid" as const,
|
status: "paid" as const,
|
||||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||||
paidAt, notes: null,
|
paidAt,
|
||||||
|
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
|
||||||
|
notes: null,
|
||||||
});
|
});
|
||||||
lineItemBatch.push({
|
lineItemBatch.push({
|
||||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface Pet {
|
|||||||
shampooPreference: string | null;
|
shampooPreference: string | null;
|
||||||
specialCareNotes: string | null;
|
specialCareNotes: string | null;
|
||||||
customFields: Record<string, string>;
|
customFields: Record<string, string>;
|
||||||
|
photoKey?: string;
|
||||||
|
photoUploadedAt?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -150,10 +152,16 @@ export interface Invoice {
|
|||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
paymentMethod: PaymentMethod | null;
|
paymentMethod: PaymentMethod | null;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
|
stripePaymentIntentId: string | null;
|
||||||
|
stripeRefundId: string | null;
|
||||||
|
paymentFailureReason: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lineItems?: InvoiceLineItem[];
|
lineItems?: InvoiceLineItem[];
|
||||||
|
// Transient fields populated from Stripe API (not stored in DB)
|
||||||
|
cardLast4?: string | null;
|
||||||
|
paymentStatus?: string | null;
|
||||||
tipSplits?: InvoiceTipSplit[];
|
tipSplits?: InvoiceTipSplit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user