PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
690 files changed
+33944 -12046

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Generated business environment file. Run
# `./scripts/create-business.sh <slug> <domain>` instead of editing a copy.
DOMAIN="example.com"
DATABASE_HOST="nextchair-mysql"
DATABASE_PORT=3306
DATABASE_NAME="nextchair_example_business"
DATABASE_USER="nextchair_example_business"
DATABASE_PASSWORD="replace-me"
DATABASE_CONNECTION_LIMIT=5
DATABASE_CONNECT_TIMEOUT_MS=10000
DATABASE_SSL=false
AUTH_SECRET="replace-with-a-long-random-secret"
AUTH_TRUST_HOST=true
NEXT_PUBLIC_APP_URL="https://example.com"
INSTALLATION_TOKEN="replace-with-a-long-random-token"
CRON_SECRET="replace-with-a-long-random-secret"
NEXTCHAIR_IMAGE="nextchair:latest"
+5
View File
@@ -0,0 +1,5 @@
# Shared infrastructure config. Copy to `.env.infra`; the real file is
# ignored by git and is also read by scripts/create-business.sh.
MYSQL_ROOT_PASSWORD="replace-with-a-long-random-password"
# Optional loopback-only port for an SSH tunnel or local admin tool.
MYSQL_HOST_PORT=127.0.0.1:3306
+1
View File
@@ -0,0 +1 @@
cache=/private/tmp/claude-501/-Users-stefanlazic-Documents-GitHub-NextChair/11cd8d75-4d20-4339-a2c5-329dd2e59132/scratchpad/npm-cache
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}
+82
View File
@@ -0,0 +1,82 @@
# Multi-stage build. NextChair is the internal project/image name only —
# see docs/phase-8-summary.md's white-label section for why that's fine
# here (Docker/package names are explicitly "internal and acceptable")
# even though the running application never shows that name to a user.
#
# Prisma 7 uses driver adapters (@prisma/adapter-mariadb), not a native
# Rust query-engine binary — the generated client is pure JS, so this
# image needs no special native-binary handling the way older Prisma
# versions on Alpine sometimes did.
#
# `output: "standalone"` in next.config.ts was deliberately NOT enabled:
# this image runs `prisma migrate deploy` (and, for the optional seed
# service, `tsx prisma/seed.ts`) at container start, both of which need
# the full `prisma`/`tsx` CLIs and their dependencies present — a pruned
# standalone bundle would need those re-added anyway, so a single
# `npm ci` (full dependencies, including the small set of dev tools this
# image actually runs) is simpler and no less correct. See
# docs/phase-8-summary.md, "Docker image quality" for this trade-off.
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# `npm run build` runs `prisma generate` itself (see package.json — needed
# so a plain Vercel/`npm run build` deploy generates the client too, not
# just this Docker path, which used to have its own separate `prisma
# generate` step here).
# `next build`'s page-data collection step imports every route module
# (including /api/health) to inspect it — that pulls in
# src/lib/db/prisma.ts, which requires DATABASE_HOST/NAME/USER/PASSWORD
# just to construct its connection-pool config object, even though nothing
# actually connects at build time. These placeholders only exist to
# satisfy that check; the real values are supplied at container runtime
# via env_file (see docker-compose.local.yaml) and these are never used to
# connect to anything or baked into a layer that matters.
RUN DATABASE_HOST="localhost" \
DATABASE_PORT="3306" \
DATABASE_NAME="build" \
DATABASE_USER="build" \
DATABASE_PASSWORD="build" \
npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Non-root — see the brief's "Docker image quality: non-root user."
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextchair
COPY --from=builder --chown=nextchair:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextchair:nodejs /app/.next ./.next
COPY --from=builder --chown=nextchair:nodejs /app/public ./public
COPY --from=builder --chown=nextchair:nodejs /app/generated ./generated
COPY --from=builder --chown=nextchair:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextchair:nodejs /app/src ./src
COPY --from=builder --chown=nextchair:nodejs /app/scripts ./scripts
COPY --from=builder --chown=nextchair:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextchair:nodejs /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder --chown=nextchair:nodejs /app/next.config.ts ./next.config.ts
COPY --from=builder --chown=nextchair:nodejs /app/tsconfig.json ./tsconfig.json
COPY --chown=nextchair:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
RUN chmod +x ./docker/entrypoint.sh
# Media Manager uploads (Phase 10) — storage/media (NOT public/) is a
# named volume mount point (see docker-compose.local.yaml). Served by
# src/app/api/media/[...path]/route.ts, not Next's static handling for
# public/ — verified against a real production build that runtime-added
# public/ files 404 (Next resolves public assets at build time, not from
# a live filesystem check). Creating this here, owned by the non-root
# user, before the volume first attaches means Docker initializes the
# volume with this ownership rather than root's.
RUN mkdir -p ./storage/media && chown -R nextchair:nodejs ./storage
USER nextchair
EXPOSE 3000
ENV PORT=3000
ENTRYPOINT ["./docker/entrypoint.sh"]
+383
View File
@@ -0,0 +1,383 @@
+# NextChair
NextChair is a white-label, mobile-first booking platform for barbershops. It combines customer booking, staff scheduling, owner administration, notifications, loyalty, media, reviews, and optional Google integrations in one Next.js application.
## Technology
- Next.js 16 with the App Router and React 19
- TypeScript 6
- MySQL 8.0, accessed through Prisma 7 and the MariaDB driver adapter
- Auth.js / NextAuth v5 for authentication
- Tailwind CSS 4, Radix UI, Lucide, and Font Awesome
- Vitest for unit and integration tests
- Playwright for browser tests
- Docker Compose for a production-style local deployment
The application is intentionally configured with discrete database settings: `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USER`, and `DATABASE_PASSWORD`. It does not use `DATABASE_URL` at runtime.
## Deployment Model
The intended deployment model is one repeatable Docker deployment for both a
local/self-hosted Debian server and production:
- One Dockge-managed Caddy stack owns ports 80 and 443 and routes each domain.
- One Dockge-managed MySQL 8 stack hosts a separate database and user per business.
- Each business is a separate Dockge stack with its own media volume and env file.
- Business containers publish no host ports and share only the proxy and database networks.
- Each business is installed independently through `/install` after its stack starts.
`deploy/business-compose.yaml.template` and `scripts/create-business.sh` are the
canonical business deployment path. The local Docker file remains useful for a
single isolated development instance.
## Requirements
For local Node development:
- Node.js 22 or later
- npm
- A reachable MySQL 8.0 database
For the primary VPS deployment:
- A Linux VPS with Docker Engine and Docker Compose
- A domain per business, with DNS pointing at the VPS
- Ports 80 and 443 available for Caddy and Let's Encrypt
- Dockge configured to scan the directory containing the generated stacks
For local development:
- Docker Desktop with Docker Compose
## Local Docker Desktop (Isolated Business Instance)
This is a disposable local approximation of one business from the VPS deployment. It starts one MySQL container and one NextChair container, applies migrations automatically, and leaves the database empty until you either complete the installer or explicitly run the demo seed. It is not the shared multi-business topology; use the VPS setup above for the canonical deployment.
1. Install dependencies if you also plan to run commands on the host:
```bash
npm ci
```
2. Create the Docker environment file:
```bash
cp .env.local.example .env.local
```
3. Replace the placeholder values in `.env.local`. At minimum, set strong values for:
- `MYSQL_PASSWORD`
- `MYSQL_ROOT_PASSWORD`
- `DATABASE_PASSWORD`
- `AUTH_SECRET`
- `INSTALLATION_TOKEN`
- `CRON_SECRET`
`DATABASE_NAME`, `DATABASE_USER`, and `DATABASE_PASSWORD` must match the corresponding MySQL container settings. Inside Docker, `DATABASE_HOST` must remain `next-chair-mysql`, not `localhost`.
Generate suitable secrets with:
```bash
openssl rand -hex 32
npx auth secret
```
4. Build and start the stack:
```bash
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local up --build -d
```
5. Open [http://localhost:3000](http://localhost:3000). If the database has not been installed, the application redirects to `/install`. Enter the `INSTALLATION_TOKEN` and complete the wizard to create the first business and owner account.
6. Follow the application logs when needed:
```bash
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local logs -f next-chair
```
The application health endpoint is [http://localhost:3000/api/health](http://localhost:3000/api/health). It returns HTTP 200 when the database is reachable, even before the installation wizard has been completed.
### Docker lifecycle commands
```bash
# Stop containers but keep database and media volumes
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local down
# Start again without rebuilding
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local up -d
# Run the development/demo fixture explicitly
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local run --rm next-chair-seed
# Remove containers and all local database/media data
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local down -v
```
The seed service is opt-in. Normal `up` never seeds the database. `down -v` is destructive for this local deployment: it removes the named MySQL and media volumes.
## Local Node Development
Use this path when you already have MySQL running outside Docker or want fast Next.js source reloads.
1. Install dependencies:
```bash
npm ci
```
2. Create `.env.local` from `.env.example` and configure a local MySQL 8 database. Set `DATABASE_HOST=localhost` and make sure the database and user already exist.
3. Generate the Prisma client and apply development migrations:
```bash
npm run prisma:generate
npm run prisma:migrate
```
4. Start the development server:
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
5. Choose one database bootstrap path:
- For a real, empty installation, do not seed. Use `/install` with `INSTALLATION_TOKEN`.
- For development fixtures, run:
```bash
npm run prisma:seed
```
The seed is idempotent and creates demo business, staff, services, customers, appointments, and related records. It marks the database as installed so the application can be used immediately. Do not run it alongside a real first-run installation.
The direct Node path and the local Docker path should use different `.env.local` values: Docker connects to `next-chair-mysql`, while a host-run Next.js process normally connects to `localhost`. Business stacks use `DATABASE_HOST=nextchair-mysql` on the shared database network.
## Environment Configuration
The available templates are:
- `.env.example`: host-run development configuration
- `.env.local.example`: Docker Desktop configuration
- `.env.infra.example`: shared MySQL root settings
- `stacks/caddy/.env.example`: shared Caddy ACME email
- `.env.business.example`: generated business environment reference
Required for a basic running installation:
- MySQL settings: `DATABASE_*`
- `AUTH_SECRET`
- `NEXT_PUBLIC_APP_URL`
- `INSTALLATION_TOKEN`
- `CRON_SECRET`
- `AUTH_TRUST_HOST=true` when serving through a non-localhost host or reverse proxy
Optional integrations are disabled safely when left unconfigured:
- Google sign-in: `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET`
- Google Calendar sync: `GOOGLE_CALENDAR_*`, `CALENDAR_TOKEN_ENCRYPTION_KEY`
- Google Reviews: `GOOGLE_PLACES_API_KEY`
- SMTP email: set `EMAIL_PROVIDER=brevo` and provide the `EMAIL_SMTP_*` and `EMAIL_FROM_ADDRESS` values. The default `console` provider logs notifications instead of sending them.
- Web Push: set `PUSH_PROVIDER=web-push` and provide the VAPID keys. Generate them with `npx web-push generate-vapid-keys`.
- Platform super administrator: `SUPER_ADMIN_EMAIL`, `SUPER_ADMIN_PASSWORD`, `SUPER_ADMIN_FIRST_NAME`, and `SUPER_ADMIN_LAST_NAME`
Do not commit `.env.local`, production environment files, OAuth secrets, SMTP credentials, VAPID private keys, or encryption keys.
## Application Flow
The application has a deliberate first-run gate:
1. A fresh database is migrated by Prisma.
2. Requests are redirected to `/install`, except for the installer and required static/PWA assets.
3. The installer validates `INSTALLATION_TOKEN`, then creates the business, primary location, settings, hours, booking rules, loyalty configuration, starter services, owner role, and first administrator in one transaction.
4. After installation, `/staff` and `/owner` require authentication and permissions. Customer browsing and booking routes remain available according to the business booking rules.
The installation token is checked server-side and rate-limited. It is never stored in the database. The installer is one-way for a deployment: to start over locally, remove the local database volume or use a separate database rather than attempting to reinstall over existing data.
## Database and Prisma
Useful commands:
```bash
npm run prisma:generate # Generate the client in generated/prisma
npm run prisma:migrate # Create/apply a development migration
npm run prisma:studio # Open Prisma Studio
npm run prisma:seed # Load idempotent development fixtures
```
For a deployed database, the container entrypoint runs:
```bash
npx prisma migrate deploy
npx tsx scripts/ensure-super-admin.ts
```
Migrations are applied on every application container start and are not destructive. The optional seed is separate because demo data is not an appropriate production bootstrap mechanism.
## Quality Checks and Tests
Run the fast checks first:
```bash
npm run typecheck
npm run lint
npm test
```
Additional commands:
```bash
npm run test:watch
npm run test:integration
npm run test:e2e
npm run build
```
Unit tests do not require a database. Integration tests require a disposable MySQL 8 database configured through the `DATABASE_*` variables. They apply migrations but the caller is responsible for providing the database.
The Playwright suite starts a development server on port `3100`, applies migrations, loads the demo seed, and provisions test credentials. Point it at a throwaway database, never a database containing real business data:
```bash
DATABASE_HOST=localhost \
DATABASE_PORT=3306 \
DATABASE_NAME=nextchair_e2e \
DATABASE_USER=nextchair \
DATABASE_PASSWORD='replace-me' \
npm run test:e2e
```
Playwright uses one Chromium project, one worker, a 45-second test timeout, screenshots on failure, and retains traces on failure. The database should be reset or recreated between isolated test runs when fixtures from previous runs could affect results.
## Docker Server Deployment
This is the same topology for a Debian self-hosted server and a production
server. The only intentional deployment difference is the image tag: build
`nextchair:latest` locally, or use a registry tag such as
`registry.example.com/nextchair:2026-09-04` in production.
### 1. Prepare the server
Copy this repository's deployment files to a stable directory, for example
`/opt/nextchair`, and configure Dockge to use `/opt/nextchair/stacks` as its
stack directory. The tracked `infra` and `caddy` directories are shared
stacks; every generated business directory sits beside them.
Build or pull the application image once:
```bash
# Self-hosted/local image
docker build -t nextchair:latest .
# Production image example
docker pull registry.example.com/nextchair:2026-09-04
```
Create the shared networks and start the two infrastructure stacks:
```bash
cp .env.infra.example .env.infra
cp stacks/caddy/.env.example stacks/caddy/.env
# Edit both env files before starting them.
docker network create nextchair-db
docker network create nextchair-proxy
docker compose -f stacks/infra/compose.yaml up -d
docker compose -f stacks/caddy/compose.yaml up -d
```
Start the Manager stack as well, after copying its environment template and
setting a strong platform-admin password and auth secret:
```bash
cp stacks/manager/.env.example stacks/manager/.env
docker compose -f stacks/manager/compose.yaml up -d
```
Add a Manager-only Caddy route in `stacks/caddy/sites/manager.caddy`, replacing
the example hostname with the private/admin hostname you control:
```caddy
manager.example.com {
reverse_proxy nextchair-manager:3000
}
```
Reload Caddy after adding the route, then open `/manager`. The Manager has its
own administrator session and does not use any business customer, staff, or
owner account.
The MySQL port is loopback-only by default. Use an SSH tunnel for database
administration rather than opening port 3306 publicly.
### 2. Provision a business
Run this after MySQL is healthy. The script creates the database and least
scope database user, generates secrets, writes a Dockge-ready stack directory,
and writes one Caddy site file:
```bash
NEXTCHAIR_STACK_ROOT=/opt/nextchair/stacks \
NEXTCHAIR_CADDY_SITES_DIR=/opt/nextchair/stacks/caddy/sites \
./scripts/create-business.sh blake-heritage blakeheritage.com nextchair:latest
```
For production, pass the exact image tag to use. The script never overwrites
an existing business slug. In Dockge, the new directory appears as its own
stack, so `start`, `stop`, `restart`, `recreate`, `update`, and logs are scoped
to that business. The generated `.env` is mode `600` and is ignored by git.
After DNS points at the server, start the generated stack from Dockge. Caddy
will obtain the certificate and route the domain to the app container over the
private `nextchair-proxy` network. Reload Caddy after adding or removing a
route:
```bash
docker compose -f stacks/caddy/compose.yaml exec nextchair-caddy caddy reload --config /etc/caddy/Caddyfile
```
Repeat the provisioning command for each business. A business's database,
media volume, env file, app container, and Caddy route are all named from its
slug. The application migration entrypoint runs on every start, while the
first-run business setup remains the app-level `/install` flow.
## Repository Layout
```text
src/app/ Next.js routes, layouts, and API handlers
src/features/ Feature workflows, server actions, schemas, components
src/services/ Domain services and external-provider integrations
src/lib/ Shared infrastructure, auth, database, security, and utilities
prisma/ Schema, migrations, and development seed
e2e/ Playwright browser journeys and test setup
docker/ Container entrypoint
docs/ Phase implementation notes and historical decisions
deploy/ Reusable business stack template
stacks/ Dockge stacks: infra, caddy, and businesses
scripts/ Database/business provisioning utilities
templates/ UI reference templates
```
The phase documents in `docs/` contain implementation history and deeper design rationale. They are useful when changing installation, scheduling, notification, calendar, media, or deployment behavior, but this README is the intended starting point for running the project.
## Common Troubleshooting
**The app redirects to `/install`.** This is expected for a fresh or unseeded database. Set `INSTALLATION_TOKEN` and complete the wizard, or explicitly run the development seed.
**The app cannot connect to MySQL in the local stack.** Use `DATABASE_HOST=next-chair-mysql`, not `localhost`, and ensure the local credentials match the local MySQL container.
**The app cannot connect to MySQL in a business stack.** Use `DATABASE_HOST=nextchair-mysql`, confirm `nextchair-mysql` is attached to `nextchair-db`, and confirm the generated `DATABASE_NAME`, `DATABASE_USER`, and `DATABASE_PASSWORD` match the database created by `scripts/create-business.sh`.
**The app cannot connect to MySQL when run with `npm run dev`.** Use `DATABASE_HOST=localhost` or the hostname of your external MySQL server, and verify that the database and user already exist.
**Port 3000 is already in use.** Set `APP_HOST_PORT` in `.env.local` for Docker, or start Next.js with `npm run dev -- --port 3001` for host development. Update `NEXT_PUBLIC_APP_URL` when callback URLs need to match the new port.
**Emails or push notifications are not delivered.** The default providers are console-only. Configure the relevant provider and credentials before expecting external delivery.
**A reset is needed during local development.** Use a separate database or `docker compose ... down -v` for the local Docker stack. This removes local database and media data and should never be used against production volumes.
## License
The package currently declares the ISC license in `package.json`. Confirm the intended distribution license before publishing the application or its assets.
@@ -0,0 +1,35 @@
name: nextchair-${BUSINESS_SLUG}
# Generated by scripts/create-business.sh. Keep this file self-contained so
# Dockge can operate this business independently.
services:
nextchair-${BUSINESS_SLUG}:
image: ${NEXTCHAIR_IMAGE:-nextchair:latest}
container_name: nextchair-${BUSINESS_SLUG}
restart: unless-stopped
env_file:
- .env
volumes:
- nextchair-media-${BUSINESS_SLUG}:/app/storage/media
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- nextchair-proxy
- nextchair-db
volumes:
nextchair-media-${BUSINESS_SLUG}:
name: nextchair-media-${BUSINESS_SLUG}
networks:
nextchair-proxy:
name: nextchair-proxy
external: true
nextchair-db:
name: nextchair-db
external: true
+107
View File
@@ -0,0 +1,107 @@
name: next-chair
# Local Docker Desktop deployment — production-style by default (real
# migrations, no auto-seed), not a dev-mode source-mount setup. See the
# README section this file is documented under for exact commands.
#
# Config source of truth: copy .env.local.example to .env.local and run
# every command below with `--env-file .env.local` (see README) — that
# one file drives both container environment variables (via each
# service's `env_file:`) and the few values this compose file itself
# needs (the host-port mappings below).
services:
next-chair-mysql:
image: mysql:8.0
container_name: next-chair-mysql
restart: unless-stopped
env_file:
- .env.local
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
ports:
- "${MYSQL_HOST_PORT:-3306}:3306"
volumes:
- next-chair-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-change-me-too}"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
networks:
- next-chair
next-chair:
build:
context: .
dockerfile: Dockerfile
container_name: next-chair
restart: unless-stopped
depends_on:
next-chair-mysql:
condition: service_healthy
env_file:
- .env.local
ports:
- "${APP_HOST_PORT:-3000}:3000"
volumes:
# Media Manager uploads — a dedicated volume, separate from
# next-chair-mysql-data. Phase 10 moved the mount point from
# /app/public/media to /app/storage/media (outside public/ — see
# src/lib/media/media-storage.ts's header for why runtime uploads
# can't live under public/ with this Next.js version) while
# deliberately keeping the SAME volume name, so any file already
# uploaded before this fix reappears at its new path instead of
# being orphaned. Writable by the non-root nextchair user (the
# Dockerfile pre-creates and chowns this directory before the
# volume first attaches). Persists across `docker compose restart`
# and a full image rebuild, since it's a named volume, not
# container-local storage.
- next-chair-media:/app/storage/media
healthcheck:
# No curl/wget in the node:22-alpine base image — a plain Node
# request avoids adding a package just for this. See
# src/app/api/health/route.ts: healthy as soon as the database is
# reachable, even before installation completes.
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- next-chair
# Explicit, opt-in only — never runs as part of a normal `up` (see the
# `profiles` key). Development/demo fixtures, not a production seed —
# see prisma/seed.ts's own header and docs/phase-8-summary.md,
# "Production installation versus development seed."
next-chair-seed:
build:
context: .
dockerfile: Dockerfile
container_name: next-chair-seed
profiles: ["seed"]
depends_on:
next-chair-mysql:
condition: service_healthy
env_file:
- .env.local
entrypoint: ["npx", "tsx", "prisma/seed.ts"]
networks:
- next-chair
volumes:
next-chair-mysql-data:
name: next-chair-mysql-data
next-chair-media:
name: next-chair-media
networks:
next-chair:
name: next-chair
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Entrypoint for the `next-chair` app container — see docker-compose.local.yaml.
# `migrate deploy` (never `migrate reset`) is safe to run on every
# container start: it only applies migrations not yet recorded in
# `_prisma_migrations`, so a restart with an already-up-to-date schema is
# a fast no-op, never destructive. It intentionally does NOT seed —
# development fixtures are a separate, explicit opt-in service (see
# `next-chair-seed` in docker-compose.local.yaml and the brief's own
# "production installation versus development seed" distinction); a
# fresh container is left empty and shows the installation wizard.
set -e
if [ "${MANAGER_MODE:-false}" = "true" ]; then
echo "[entrypoint] Starting in NextChair Manager mode; skipping business database bootstrap."
else
echo "[entrypoint] Applying database migrations..."
npx prisma migrate deploy
# Optional — only provisions/syncs a Super Administrator when
# SUPER_ADMIN_EMAIL is set (see scripts/ensure-super-admin.ts).
echo "[entrypoint] Syncing Super Administrator..."
npx tsx scripts/ensure-super-admin.ts
fi
echo "[entrypoint] Starting application..."
# `exec` + calling the `next` binary directly (not `npm run start`) so
# the Next.js server process itself receives SIGTERM directly from
# Docker on `docker stop` / `docker compose down` — routing through npm
# as an intermediary process can swallow the signal and force a slower
# SIGKILL after Compose's stop-grace-period instead of a clean shutdown.
exec node_modules/.bin/next start -p "${PORT:-3000}"
+19
View File
@@ -0,0 +1,19 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
/** @type {import('eslint').Linter.Config[]} */
const eslintConfig = [
{ ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**"] },
...nextCoreWebVitals,
...nextTypescript,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
];
export default eslintConfig;
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+54
View File
@@ -0,0 +1,54 @@
import type { NextConfig } from "next";
// Static, request-independent security headers — applied to every
// response via next.config's own headers() so they can't be missed on a
// route that forgets to set them itself. Content-Security-Policy is NOT
// set here: it needs a per-request nonce (for Next's own inline
// hydration scripts), which only a request-scoped place like proxy.ts
// can generate — see that file.
const SECURITY_HEADERS = [
// This app is never meant to be framed by another origin.
{ key: "X-Frame-Options", value: "DENY" },
// Stops the browser from MIME-sniffing a response into something more
// dangerous than its declared Content-Type.
{ key: "X-Content-Type-Options", value: "nosniff" },
// Full referrer to same-origin navigations (useful for our own
// analytics-free logs), origin-only to other origins, nothing on downgrade.
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
// No page here needs the camera, microphone, geolocation, etc.
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()" },
// Only meaningful over HTTPS (which every real deployment of this app
// uses) — harmless to send in local HTTP dev too, since browsers only
// honor it on secure origins.
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
];
const nextConfig: NextConfig = {
reactStrictMode: true,
experimental: {
// Next's Server Action default (1MB) is too small for the PWA
// icon/splash-screen ZIP import (see updateAppIconSettingsAction's
// sibling importAppIconZipAction) — a full iOS splash-screen bundle
// from a generator like progressier.com is ~10-15MB. Also lifts the
// ceiling for regular media uploads (MediaPicker), which were
// previously constrained below their own MEDIA_MAX_UPLOAD_BYTES.
serverActions: { bodySizeLimit: "20mb" },
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "storage.googleapis.com",
pathname: "/uxpilot-auth.appspot.com/**",
},
// Google OAuth profile photos (session.user.image when signed in via
// "Continue with Google" — see src/auth.ts's Google provider).
{ protocol: "https", hostname: "lh3.googleusercontent.com" },
],
},
async headers() {
return [{ source: "/(.*)", headers: SECURITY_HEADERS }];
},
};
export default nextConfig;
+11452
View File
File diff suppressed because it is too large. Load diff
+81
View File
@@ -0,0 +1,81 @@
{
"name": "nextchair",
"version": "0.1.0",
"private": true,
"description": "Mobile-first barber booking platform — customer, barber and owner experiences in one Next.js app.",
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:seed": "tsx prisma/seed.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.mts",
"test:e2e": "playwright test"
},
"repository": {
"type": "git",
"url": "http://192.168.0.126:3000/lazis002/NextChair.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@auth/prisma-adapter": "^2.11.3",
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0",
"@hookform/resolvers": "^5.7.1",
"@prisma/adapter-mariadb": "^7.9.1",
"@prisma/client": "^7.9.1",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"image-size": "^2.0.2",
"jszip": "^3.10.1",
"lucide-react": "^1.28.0",
"luxon": "^3.7.2",
"mariadb": "^3.5.3",
"next": "^16.2.12",
"next-auth": "^5.0.0-beta.32",
"nodemailer": "^8.0.11",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.84.0",
"server-only": "^0.0.1",
"tailwind-merge": "^3.6.0",
"web-push": "^3.6.7",
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@tailwindcss/postcss": "^4.3.3",
"@types/bcryptjs": "^2.4.6",
"@types/luxon": "^3.7.3",
"@types/node": "^26.1.2",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/web-push": "^3.6.4",
"dotenv": "^17.4.2",
"eslint": "^9.39.5",
"eslint-config-next": "^16.2.12",
"postcss": "^8.5.25",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"prisma": "^7.9.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.4",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
}
}
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig, devices } from "@playwright/test";
/**
* End-to-end customer journeys — real browser, real dev server, real
* MySQL. Needs DATABASE_HOST/DATABASE_NAME/DATABASE_USER/DATABASE_PASSWORD
* pointed at a throwaway database before running (same "migrate, seed,
* test, tear down" pattern as `npm run test:integration` — see
* docs/phase-3-summary.md and docs/phase-4-summary.md). `globalSetup`
* applies migrations and seed data automatically so a single
* `npm run test:e2e` is enough once those are set.
*/
const PORT = 3100;
const baseURL = `http://localhost:${PORT}`;
export default defineConfig({
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 45_000,
expect: { timeout: 10_000 },
reporter: "list",
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: `npm run dev -- --port ${PORT}`,
url: baseURL,
reuseExistingServer: false,
timeout: 60_000,
env: {
...process.env,
// Any fixed value is fine — see src/lib/booking-draft/sign.ts. Only
// needs to be consistent for the lifetime of this one server process.
AUTH_SECRET: process.env.AUTH_SECRET ?? "playwright-e2e-test-secret-do-not-use-in-production",
NEXT_PUBLIC_APP_URL: baseURL,
},
},
});
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+19
View File
@@ -0,0 +1,19 @@
import "dotenv/config";
import { defineConfig } from "prisma/config";
import { buildMysqlConnectionUrl } from "./src/lib/db/mysql-pool-config";
// The Prisma CLI (migrate/introspect) needs a connection string, not
// discrete fields — this app's own runtime code never uses this URL (see
// src/lib/db/mysql-pool-config.ts's buildMysqlPoolConfig, which reads
// DATABASE_HOST/PORT/NAME/USER/PASSWORD directly), so it's built here only
// to satisfy the CLI.
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: buildMysqlConnectionUrl(),
},
});
@@ -0,0 +1,729 @@
-- CreateEnum
CREATE TYPE "Weekday" AS ENUM ('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY');
-- CreateEnum
CREATE TYPE "Permission" AS ENUM ('MANAGE_BUSINESS_PROFILE', 'MANAGE_BOOKING_RULES', 'MANAGE_SERVICES', 'MANAGE_STAFF', 'MANAGE_LOYALTY_PROGRAMME', 'MANAGE_NOTIFICATION_SETTINGS', 'VIEW_ALL_STAFF_APPOINTMENTS', 'MANAGE_OWN_APPOINTMENTS', 'CHECK_IN_CUSTOMERS', 'MANAGE_LOYALTY_STAMPS', 'MANAGE_OWN_AVAILABILITY', 'VIEW_CUSTOMER_DIRECTORY', 'BOOK_ON_BEHALF_OF_CUSTOMER');
-- CreateEnum
CREATE TYPE "StaffStatus" AS ENUM ('ACTIVE', 'ON_BREAK', 'INACTIVE');
-- CreateEnum
CREATE TYPE "AppointmentStatus" AS ENUM ('PENDING_APPROVAL', 'CONFIRMED', 'ARRIVED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW');
-- CreateEnum
CREATE TYPE "TimeOffStatus" AS ENUM ('PENDING', 'APPROVED', 'DECLINED');
-- CreateEnum
CREATE TYPE "RewardType" AS ENUM ('PERCENTAGE', 'FIXED_AMOUNT', 'FREE_SERVICE');
-- CreateEnum
CREATE TYPE "RewardStatus" AS ENUM ('AVAILABLE', 'REDEEMED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "LoyaltyTransactionType" AS ENUM ('STAMP_ADDED', 'STAMP_REMOVED', 'STAMP_EXPIRED', 'REWARD_REDEEMED');
-- CreateEnum
CREATE TYPE "NotificationCategory" AS ENUM ('BOOKING_CONFIRMATION', 'BOOKING_REMINDER', 'BOOKING_CHANGED', 'BOOKING_CANCELLED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_AVAILABLE', 'MARKETING');
-- CreateEnum
CREATE TYPE "NotificationChannel" AS ENUM ('EMAIL', 'SMS', 'PUSH');
-- CreateEnum
CREATE TYPE "NotificationStatus" AS ENUM ('PENDING', 'SENT', 'FAILED');
-- CreateTable
CREATE TABLE "Business" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"logoUrl" TEXT,
"phone" TEXT,
"email" TEXT,
"website" TEXT,
"instagramUrl" TEXT,
"facebookUrl" TEXT,
"timezone" TEXT NOT NULL DEFAULT 'Europe/London',
"currency" TEXT NOT NULL DEFAULT 'GBP',
"locale" TEXT NOT NULL DEFAULT 'en-GB',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Business_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BusinessLocation" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT 'Main Location',
"addressLine1" TEXT NOT NULL,
"addressLine2" TEXT,
"city" TEXT NOT NULL,
"region" TEXT,
"postalCode" TEXT NOT NULL,
"country" TEXT NOT NULL DEFAULT 'GB',
"latitude" DECIMAL(9,6),
"longitude" DECIMAL(9,6),
"isPrimary" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BusinessLocation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BusinessHours" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"dayOfWeek" "Weekday" NOT NULL,
"isClosed" BOOLEAN NOT NULL DEFAULT false,
"opensAtMinutes" INTEGER,
"closesAtMinutes" INTEGER,
CONSTRAINT "BusinessHours_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BusinessSettings" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"cancellationPolicy" TEXT,
"lateArrivalPolicy" TEXT,
"bookingTerms" TEXT,
"privacyPolicyUrl" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BusinessSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BookingRule" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"minNoticeMinutes" INTEGER NOT NULL DEFAULT 120,
"maxAdvanceDays" INTEGER NOT NULL DEFAULT 60,
"cancellationWindowHours" INTEGER NOT NULL DEFAULT 24,
"rescheduleWindowHours" INTEGER NOT NULL DEFAULT 24,
"slotIntervalMinutes" INTEGER NOT NULL DEFAULT 30,
"bufferBeforeMinutes" INTEGER NOT NULL DEFAULT 0,
"bufferAfterMinutes" INTEGER NOT NULL DEFAULT 5,
"maxAppointmentsPerDay" INTEGER,
"customerCanChooseStaff" BOOLEAN NOT NULL DEFAULT true,
"allowGuestBooking" BOOLEAN NOT NULL DEFAULT true,
"requireApproval" BOOLEAN NOT NULL DEFAULT false,
"requireDeposit" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BookingRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"phone" TEXT,
"phoneVerified" TIMESTAMP(3),
"image" TEXT,
"passwordHash" TEXT,
"isSuperAdmin" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "CustomerProfile" (
"id" TEXT NOT NULL,
"userId" TEXT,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"isGuest" BOOLEAN NOT NULL DEFAULT true,
"preferredBarberId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomerProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StaffProfile" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"bio" TEXT,
"status" "StaffStatus" NOT NULL DEFAULT 'ACTIVE',
"isBookableOnline" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StaffProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isSystem" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"id" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"permission" "Permission" NOT NULL,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ServiceCategory" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ServiceCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Service" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"durationMinutes" INTEGER NOT NULL,
"price" DECIMAL(10,2) NOT NULL,
"bufferBeforeMinutes" INTEGER NOT NULL DEFAULT 0,
"bufferAfterMinutes" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"allowOnlineBooking" BOOLEAN NOT NULL DEFAULT true,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Service_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StaffService" (
"id" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"priceOverride" DECIMAL(10,2),
"durationOverrideMinutes" INTEGER,
CONSTRAINT "StaffService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkingHours" (
"id" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"dayOfWeek" "Weekday" NOT NULL,
"isOff" BOOLEAN NOT NULL DEFAULT false,
"startMinutes" INTEGER,
"endMinutes" INTEGER,
CONSTRAINT "WorkingHours_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Break" (
"id" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"startMinutes" INTEGER NOT NULL,
"endMinutes" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Break_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BreakDay" (
"id" TEXT NOT NULL,
"breakId" TEXT NOT NULL,
"dayOfWeek" "Weekday" NOT NULL,
CONSTRAINT "BreakDay_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TimeOff" (
"id" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"startDate" DATE NOT NULL,
"endDate" DATE NOT NULL,
"reason" TEXT,
"status" "TimeOffStatus" NOT NULL DEFAULT 'APPROVED',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TimeOff_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Appointment" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"status" "AppointmentStatus" NOT NULL DEFAULT 'CONFIRMED',
"startAt" TIMESTAMP(3) NOT NULL,
"endAt" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"cancellationReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Appointment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AppointmentService" (
"id" TEXT NOT NULL,
"appointmentId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"priceAtBooking" DECIMAL(10,2) NOT NULL,
"durationAtBookingMinutes" INTEGER NOT NULL,
CONSTRAINT "AppointmentService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyProgramme" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT false,
"stampsRequired" INTEGER NOT NULL DEFAULT 10,
"rewardType" "RewardType" NOT NULL DEFAULT 'PERCENTAGE',
"rewardValue" DECIMAL(10,2),
"rewardLabel" TEXT,
"maxRewardValue" DECIMAL(10,2),
"autoApplyReward" BOOLEAN NOT NULL DEFAULT true,
"requiresStaffApproval" BOOLEAN NOT NULL DEFAULT false,
"stampsExpireAfterDays" INTEGER,
"rewardExpiresAfterDays" INTEGER,
"earnOnCancelled" BOOLEAN NOT NULL DEFAULT false,
"earnOnNoShow" BOOLEAN NOT NULL DEFAULT false,
"cardBackgroundColor" TEXT,
"cardBackgroundImageUrl" TEXT,
"stampIcon" TEXT,
"termsLine" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltyProgramme_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyEligibleService" (
"id" TEXT NOT NULL,
"loyaltyProgrammeId" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
CONSTRAINT "LoyaltyEligibleService_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyEligibleStaff" (
"id" TEXT NOT NULL,
"loyaltyProgrammeId" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
CONSTRAINT "LoyaltyEligibleStaff_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyAccount" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"loyaltyProgrammeId" TEXT NOT NULL,
"currentStamps" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltyAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyTransaction" (
"id" TEXT NOT NULL,
"loyaltyAccountId" TEXT NOT NULL,
"type" "LoyaltyTransactionType" NOT NULL,
"amount" INTEGER NOT NULL,
"reason" TEXT,
"appointmentId" TEXT,
"authoredByStaffId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoyaltyTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LoyaltyReward" (
"id" TEXT NOT NULL,
"loyaltyAccountId" TEXT NOT NULL,
"rewardType" "RewardType" NOT NULL,
"value" DECIMAL(10,2),
"label" TEXT NOT NULL,
"status" "RewardStatus" NOT NULL DEFAULT 'AVAILABLE',
"appliedToAppointmentId" TEXT,
"redeemedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoyaltyReward_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"userId" TEXT,
"appointmentId" TEXT,
"category" "NotificationCategory" NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"status" "NotificationStatus" NOT NULL DEFAULT 'PENDING',
"subject" TEXT,
"body" TEXT NOT NULL,
"sentAt" TIMESTAMP(3),
"failureReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotificationPreference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"category" "NotificationCategory" NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustomerNote" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"authorStaffId" TEXT NOT NULL,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"actorUserId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Business_slug_key" ON "Business"("slug");
-- CreateIndex
CREATE INDEX "BusinessLocation_businessId_idx" ON "BusinessLocation"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "BusinessHours_businessId_dayOfWeek_key" ON "BusinessHours"("businessId", "dayOfWeek");
-- CreateIndex
CREATE UNIQUE INDEX "BusinessSettings_businessId_key" ON "BusinessSettings"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "BookingRule_businessId_key" ON "BookingRule"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "CustomerProfile_userId_key" ON "CustomerProfile"("userId");
-- CreateIndex
CREATE INDEX "CustomerProfile_email_idx" ON "CustomerProfile"("email");
-- CreateIndex
CREATE INDEX "CustomerProfile_phone_idx" ON "CustomerProfile"("phone");
-- CreateIndex
CREATE INDEX "CustomerProfile_preferredBarberId_idx" ON "CustomerProfile"("preferredBarberId");
-- CreateIndex
CREATE UNIQUE INDEX "StaffProfile_userId_key" ON "StaffProfile"("userId");
-- CreateIndex
CREATE INDEX "StaffProfile_businessId_idx" ON "StaffProfile"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "Role_businessId_name_key" ON "Role"("businessId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "RolePermission_roleId_permission_key" ON "RolePermission"("roleId", "permission");
-- CreateIndex
CREATE INDEX "ServiceCategory_businessId_idx" ON "ServiceCategory"("businessId");
-- CreateIndex
CREATE INDEX "Service_businessId_idx" ON "Service"("businessId");
-- CreateIndex
CREATE INDEX "Service_categoryId_idx" ON "Service"("categoryId");
-- CreateIndex
CREATE UNIQUE INDEX "StaffService_staffId_serviceId_key" ON "StaffService"("staffId", "serviceId");
-- CreateIndex
CREATE UNIQUE INDEX "WorkingHours_staffId_dayOfWeek_key" ON "WorkingHours"("staffId", "dayOfWeek");
-- CreateIndex
CREATE UNIQUE INDEX "BreakDay_breakId_dayOfWeek_key" ON "BreakDay"("breakId", "dayOfWeek");
-- CreateIndex
CREATE INDEX "TimeOff_staffId_startDate_endDate_idx" ON "TimeOff"("staffId", "startDate", "endDate");
-- CreateIndex
CREATE INDEX "Appointment_staffId_startAt_idx" ON "Appointment"("staffId", "startAt");
-- CreateIndex
CREATE INDEX "Appointment_businessId_startAt_idx" ON "Appointment"("businessId", "startAt");
-- CreateIndex
CREATE INDEX "Appointment_customerId_idx" ON "Appointment"("customerId");
-- CreateIndex
CREATE INDEX "AppointmentService_appointmentId_idx" ON "AppointmentService"("appointmentId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyProgramme_businessId_key" ON "LoyaltyProgramme"("businessId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyEligibleService_loyaltyProgrammeId_serviceId_key" ON "LoyaltyEligibleService"("loyaltyProgrammeId", "serviceId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyEligibleStaff_loyaltyProgrammeId_staffId_key" ON "LoyaltyEligibleStaff"("loyaltyProgrammeId", "staffId");
-- CreateIndex
CREATE UNIQUE INDEX "LoyaltyAccount_customerId_loyaltyProgrammeId_key" ON "LoyaltyAccount"("customerId", "loyaltyProgrammeId");
-- CreateIndex
CREATE INDEX "LoyaltyTransaction_loyaltyAccountId_idx" ON "LoyaltyTransaction"("loyaltyAccountId");
-- CreateIndex
CREATE INDEX "LoyaltyReward_loyaltyAccountId_idx" ON "LoyaltyReward"("loyaltyAccountId");
-- CreateIndex
CREATE INDEX "Notification_userId_idx" ON "Notification"("userId");
-- CreateIndex
CREATE INDEX "Notification_appointmentId_idx" ON "Notification"("appointmentId");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationPreference_userId_category_channel_key" ON "NotificationPreference"("userId", "category", "channel");
-- CreateIndex
CREATE INDEX "CustomerNote_customerId_idx" ON "CustomerNote"("customerId");
-- CreateIndex
CREATE INDEX "AuditLog_entityType_entityId_idx" ON "AuditLog"("entityType", "entityId");
-- CreateIndex
CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
-- AddForeignKey
ALTER TABLE "BusinessLocation" ADD CONSTRAINT "BusinessLocation_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BusinessHours" ADD CONSTRAINT "BusinessHours_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BusinessSettings" ADD CONSTRAINT "BusinessSettings_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingRule" ADD CONSTRAINT "BookingRule_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerProfile" ADD CONSTRAINT "CustomerProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerProfile" ADD CONSTRAINT "CustomerProfile_preferredBarberId_fkey" FOREIGN KEY ("preferredBarberId") REFERENCES "StaffProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffProfile" ADD CONSTRAINT "StaffProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffProfile" ADD CONSTRAINT "StaffProfile_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffProfile" ADD CONSTRAINT "StaffProfile_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Role" ADD CONSTRAINT "Role_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ServiceCategory" ADD CONSTRAINT "ServiceCategory_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Service" ADD CONSTRAINT "Service_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ServiceCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffService" ADD CONSTRAINT "StaffService_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffService" ADD CONSTRAINT "StaffService_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkingHours" ADD CONSTRAINT "WorkingHours_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Break" ADD CONSTRAINT "Break_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BreakDay" ADD CONSTRAINT "BreakDay_breakId_fkey" FOREIGN KEY ("breakId") REFERENCES "Break"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TimeOff" ADD CONSTRAINT "TimeOff_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "CustomerProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AppointmentService" ADD CONSTRAINT "AppointmentService_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AppointmentService" ADD CONSTRAINT "AppointmentService_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyProgramme" ADD CONSTRAINT "LoyaltyProgramme_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyEligibleService" ADD CONSTRAINT "LoyaltyEligibleService_loyaltyProgrammeId_fkey" FOREIGN KEY ("loyaltyProgrammeId") REFERENCES "LoyaltyProgramme"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyEligibleService" ADD CONSTRAINT "LoyaltyEligibleService_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyEligibleStaff" ADD CONSTRAINT "LoyaltyEligibleStaff_loyaltyProgrammeId_fkey" FOREIGN KEY ("loyaltyProgrammeId") REFERENCES "LoyaltyProgramme"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "CustomerProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyAccount" ADD CONSTRAINT "LoyaltyAccount_loyaltyProgrammeId_fkey" FOREIGN KEY ("loyaltyProgrammeId") REFERENCES "LoyaltyProgramme"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyTransaction" ADD CONSTRAINT "LoyaltyTransaction_loyaltyAccountId_fkey" FOREIGN KEY ("loyaltyAccountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyTransaction" ADD CONSTRAINT "LoyaltyTransaction_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyTransaction" ADD CONSTRAINT "LoyaltyTransaction_authoredByStaffId_fkey" FOREIGN KEY ("authoredByStaffId") REFERENCES "StaffProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_loyaltyAccountId_fkey" FOREIGN KEY ("loyaltyAccountId") REFERENCES "LoyaltyAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerNote" ADD CONSTRAINT "CustomerNote_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "CustomerProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerNote" ADD CONSTRAINT "CustomerNote_authorStaffId_fkey" FOREIGN KEY ("authorStaffId") REFERENCES "StaffProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,21 @@
-- AlterEnum
BEGIN;
CREATE TYPE "AppointmentStatus_new" AS ENUM ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW');
ALTER TABLE "public"."Appointment" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE "Appointment" ALTER COLUMN "status" TYPE "AppointmentStatus_new" USING ("status"::text::"AppointmentStatus_new");
ALTER TYPE "AppointmentStatus" RENAME TO "AppointmentStatus_old";
ALTER TYPE "AppointmentStatus_new" RENAME TO "AppointmentStatus";
DROP TYPE "public"."AppointmentStatus_old";
ALTER TABLE "Appointment" ALTER COLUMN "status" SET DEFAULT 'CONFIRMED';
COMMIT;
-- AlterTable
ALTER TABLE "Appointment" ADD COLUMN "cancelledAt" TIMESTAMP(3),
ADD COLUMN "cancelledByUserId" TEXT;
-- AlterTable
ALTER TABLE "BookingRule" ADD COLUMN "maxAppointmentsPerStaffPerDay" INTEGER;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_cancelledByUserId_fkey" FOREIGN KEY ("cancelledByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "Appointment" ADD COLUMN "createdByUserId" TEXT;
-- AlterTable
ALTER TABLE "CustomerNote" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- AlterTable
ALTER TABLE "TimeOff" ADD COLUMN "endMinutes" INTEGER,
ADD COLUMN "startMinutes" INTEGER;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,55 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "Permission" ADD VALUE 'MANAGE_ALL_APPOINTMENTS';
ALTER TYPE "Permission" ADD VALUE 'VIEW_REPORTING';
-- AlterTable
ALTER TABLE "Appointment" ADD COLUMN "discountAmount" DECIMAL(10,2);
-- AlterTable
ALTER TABLE "LoyaltyReward" ADD COLUMN "redeemedByStaffId" TEXT;
-- CreateTable
CREATE TABLE "StaffInvitation" (
"id" TEXT NOT NULL,
"businessId" TEXT NOT NULL,
"staffId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"token" TEXT NOT NULL,
"invitedByUserId" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StaffInvitation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "StaffInvitation_staffId_key" ON "StaffInvitation"("staffId");
-- CreateIndex
CREATE UNIQUE INDEX "StaffInvitation_token_key" ON "StaffInvitation"("token");
-- CreateIndex
CREATE INDEX "StaffInvitation_businessId_idx" ON "StaffInvitation"("businessId");
-- AddForeignKey
ALTER TABLE "StaffInvitation" ADD CONSTRAINT "StaffInvitation_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffInvitation" ADD CONSTRAINT "StaffInvitation_staffId_fkey" FOREIGN KEY ("staffId") REFERENCES "StaffProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffInvitation" ADD CONSTRAINT "StaffInvitation_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_appliedToAppointmentId_fkey" FOREIGN KEY ("appliedToAppointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_redeemedByStaffId_fkey" FOREIGN KEY ("redeemedByStaffId") REFERENCES "StaffProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "LoyaltyProgramme" ADD COLUMN "freeServiceId" TEXT;
-- AddForeignKey
ALTER TABLE "LoyaltyProgramme" ADD CONSTRAINT "LoyaltyProgramme_freeServiceId_fkey" FOREIGN KEY ("freeServiceId") REFERENCES "Service"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
@@ -0,0 +1,665 @@
-- CreateTable
CREATE TABLE `Business` (
`id` VARCHAR(191) NOT NULL,
`slug` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`logoUrl` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
`website` VARCHAR(191) NULL,
`instagramUrl` VARCHAR(191) NULL,
`facebookUrl` VARCHAR(191) NULL,
`timezone` VARCHAR(191) NOT NULL DEFAULT 'Europe/London',
`currency` VARCHAR(191) NOT NULL DEFAULT 'GBP',
`locale` VARCHAR(191) NOT NULL DEFAULT 'en-GB',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Business_slug_key`(`slug`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BusinessLocation` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL DEFAULT 'Main Location',
`addressLine1` VARCHAR(191) NOT NULL,
`addressLine2` VARCHAR(191) NULL,
`city` VARCHAR(191) NOT NULL,
`region` VARCHAR(191) NULL,
`postalCode` VARCHAR(191) NOT NULL,
`country` VARCHAR(191) NOT NULL DEFAULT 'GB',
`latitude` DECIMAL(9, 6) NULL,
`longitude` DECIMAL(9, 6) NULL,
`isPrimary` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `BusinessLocation_businessId_idx`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BusinessHours` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`dayOfWeek` ENUM('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY') NOT NULL,
`isClosed` BOOLEAN NOT NULL DEFAULT false,
`opensAtMinutes` INTEGER NULL,
`closesAtMinutes` INTEGER NULL,
UNIQUE INDEX `BusinessHours_businessId_dayOfWeek_key`(`businessId`, `dayOfWeek`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BusinessSettings` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`cancellationPolicy` TEXT NULL,
`lateArrivalPolicy` TEXT NULL,
`bookingTerms` TEXT NULL,
`privacyPolicyUrl` VARCHAR(191) NULL,
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `BusinessSettings_businessId_key`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BookingRule` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`minNoticeMinutes` INTEGER NOT NULL DEFAULT 120,
`maxAdvanceDays` INTEGER NOT NULL DEFAULT 60,
`cancellationWindowHours` INTEGER NOT NULL DEFAULT 24,
`rescheduleWindowHours` INTEGER NOT NULL DEFAULT 24,
`slotIntervalMinutes` INTEGER NOT NULL DEFAULT 30,
`bufferBeforeMinutes` INTEGER NOT NULL DEFAULT 0,
`bufferAfterMinutes` INTEGER NOT NULL DEFAULT 5,
`maxAppointmentsPerDay` INTEGER NULL,
`maxAppointmentsPerStaffPerDay` INTEGER NULL,
`customerCanChooseStaff` BOOLEAN NOT NULL DEFAULT true,
`allowGuestBooking` BOOLEAN NOT NULL DEFAULT true,
`requireApproval` BOOLEAN NOT NULL DEFAULT false,
`requireDeposit` BOOLEAN NOT NULL DEFAULT false,
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `BookingRule_businessId_key`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `User` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
`emailVerified` DATETIME(3) NULL,
`phone` VARCHAR(191) NULL,
`phoneVerified` DATETIME(3) NULL,
`image` VARCHAR(191) NULL,
`passwordHash` VARCHAR(191) NULL,
`isSuperAdmin` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `User_email_key`(`email`),
UNIQUE INDEX `User_phone_key`(`phone`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Account` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`type` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191) NOT NULL,
`providerAccountId` VARCHAR(191) NOT NULL,
`refresh_token` TEXT NULL,
`access_token` TEXT NULL,
`expires_at` INTEGER NULL,
`token_type` VARCHAR(191) NULL,
`scope` VARCHAR(191) NULL,
`id_token` TEXT NULL,
`session_state` VARCHAR(191) NULL,
UNIQUE INDEX `Account_provider_providerAccountId_key`(`provider`, `providerAccountId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Session` (
`id` VARCHAR(191) NOT NULL,
`sessionToken` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `Session_sessionToken_key`(`sessionToken`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `VerificationToken` (
`identifier` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`expires` DATETIME(3) NOT NULL,
UNIQUE INDEX `VerificationToken_token_key`(`token`),
UNIQUE INDEX `VerificationToken_identifier_token_key`(`identifier`, `token`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `CustomerProfile` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NULL,
`firstName` VARCHAR(191) NOT NULL,
`lastName` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`phone` VARCHAR(191) NULL,
`isGuest` BOOLEAN NOT NULL DEFAULT true,
`preferredBarberId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `CustomerProfile_userId_key`(`userId`),
INDEX `CustomerProfile_email_idx`(`email`),
INDEX `CustomerProfile_phone_idx`(`phone`),
INDEX `CustomerProfile_preferredBarberId_idx`(`preferredBarberId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `StaffProfile` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`roleId` VARCHAR(191) NOT NULL,
`bio` TEXT NULL,
`status` ENUM('ACTIVE', 'ON_BREAK', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE',
`isBookableOnline` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `StaffProfile_userId_key`(`userId`),
INDEX `StaffProfile_businessId_idx`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Role` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`isSystem` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `Role_businessId_name_key`(`businessId`, `name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `RolePermission` (
`id` VARCHAR(191) NOT NULL,
`roleId` VARCHAR(191) NOT NULL,
`permission` ENUM('MANAGE_BUSINESS_PROFILE', 'MANAGE_BOOKING_RULES', 'MANAGE_SERVICES', 'MANAGE_STAFF', 'MANAGE_LOYALTY_PROGRAMME', 'MANAGE_NOTIFICATION_SETTINGS', 'VIEW_ALL_STAFF_APPOINTMENTS', 'MANAGE_ALL_APPOINTMENTS', 'MANAGE_OWN_APPOINTMENTS', 'CHECK_IN_CUSTOMERS', 'MANAGE_LOYALTY_STAMPS', 'MANAGE_OWN_AVAILABILITY', 'VIEW_CUSTOMER_DIRECTORY', 'BOOK_ON_BEHALF_OF_CUSTOMER', 'VIEW_REPORTING') NOT NULL,
UNIQUE INDEX `RolePermission_roleId_permission_key`(`roleId`, `permission`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `StaffInvitation` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`invitedByUserId` VARCHAR(191) NULL,
`expiresAt` DATETIME(3) NOT NULL,
`acceptedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `StaffInvitation_staffId_key`(`staffId`),
UNIQUE INDEX `StaffInvitation_token_key`(`token`),
INDEX `StaffInvitation_businessId_idx`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `ServiceCategory` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`displayOrder` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `ServiceCategory_businessId_idx`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Service` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`categoryId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`description` TEXT NULL,
`durationMinutes` INTEGER NOT NULL,
`price` DECIMAL(10, 2) NOT NULL,
`bufferBeforeMinutes` INTEGER NOT NULL DEFAULT 0,
`bufferAfterMinutes` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`allowOnlineBooking` BOOLEAN NOT NULL DEFAULT true,
`displayOrder` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Service_businessId_idx`(`businessId`),
INDEX `Service_categoryId_idx`(`categoryId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `StaffService` (
`id` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`serviceId` VARCHAR(191) NOT NULL,
`priceOverride` DECIMAL(10, 2) NULL,
`durationOverrideMinutes` INTEGER NULL,
UNIQUE INDEX `StaffService_staffId_serviceId_key`(`staffId`, `serviceId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `WorkingHours` (
`id` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`dayOfWeek` ENUM('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY') NOT NULL,
`isOff` BOOLEAN NOT NULL DEFAULT false,
`startMinutes` INTEGER NULL,
`endMinutes` INTEGER NULL,
UNIQUE INDEX `WorkingHours_staffId_dayOfWeek_key`(`staffId`, `dayOfWeek`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Break` (
`id` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`label` VARCHAR(191) NOT NULL,
`startMinutes` INTEGER NOT NULL,
`endMinutes` INTEGER NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BreakDay` (
`id` VARCHAR(191) NOT NULL,
`breakId` VARCHAR(191) NOT NULL,
`dayOfWeek` ENUM('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY') NOT NULL,
UNIQUE INDEX `BreakDay_breakId_dayOfWeek_key`(`breakId`, `dayOfWeek`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `TimeOff` (
`id` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`startDate` DATE NOT NULL,
`endDate` DATE NOT NULL,
`reason` VARCHAR(191) NULL,
`status` ENUM('PENDING', 'APPROVED', 'DECLINED') NOT NULL DEFAULT 'APPROVED',
`startMinutes` INTEGER NULL,
`endMinutes` INTEGER NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `TimeOff_staffId_startDate_endDate_idx`(`staffId`, `startDate`, `endDate`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Appointment` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`status` ENUM('PENDING', 'CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW') NOT NULL DEFAULT 'CONFIRMED',
`startAt` DATETIME(3) NOT NULL,
`endAt` DATETIME(3) NOT NULL,
`notes` TEXT NULL,
`discountAmount` DECIMAL(10, 2) NULL,
`cancellationReason` VARCHAR(191) NULL,
`cancelledAt` DATETIME(3) NULL,
`cancelledByUserId` VARCHAR(191) NULL,
`createdByUserId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Appointment_staffId_startAt_idx`(`staffId`, `startAt`),
INDEX `Appointment_businessId_startAt_idx`(`businessId`, `startAt`),
INDEX `Appointment_customerId_idx`(`customerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `AppointmentService` (
`id` VARCHAR(191) NOT NULL,
`appointmentId` VARCHAR(191) NOT NULL,
`serviceId` VARCHAR(191) NOT NULL,
`priceAtBooking` DECIMAL(10, 2) NOT NULL,
`durationAtBookingMinutes` INTEGER NOT NULL,
INDEX `AppointmentService_appointmentId_idx`(`appointmentId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyProgramme` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`isEnabled` BOOLEAN NOT NULL DEFAULT false,
`stampsRequired` INTEGER NOT NULL DEFAULT 10,
`rewardType` ENUM('PERCENTAGE', 'FIXED_AMOUNT', 'FREE_SERVICE') NOT NULL DEFAULT 'PERCENTAGE',
`rewardValue` DECIMAL(10, 2) NULL,
`rewardLabel` VARCHAR(191) NULL,
`maxRewardValue` DECIMAL(10, 2) NULL,
`freeServiceId` VARCHAR(191) NULL,
`autoApplyReward` BOOLEAN NOT NULL DEFAULT true,
`requiresStaffApproval` BOOLEAN NOT NULL DEFAULT false,
`stampsExpireAfterDays` INTEGER NULL,
`rewardExpiresAfterDays` INTEGER NULL,
`earnOnCancelled` BOOLEAN NOT NULL DEFAULT false,
`earnOnNoShow` BOOLEAN NOT NULL DEFAULT false,
`cardBackgroundColor` VARCHAR(191) NULL,
`cardBackgroundImageUrl` VARCHAR(191) NULL,
`stampIcon` VARCHAR(191) NULL,
`termsLine` VARCHAR(191) NULL,
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `LoyaltyProgramme_businessId_key`(`businessId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyEligibleService` (
`id` VARCHAR(191) NOT NULL,
`loyaltyProgrammeId` VARCHAR(191) NOT NULL,
`serviceId` VARCHAR(191) NOT NULL,
UNIQUE INDEX `LoyaltyEligibleService_loyaltyProgrammeId_serviceId_key`(`loyaltyProgrammeId`, `serviceId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyEligibleStaff` (
`id` VARCHAR(191) NOT NULL,
`loyaltyProgrammeId` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
UNIQUE INDEX `LoyaltyEligibleStaff_loyaltyProgrammeId_staffId_key`(`loyaltyProgrammeId`, `staffId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyAccount` (
`id` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`loyaltyProgrammeId` VARCHAR(191) NOT NULL,
`currentStamps` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `LoyaltyAccount_customerId_loyaltyProgrammeId_key`(`customerId`, `loyaltyProgrammeId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyTransaction` (
`id` VARCHAR(191) NOT NULL,
`loyaltyAccountId` VARCHAR(191) NOT NULL,
`type` ENUM('STAMP_ADDED', 'STAMP_REMOVED', 'STAMP_EXPIRED', 'REWARD_REDEEMED') NOT NULL,
`amount` INTEGER NOT NULL,
`reason` VARCHAR(191) NULL,
`appointmentId` VARCHAR(191) NULL,
`authoredByStaffId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `LoyaltyTransaction_loyaltyAccountId_idx`(`loyaltyAccountId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `LoyaltyReward` (
`id` VARCHAR(191) NOT NULL,
`loyaltyAccountId` VARCHAR(191) NOT NULL,
`rewardType` ENUM('PERCENTAGE', 'FIXED_AMOUNT', 'FREE_SERVICE') NOT NULL,
`value` DECIMAL(10, 2) NULL,
`label` VARCHAR(191) NOT NULL,
`status` ENUM('AVAILABLE', 'REDEEMED', 'EXPIRED') NOT NULL DEFAULT 'AVAILABLE',
`appliedToAppointmentId` VARCHAR(191) NULL,
`redeemedAt` DATETIME(3) NULL,
`redeemedByStaffId` VARCHAR(191) NULL,
`expiresAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `LoyaltyReward_loyaltyAccountId_idx`(`loyaltyAccountId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Notification` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NULL,
`appointmentId` VARCHAR(191) NULL,
`category` ENUM('BOOKING_CONFIRMATION', 'BOOKING_REMINDER', 'BOOKING_CHANGED', 'BOOKING_CANCELLED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_AVAILABLE', 'MARKETING') NOT NULL,
`channel` ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
`status` ENUM('PENDING', 'SENT', 'FAILED') NOT NULL DEFAULT 'PENDING',
`subject` VARCHAR(191) NULL,
`body` TEXT NOT NULL,
`sentAt` DATETIME(3) NULL,
`failureReason` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `Notification_userId_idx`(`userId`),
INDEX `Notification_appointmentId_idx`(`appointmentId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `NotificationPreference` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`category` ENUM('BOOKING_CONFIRMATION', 'BOOKING_REMINDER', 'BOOKING_CHANGED', 'BOOKING_CANCELLED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_AVAILABLE', 'MARKETING') NOT NULL,
`channel` ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
`isEnabled` BOOLEAN NOT NULL DEFAULT true,
UNIQUE INDEX `NotificationPreference_userId_category_channel_key`(`userId`, `category`, `channel`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `CustomerNote` (
`id` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`authorStaffId` VARCHAR(191) NOT NULL,
`body` TEXT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CustomerNote_customerId_idx`(`customerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `AuditLog` (
`id` VARCHAR(191) NOT NULL,
`actorUserId` VARCHAR(191) NULL,
`action` VARCHAR(191) NOT NULL,
`entityType` VARCHAR(191) NOT NULL,
`entityId` VARCHAR(191) NOT NULL,
`metadata` JSON NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `AuditLog_entityType_entityId_idx`(`entityType`, `entityId`),
INDEX `AuditLog_actorUserId_idx`(`actorUserId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- AddForeignKey
ALTER TABLE `BusinessLocation` ADD CONSTRAINT `BusinessLocation_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BusinessHours` ADD CONSTRAINT `BusinessHours_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BusinessSettings` ADD CONSTRAINT `BusinessSettings_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BookingRule` ADD CONSTRAINT `BookingRule_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Account` ADD CONSTRAINT `Account_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Session` ADD CONSTRAINT `Session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerProfile` ADD CONSTRAINT `CustomerProfile_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerProfile` ADD CONSTRAINT `CustomerProfile_preferredBarberId_fkey` FOREIGN KEY (`preferredBarberId`) REFERENCES `StaffProfile`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StaffProfile` ADD CONSTRAINT `StaffProfile_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StaffProfile` ADD CONSTRAINT `StaffProfile_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StaffProfile` ADD CONSTRAINT `StaffProfile_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Role` ADD CONSTRAINT `Role_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StaffInvitation` ADD CONSTRAINT `StaffInvitation_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StaffInvitation` ADD CONSTRAINT `StaffInvitation_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StaffInvitation` ADD CONSTRAINT `StaffInvitation_invitedByUserId_fkey` FOREIGN KEY (`invitedByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ServiceCategory` ADD CONSTRAINT `ServiceCategory_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Service` ADD CONSTRAINT `Service_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Service` ADD CONSTRAINT `Service_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `ServiceCategory`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StaffService` ADD CONSTRAINT `StaffService_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `StaffService` ADD CONSTRAINT `StaffService_serviceId_fkey` FOREIGN KEY (`serviceId`) REFERENCES `Service`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `WorkingHours` ADD CONSTRAINT `WorkingHours_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Break` ADD CONSTRAINT `Break_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BreakDay` ADD CONSTRAINT `BreakDay_breakId_fkey` FOREIGN KEY (`breakId`) REFERENCES `Break`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TimeOff` ADD CONSTRAINT `TimeOff_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_cancelledByUserId_fkey` FOREIGN KEY (`cancelledByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_createdByUserId_fkey` FOREIGN KEY (`createdByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `AppointmentService` ADD CONSTRAINT `AppointmentService_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `AppointmentService` ADD CONSTRAINT `AppointmentService_serviceId_fkey` FOREIGN KEY (`serviceId`) REFERENCES `Service`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyProgramme` ADD CONSTRAINT `LoyaltyProgramme_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyProgramme` ADD CONSTRAINT `LoyaltyProgramme_freeServiceId_fkey` FOREIGN KEY (`freeServiceId`) REFERENCES `Service`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyEligibleService` ADD CONSTRAINT `LoyaltyEligibleService_loyaltyProgrammeId_fkey` FOREIGN KEY (`loyaltyProgrammeId`) REFERENCES `LoyaltyProgramme`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyEligibleService` ADD CONSTRAINT `LoyaltyEligibleService_serviceId_fkey` FOREIGN KEY (`serviceId`) REFERENCES `Service`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyEligibleStaff` ADD CONSTRAINT `LoyaltyEligibleStaff_loyaltyProgrammeId_fkey` FOREIGN KEY (`loyaltyProgrammeId`) REFERENCES `LoyaltyProgramme`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyAccount` ADD CONSTRAINT `LoyaltyAccount_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyAccount` ADD CONSTRAINT `LoyaltyAccount_loyaltyProgrammeId_fkey` FOREIGN KEY (`loyaltyProgrammeId`) REFERENCES `LoyaltyProgramme`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyTransaction` ADD CONSTRAINT `LoyaltyTransaction_loyaltyAccountId_fkey` FOREIGN KEY (`loyaltyAccountId`) REFERENCES `LoyaltyAccount`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyTransaction` ADD CONSTRAINT `LoyaltyTransaction_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyTransaction` ADD CONSTRAINT `LoyaltyTransaction_authoredByStaffId_fkey` FOREIGN KEY (`authoredByStaffId`) REFERENCES `StaffProfile`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyReward` ADD CONSTRAINT `LoyaltyReward_loyaltyAccountId_fkey` FOREIGN KEY (`loyaltyAccountId`) REFERENCES `LoyaltyAccount`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyReward` ADD CONSTRAINT `LoyaltyReward_appliedToAppointmentId_fkey` FOREIGN KEY (`appliedToAppointmentId`) REFERENCES `Appointment`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `LoyaltyReward` ADD CONSTRAINT `LoyaltyReward_redeemedByStaffId_fkey` FOREIGN KEY (`redeemedByStaffId`) REFERENCES `StaffProfile`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `NotificationPreference` ADD CONSTRAINT `NotificationPreference_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerNote` ADD CONSTRAINT `CustomerNote_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CustomerNote` ADD CONSTRAINT `CustomerNote_authorStaffId_fkey` FOREIGN KEY (`authorStaffId`) REFERENCES `StaffProfile`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE `GuestAppointmentAccessToken` (
`id` VARCHAR(191) NOT NULL,
`appointmentId` VARCHAR(191) NOT NULL,
`tokenHash` VARCHAR(191) NOT NULL,
`expiresAt` DATETIME(3) NOT NULL,
`revokedAt` DATETIME(3) NULL,
`lastUsedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `GuestAppointmentAccessToken_appointmentId_key`(`appointmentId`),
UNIQUE INDEX `GuestAppointmentAccessToken_tokenHash_key`(`tokenHash`),
INDEX `GuestAppointmentAccessToken_expiresAt_idx`(`expiresAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- AddForeignKey
ALTER TABLE `GuestAppointmentAccessToken` ADD CONSTRAINT `GuestAppointmentAccessToken_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,101 @@
-- Phase 7: redesign Notification/NotificationPreference around the
-- Notification (message) / NotificationDelivery (one attempt per channel)
-- split — see prisma/schema.prisma for the full rationale. Both tables
-- were unused scaffolding from Phase 2 (confirmed via a repo-wide grep for
-- `prisma.notification`/`prisma.notificationPreference` before writing
-- this migration — zero call sites), so these are safe destructive
-- ALTERs, not a real data migration.
-- DropForeignKey
ALTER TABLE `Notification` DROP FOREIGN KEY `Notification_userId_fkey`;
-- DropForeignKey
ALTER TABLE `NotificationPreference` DROP FOREIGN KEY `NotificationPreference_userId_fkey`;
-- DropIndex
DROP INDEX `Notification_userId_idx` ON `Notification`;
-- DropIndex
DROP INDEX `NotificationPreference_userId_category_channel_key` ON `NotificationPreference`;
-- AlterTable
ALTER TABLE `Notification` DROP COLUMN `body`,
DROP COLUMN `category`,
DROP COLUMN `channel`,
DROP COLUMN `failureReason`,
DROP COLUMN `sentAt`,
DROP COLUMN `status`,
DROP COLUMN `userId`,
ADD COLUMN `bodyText` TEXT NOT NULL,
ADD COLUMN `businessId` VARCHAR(191) NOT NULL,
ADD COLUMN `deepLinkPath` VARCHAR(191) NULL,
ADD COLUMN `idempotencyKey` VARCHAR(191) NOT NULL,
ADD COLUMN `loyaltyRewardId` VARCHAR(191) NULL,
ADD COLUMN `readAt` DATETIME(3) NULL,
ADD COLUMN `recipientCustomerId` VARCHAR(191) NULL,
ADD COLUMN `recipientUserId` VARCHAR(191) NULL,
ADD COLUMN `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL;
-- AlterTable
ALTER TABLE `NotificationPreference` DROP COLUMN `category`,
ADD COLUMN `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL,
MODIFY `channel` ENUM('EMAIL', 'PUSH', 'IN_APP') NOT NULL;
-- CreateTable
CREATE TABLE `NotificationDelivery` (
`id` VARCHAR(191) NOT NULL,
`notificationId` VARCHAR(191) NOT NULL,
`channel` ENUM('EMAIL', 'PUSH', 'IN_APP') NOT NULL,
`status` ENUM('PENDING', 'SCHEDULED', 'PROCESSING', 'SENT', 'FAILED', 'CANCELLED', 'SKIPPED') NOT NULL DEFAULT 'PENDING',
`scheduledFor` DATETIME(3) NULL,
`attemptCount` INTEGER NOT NULL DEFAULT 0,
`lastAttemptedAt` DATETIME(3) NULL,
`providerName` VARCHAR(191) NULL,
`providerMessageId` VARCHAR(191) NULL,
`failureReason` VARCHAR(191) NULL,
`sentAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `NotificationDelivery_status_scheduledFor_idx`(`status`, `scheduledFor`),
UNIQUE INDEX `NotificationDelivery_notificationId_channel_key`(`notificationId`, `channel`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateIndex
CREATE UNIQUE INDEX `Notification_idempotencyKey_key` ON `Notification`(`idempotencyKey`);
-- CreateIndex
CREATE INDEX `Notification_businessId_createdAt_idx` ON `Notification`(`businessId`, `createdAt`);
-- CreateIndex
CREATE INDEX `Notification_recipientUserId_readAt_idx` ON `Notification`(`recipientUserId`, `readAt`);
-- CreateIndex
CREATE INDEX `Notification_recipientCustomerId_idx` ON `Notification`(`recipientCustomerId`);
-- CreateIndex
CREATE UNIQUE INDEX `NotificationPreference_userId_type_channel_key` ON `NotificationPreference`(`userId`, `type`, `channel`);
-- AddForeignKey
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_recipientUserId_fkey` FOREIGN KEY (`recipientUserId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_recipientCustomerId_fkey` FOREIGN KEY (`recipientCustomerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_loyaltyRewardId_fkey` FOREIGN KEY (`loyaltyRewardId`) REFERENCES `LoyaltyReward`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `NotificationDelivery` ADD CONSTRAINT `NotificationDelivery_notificationId_fkey` FOREIGN KEY (`notificationId`) REFERENCES `Notification`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
-- Re-adds the FK this migration dropped above (to accommodate the
-- category->type column rename) but never restored — caught while writing
-- a later migration and diffing against the live dev DB, which showed
-- NotificationPreference.userId with no FK at all. Fixed here, in the
-- original migration, rather than a separate patch: this migration had
-- only ever been applied to this local dev database, never shared.
ALTER TABLE `NotificationPreference` ADD CONSTRAINT `NotificationPreference_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- Adds STAFF_INVITATION_RECEIVED to NotificationType — the invite email
-- itself, sent to the person being invited (see StaffInvitationService and
-- templates/staff-emails.ts renderStaffInvitationEmail). Distinct from the
-- existing OWNER_STAFF_INVITATION_ACCEPTED, which notifies the owner once
-- accepted.
-- AlterTable
ALTER TABLE `Notification` MODIFY `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'STAFF_INVITATION_RECEIVED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL;
-- AlterTable
ALTER TABLE `NotificationPreference` MODIFY `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'STAFF_INVITATION_RECEIVED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL;
@@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE `PushSubscription` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NULL,
`customerId` VARCHAR(191) NULL,
`endpoint` VARCHAR(500) NOT NULL,
`p256dhKey` VARCHAR(191) NOT NULL,
`authKey` VARCHAR(191) NOT NULL,
`deviceLabel` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`lastUsedAt` DATETIME(3) NULL,
`revokedAt` DATETIME(3) NULL,
UNIQUE INDEX `PushSubscription_endpoint_key`(`endpoint`),
INDEX `PushSubscription_userId_idx`(`userId`),
INDEX `PushSubscription_customerId_idx`(`customerId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- AddForeignKey
ALTER TABLE `PushSubscription` ADD CONSTRAINT `PushSubscription_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PushSubscription` ADD CONSTRAINT `PushSubscription_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PushSubscription` ADD CONSTRAINT `PushSubscription_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- RenameIndex
ALTER TABLE `Break` RENAME INDEX `Break_staffId_fkey` TO `Break_staffId_idx`;
@@ -0,0 +1,77 @@
-- AlterTable
ALTER TABLE `BookingRule` MODIFY `allowGuestBooking` BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE `Business` ADD COLUMN `appIconUrl` VARCHAR(191) NULL,
ADD COLUMN `backgroundColor` VARCHAR(191) NULL,
ADD COLUMN `coverImageUrl` VARCHAR(191) NULL,
ADD COLUMN `faviconUrl` VARCHAR(191) NULL,
ADD COLUMN `shortName` VARCHAR(191) NULL,
ADD COLUMN `themeColor` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `ApplicationInstallation` (
`id` VARCHAR(191) NOT NULL,
`status` ENUM('NOT_INSTALLED', 'INSTALLING', 'INSTALLED', 'FAILED') NOT NULL DEFAULT 'NOT_INSTALLED',
`installedAt` DATETIME(3) NULL,
`installedVersion` VARCHAR(191) NULL,
`initialAdminUserId` VARCHAR(191) NULL,
`primaryBusinessId` VARCHAR(191) NULL,
`metadata` JSON NULL,
`failureReason` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `StaffCalendarConnection` (
`id` VARCHAR(191) NOT NULL,
`staffId` VARCHAR(191) NOT NULL,
`provider` ENUM('GOOGLE') NOT NULL DEFAULT 'GOOGLE',
`externalAccountId` VARCHAR(191) NOT NULL,
`accountEmail` VARCHAR(191) NOT NULL,
`encryptedAccessToken` TEXT NULL,
`encryptedRefreshToken` TEXT NOT NULL,
`tokenExpiresAt` DATETIME(3) NULL,
`selectedCalendarId` VARCHAR(191) NOT NULL,
`selectedCalendarName` VARCHAR(191) NOT NULL,
`connectedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`lastSyncAt` DATETIME(3) NULL,
`lastSyncError` TEXT NULL,
`revokedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `StaffCalendarConnection_staffId_key`(`staffId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `AppointmentCalendarEvent` (
`id` VARCHAR(191) NOT NULL,
`appointmentId` VARCHAR(191) NOT NULL,
`connectionId` VARCHAR(191) NOT NULL,
`providerEventId` VARCHAR(191) NOT NULL,
`providerCalendarId` VARCHAR(191) NOT NULL,
`status` ENUM('SYNCED', 'PENDING', 'FAILED', 'CANCELLED') NOT NULL DEFAULT 'SYNCED',
`lastSyncedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`lastSyncError` TEXT NULL,
`sourceVersion` INTEGER NOT NULL DEFAULT 1,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `AppointmentCalendarEvent_connectionId_idx`(`connectionId`),
UNIQUE INDEX `AppointmentCalendarEvent_appointmentId_connectionId_key`(`appointmentId`, `connectionId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- AddForeignKey
ALTER TABLE `StaffCalendarConnection` ADD CONSTRAINT `StaffCalendarConnection_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `AppointmentCalendarEvent` ADD CONSTRAINT `AppointmentCalendarEvent_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `AppointmentCalendarEvent` ADD CONSTRAINT `AppointmentCalendarEvent_connectionId_fkey` FOREIGN KEY (`connectionId`) REFERENCES `StaffCalendarConnection`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,88 @@
-- AlterTable
ALTER TABLE `Business` ADD COLUMN `googlePlaceId` VARCHAR(191) NULL,
ADD COLUMN `googleRating` DECIMAL(2, 1) NULL,
ADD COLUMN `googleRatingCount` INTEGER NULL,
ADD COLUMN `welcomeBackgroundDesktopUrl` VARCHAR(191) NULL,
ADD COLUMN `welcomeBackgroundMobileUrl` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `BusinessSettings` ADD COLUMN `reviewsExcludeRatingOnly` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `reviewsMinRating` INTEGER NULL;
-- AlterTable
ALTER TABLE `RolePermission` MODIFY `permission` ENUM('MANAGE_BUSINESS_PROFILE', 'MANAGE_BOOKING_RULES', 'MANAGE_SERVICES', 'MANAGE_STAFF', 'MANAGE_LOYALTY_PROGRAMME', 'MANAGE_NOTIFICATION_SETTINGS', 'VIEW_ALL_STAFF_APPOINTMENTS', 'MANAGE_ALL_APPOINTMENTS', 'MANAGE_OWN_APPOINTMENTS', 'CHECK_IN_CUSTOMERS', 'MANAGE_LOYALTY_STAMPS', 'MANAGE_OWN_AVAILABILITY', 'VIEW_CUSTOMER_DIRECTORY', 'BOOK_ON_BEHALF_OF_CUSTOMER', 'VIEW_REPORTING', 'MANAGE_MEDIA_LIBRARY', 'MANAGE_REVIEWS') NOT NULL;
-- AlterTable
ALTER TABLE `Service` ADD COLUMN `imageUrl` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `MediaAsset` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`url` VARCHAR(500) NOT NULL,
`originalFilename` VARCHAR(191) NULL,
`mimeType` VARCHAR(191) NOT NULL,
`sizeBytes` INTEGER NOT NULL,
`width` INTEGER NULL,
`height` INTEGER NULL,
`category` VARCHAR(191) NULL,
`uploadedByUserId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `MediaAsset_businessId_category_idx`(`businessId`, `category`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `BusinessGalleryImage` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`placement` ENUM('HERO', 'PORTFOLIO') NOT NULL,
`mediaAssetId` VARCHAR(191) NULL,
`imageUrl` VARCHAR(500) NOT NULL,
`category` VARCHAR(191) NULL,
`caption` VARCHAR(191) NULL,
`displayOrder` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT true,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `BusinessGalleryImage_businessId_placement_displayOrder_idx`(`businessId`, `placement`, `displayOrder`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- CreateTable
CREATE TABLE `Review` (
`id` VARCHAR(191) NOT NULL,
`businessId` VARCHAR(191) NOT NULL,
`externalId` VARCHAR(191) NOT NULL,
`authorName` VARCHAR(191) NOT NULL,
`authorPhotoUrl` VARCHAR(191) NULL,
`rating` INTEGER NOT NULL,
`text` TEXT NULL,
`relativeTimeDescription` VARCHAR(191) NULL,
`publishedAt` DATETIME(3) NULL,
`isHidden` BOOLEAN NOT NULL DEFAULT false,
`fetchedAt` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Review_businessId_isHidden_idx`(`businessId`, `isHidden`),
UNIQUE INDEX `Review_businessId_externalId_key`(`businessId`, `externalId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- AddForeignKey
ALTER TABLE `MediaAsset` ADD CONSTRAINT `MediaAsset_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `MediaAsset` ADD CONSTRAINT `MediaAsset_uploadedByUserId_fkey` FOREIGN KEY (`uploadedByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BusinessGalleryImage` ADD CONSTRAINT `BusinessGalleryImage_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BusinessGalleryImage` ADD CONSTRAINT `BusinessGalleryImage_mediaAssetId_fkey` FOREIGN KEY (`mediaAssetId`) REFERENCES `MediaAsset`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Review` ADD CONSTRAINT `Review_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE `LoyaltyProgramme` ADD COLUMN `cardBorderColor` VARCHAR(191) NULL,
ADD COLUMN `cardFont` ENUM('SANS', 'SERIF', 'MONO') NOT NULL DEFAULT 'SANS',
ADD COLUMN `cardMemberNumberColor` VARCHAR(191) NULL,
ADD COLUMN `cardTopRightText` VARCHAR(191) NULL,
ADD COLUMN `cardTopRightTextColor` VARCHAR(191) NULL,
ADD COLUMN `cardTopRightTextSize` ENUM('SM', 'MD', 'LG') NOT NULL DEFAULT 'MD',
ADD COLUMN `rewardPosition` ENUM('AFTER_ALL_STAMPS', 'FINAL_SLOT_IS_REWARD') NOT NULL DEFAULT 'AFTER_ALL_STAMPS';
-- Stamp-limit enforcement (Phase 10 follow-up brief: "Valid range: 1–10").
-- Confirmed via a direct read of the live database before writing this
-- migration that no existing LoyaltyProgramme row has stampsRequired > 10
-- and no LoyaltyAccount has currentStamps > 10 — safe to add directly,
-- no data migration needed. Defense-in-depth alongside the identical
-- 1-10 check in validateProgrammeConfig() and the owner form's client
-- validation; MySQL 8.0.16+ enforces CHECK constraints (this app's
-- documented target, see schema.prisma's own header).
ALTER TABLE `LoyaltyProgramme` ADD CONSTRAINT `LoyaltyProgramme_stampsRequired_range`
CHECK (`stampsRequired` BETWEEN 1 AND 10);
@@ -0,0 +1,31 @@
-- Backfill MANAGE_MEDIA_LIBRARY and MANAGE_REVIEWS (added in Phase 9)
-- onto every role that already holds MANAGE_STAFF — the app's existing
-- definition of "owner" (see src/services/staff/staff.service.ts's
-- countOtherActiveManagers, "A role holding MANAGE_STAFF is this app's
-- definition of 'an owner'"). syncRolePermissions() only ever runs at
-- install/seed time, so any business installed before Phase 9 has its
-- Owner role stuck on the old 15-permission set — confirmed by reading
-- the actual RolePermission rows on a real installed business before
-- writing this migration.
--
-- This is what silently made /owner/settings/reviews redirect away
-- (hasPermission(..., MANAGE_REVIEWS) failing, redirecting to /staff,
-- which then renders the barber calendar — looking exactly like
-- "Reviews opens Calendar") and made Media Manager deletes silently fail
-- (MANAGE_MEDIA_LIBRARY). Not a routing bug — a missing data backfill.
--
-- Idempotent: a fresh install's Owner role already has both permissions
-- from syncRolePermissions() at install time, and INSERT IGNORE plus the
-- existing @@unique([roleId, permission]) constraint make re-running
-- this migration (or running it against an already-correct database)
-- a safe no-op.
INSERT IGNORE INTO `RolePermission` (`id`, `roleId`, `permission`)
SELECT UUID(), owner.`roleId`, 'MANAGE_MEDIA_LIBRARY'
FROM `RolePermission` owner
WHERE owner.`permission` = 'MANAGE_STAFF';
INSERT IGNORE INTO `RolePermission` (`id`, `roleId`, `permission`)
SELECT UUID(), owner.`roleId`, 'MANAGE_REVIEWS'
FROM `RolePermission` owner
WHERE owner.`permission` = 'MANAGE_STAFF';
@@ -0,0 +1,31 @@
-- Rewrite any already-stored `/media/...` URLs (the pre-Phase-10 upload
-- path, which never actually worked in production — Next.js 16's
-- `next start` does not dynamically serve files added to public/ after
-- the build; see src/lib/media/media-storage.ts's header for the full
-- account) to the new `/api/media/...` prefix served by
-- src/app/api/media/[...path]/route.ts.
--
-- Confirmed via a direct read of the live database before writing this
-- migration: exactly one MediaAsset row and its matching
-- BusinessGalleryImage row use the old prefix; every other image field
-- currently stores a full external URL (Business.logoUrl/coverImageUrl,
-- etc.) and is untouched by this. Every statement below is still
-- included for completeness/safety, covers every image column that can
-- receive a MediaPicker-sourced URL, and is a no-op wherever nothing
-- matches.
--
-- Idempotent: re-running finds nothing left matching `/media/%` (rows
-- already rewritten now start with `/api/media/%`), so this is safe to
-- apply more than once.
UPDATE `MediaAsset` SET `url` = CONCAT('/api', `url`) WHERE `url` LIKE '/media/%';
UPDATE `BusinessGalleryImage` SET `imageUrl` = CONCAT('/api', `imageUrl`) WHERE `imageUrl` LIKE '/media/%';
UPDATE `Business` SET `logoUrl` = CONCAT('/api', `logoUrl`) WHERE `logoUrl` LIKE '/media/%';
UPDATE `Business` SET `coverImageUrl` = CONCAT('/api', `coverImageUrl`) WHERE `coverImageUrl` LIKE '/media/%';
UPDATE `Business` SET `faviconUrl` = CONCAT('/api', `faviconUrl`) WHERE `faviconUrl` LIKE '/media/%';
UPDATE `Business` SET `appIconUrl` = CONCAT('/api', `appIconUrl`) WHERE `appIconUrl` LIKE '/media/%';
UPDATE `Business` SET `welcomeBackgroundDesktopUrl` = CONCAT('/api', `welcomeBackgroundDesktopUrl`) WHERE `welcomeBackgroundDesktopUrl` LIKE '/media/%';
UPDATE `Business` SET `welcomeBackgroundMobileUrl` = CONCAT('/api', `welcomeBackgroundMobileUrl`) WHERE `welcomeBackgroundMobileUrl` LIKE '/media/%';
UPDATE `LoyaltyProgramme` SET `cardBackgroundImageUrl` = CONCAT('/api', `cardBackgroundImageUrl`) WHERE `cardBackgroundImageUrl` LIKE '/media/%';
UPDATE `Service` SET `imageUrl` = CONCAT('/api', `imageUrl`) WHERE `imageUrl` LIKE '/media/%';
UPDATE `User` SET `image` = CONCAT('/api', `image`) WHERE `image` LIKE '/media/%';
@@ -0,0 +1,9 @@
-- Phase 11: the loyalty card's owner-configurable text now replaces the
-- hardcoded "Loyalty Card" title (top-left), not a secondary badge next to
-- the member number (top-right) — renamed to match, using CHANGE COLUMN so
-- any value an owner already configured (e.g. a custom title) survives the
-- rename instead of being dropped.
ALTER TABLE `LoyaltyProgramme`
CHANGE COLUMN `cardTopRightText` `cardTitleText` VARCHAR(191) NULL,
CHANGE COLUMN `cardTopRightTextSize` `cardTitleTextSize` ENUM('SM', 'MD', 'LG') NOT NULL DEFAULT 'MD',
CHANGE COLUMN `cardTopRightTextColor` `cardTitleTextColor` VARCHAR(191) NULL;
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE `Business` ADD COLUMN `icon192Url` VARCHAR(191) NULL,
ADD COLUMN `icon512Url` VARCHAR(191) NULL,
ADD COLUMN `maskableIcon192Url` VARCHAR(191) NULL,
ADD COLUMN `maskableIcon512Url` VARCHAR(191) NULL,
ADD COLUMN `splashScreenLandscapeUrl` VARCHAR(191) NULL,
ADD COLUMN `splashScreenPortraitUrl` VARCHAR(191) NULL;
@@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `splashScreenLandscapeUrl` on the `Business` table. All the data in the column will be lost.
- You are about to drop the column `splashScreenPortraitUrl` on the `Business` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE `Business` DROP COLUMN `splashScreenLandscapeUrl`,
DROP COLUMN `splashScreenPortraitUrl`,
ADD COLUMN `splashScreens` JSON NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `Business` ADD COLUMN `bottomNavShowLabels` BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+132
View File
@@ -0,0 +1,132 @@
// Service worker — internal filename/comment only, never shown to a user.
//
// Scope, deliberately: this app is almost entirely dynamic, signed-in,
// per-request server-rendered content (appointments, calendars, loyalty
// balances) — caching that would risk serving stale or, worse, another
// user's data. So this file does NOT try to make the booking flow work
// offline. What it does do:
// - Push notifications (`push`/`notificationclick` — unchanged from the
// original Phase 7 push-notifications work).
// - Precache + serve a small static app shell (offline fallback page,
// manifest, icons) so a lost connection shows a real "you're offline"
// screen instead of the browser's own dinosaur/error page.
// - An update-available handshake: a new SW version installs and waits
// rather than taking over immediately, so the running page can prompt
// the user before switching (see update-available-banner.tsx).
//
// Every GET request that isn't the app shell or a navigation falls
// through untouched (`fetch` handler doesn't call respondWith), and every
// non-GET request (Server Actions, API mutations) is never intercepted at
// all — this worker must never sit between the app and a write.
// v2: /manifest.webmanifest was previously cache-first here alongside the
// truly static icon fallbacks — but it's dynamic, per-business content
// (name/icons/colors, editable from "App Icon & Home Screen" settings),
// not a static build asset. Cache-first meant a phone that had ever
// loaded it once would keep serving that stale response indefinitely,
// regardless of any later branding change or server-side fix — bumping
// CACHE_VERSION forces every existing install to drop its old cache
// (including any previously-cached manifest response) on next activate.
const CACHE_VERSION = "v2";
const CACHE_NAME = `nextchair-shell-${CACHE_VERSION}`;
const OFFLINE_URL = "/offline";
const APP_SHELL = [OFFLINE_URL, "/icons/icon-192.png", "/icons/icon-512.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
// Deliberately no self.skipWaiting() here — an already-open tab keeps
// running its current version until the user acts on the
// update-available prompt (see the `message` handler below).
.catch(() => {
// A failed precache (e.g. first install with no network at all)
// shouldn't block installation — push notifications should still
// work even if the offline shell couldn't be cached yet.
}),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Page navigations: network-first (this app is dynamic — a cached page
// would be stale or session-mismatched), falling back to the offline
// shell only when the network is actually unreachable.
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(() => caches.match(OFFLINE_URL).then((cached) => cached ?? Response.error())),
);
return;
}
// The static app-shell assets themselves: cache-first, since these are
// versioned by CACHE_NAME and only change on a new SW deploy.
if (APP_SHELL.some((path) => url.pathname === path)) {
event.respondWith(
caches.match(request).then((cached) => cached ?? fetch(request)),
);
return;
}
// Everything else (Next static build assets, API routes, images) —
// untouched, normal network fetch.
});
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
self.addEventListener("push", (event) => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
return;
}
// The server always sets a real, business-branded title (see
// src/services/notifications/reminder-worker.ts) — this is only a
// defensive fallback for a malformed payload, so it stays neutral
// rather than naming the internal product.
const title = payload.title || "New notification";
const options = {
body: payload.body || "",
data: { url: payload.url || "/" },
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes(url) && "focus" in client) return client.focus();
}
if (self.clients.openWindow) return self.clients.openWindow(url);
}),
);
});
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
echo "Usage: $0 <business-slug> <domain> [image-tag] [business-name]" >&2
echo "Example: $0 blake-heritage blakeheritage.com nextchair:2026-09-04 \"Blake Heritage\"" >&2
exit 2
}
[[ $# -ge 2 && $# -le 4 ]] || usage
business_slug="$1"
domain="$2"
image_tag="${3:-nextchair:latest}"
business_name="${4:-$business_slug}"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
stack_root="${NEXTCHAIR_STACK_ROOT:-${repo_root}/stacks}"
infra_env="${NEXTCHAIR_INFRA_ENV:-${repo_root}/.env.infra}"
stack_dir="${stack_root}/${business_slug}"
compose_template="${repo_root}/deploy/business-compose.yaml.template"
[[ "$business_slug" =~ ^[a-z0-9]+([a-z0-9-]*[a-z0-9])?$ ]] || { echo "Invalid slug: use lowercase letters, numbers, and hyphens." >&2; exit 2; }
[[ ${#business_slug} -le 22 ]] || { echo "Invalid slug: maximum length is 22 characters." >&2; exit 2; }
[[ "$domain" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "Invalid domain." >&2; exit 2; }
[[ "$image_tag" =~ ^[A-Za-z0-9._/@:-]+$ ]] || { echo "Invalid image tag." >&2; exit 2; }
[[ -f "$infra_env" ]] || { echo "Missing $infra_env. Copy .env.infra.example first." >&2; exit 1; }
[[ -f "$compose_template" ]] || { echo "Missing $compose_template." >&2; exit 1; }
[[ ! -e "$stack_dir" ]] || { echo "Stack already exists: $stack_dir" >&2; exit 1; }
# shellcheck disable=SC1090
source "$infra_env"
[[ -n "${MYSQL_ROOT_PASSWORD:-}" ]] || { echo "MYSQL_ROOT_PASSWORD is required in $infra_env." >&2; exit 1; }
database_name="nextchair_${business_slug//-/_}"
database_user="$database_name"
database_password="$(openssl rand -hex 24)"
auth_secret="$(openssl rand -base64 48 | tr -d '\n')"
installation_token="$(openssl rand -hex 32)"
cron_secret="$(openssl rand -hex 32)"
docker network inspect nextchair-db >/dev/null 2>&1 || docker network create nextchair-db >/dev/null
docker network inspect nextchair-proxy >/dev/null 2>&1 || docker network create nextchair-proxy >/dev/null
docker inspect nextchair-mysql >/dev/null 2>&1 || { echo "nextchair-mysql is not running. Start stacks/infra/compose.yaml first." >&2; exit 1; }
printf 'Creating database %s...\n' "$database_name"
sql="CREATE DATABASE IF NOT EXISTS \`$database_name\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; CREATE USER IF NOT EXISTS '$database_user'@'%' IDENTIFIED BY '$database_password'; ALTER USER '$database_user'@'%' IDENTIFIED BY '$database_password'; GRANT ALL PRIVILEGES ON \`$database_name\`.* TO '$database_user'@'%'; FLUSH PRIVILEGES;"
printf '%s\n' "$sql" | docker exec -i nextchair-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD"
mkdir -p "$stack_dir"
sed \
-e "s|\${BUSINESS_SLUG}|$business_slug|g" \
-e "s|\${NEXTCHAIR_IMAGE:-nextchair:latest}|$image_tag|g" \
"$compose_template" > "$stack_dir/compose.yaml"
cat > "$stack_dir/.env" <<EOF
DOMAIN="$domain"
DATABASE_HOST="nextchair-mysql"
DATABASE_PORT=3306
DATABASE_NAME="$database_name"
DATABASE_USER="$database_user"
DATABASE_PASSWORD="$database_password"
DATABASE_CONNECTION_LIMIT=5
DATABASE_CONNECT_TIMEOUT_MS=10000
DATABASE_SSL=false
AUTH_SECRET="$auth_secret"
AUTH_TRUST_HOST=true
NEXT_PUBLIC_APP_URL="https://$domain"
INSTALLATION_TOKEN="$installation_token"
CRON_SECRET="$cron_secret"
NEXTCHAIR_IMAGE="$image_tag"
EOF
caddy_sites_dir="${NEXTCHAIR_CADDY_SITES_DIR:-${stack_root}/caddy/sites}"
mkdir -p "$caddy_sites_dir"
cat > "$caddy_sites_dir/$business_slug.caddy" <<EOF
$domain {
reverse_proxy nextchair-$business_slug:3000
}
EOF
registry_file="$stack_root/registry.json"
REGISTRY_FILE="$registry_file" BUSINESS_NAME="$business_name" BUSINESS_SLUG="$business_slug" BUSINESS_DOMAIN="$domain" BUSINESS_IMAGE="$image_tag" node <<'NODE'
const fs = require("node:fs");
const file = process.env.REGISTRY_FILE;
const items = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : [];
const now = new Date().toISOString();
items.push({
name: process.env.BUSINESS_NAME,
slug: process.env.BUSINESS_SLUG,
domain: process.env.BUSINESS_DOMAIN,
image: process.env.BUSINESS_IMAGE,
createdAt: now,
updatedAt: now,
});
fs.writeFileSync(file, `${JSON.stringify(items, null, 2)}\n`, { mode: 0o600 });
NODE
chmod 600 "$stack_dir/.env"
printf '\nCreated Dockge stack: %s\n' "$stack_dir"
printf 'Created Caddy route: %s/%s.caddy\n' "$caddy_sites_dir" "$business_slug"
printf 'Start it from Dockge, or run: docker compose -f %s up -d\n' "$stack_dir/compose.yaml"
@@ -0,0 +1,83 @@
/**
* Idempotent Super Administrator provisioning, run on every container
* start (see docker/entrypoint.sh) — NOT part of the installation wizard,
* per the brief's explicit requirement that this account come from
* environment variables. Safe to run repeatedly: re-hashes and syncs the
* password/name from the env vars every run, so rotating
* SUPER_ADMIN_PASSWORD and restarting the container is how you change it.
*
* Deliberately does NOT create a StaffProfile — that's what keeps the
* Super Administrator out of the Team list, the customer-facing barber
* picker, and off the Appointment.staffId foreign key entirely, by
* construction rather than a filter. See src/lib/auth/permissions.ts
* (hasPermission's isSuperAdmin short-circuit) and
* src/lib/business/get-acting-business-context.ts.
*
* Uses relative imports (not the "@/..." alias) and constructs its own
* PrismaClient, matching prisma/seed.ts — both scripts run under `tsx`
* outside Next's own module resolution/bundling.
*/
import "dotenv/config";
import bcrypt from "bcryptjs";
import { PrismaClient } from "../generated/prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
const BCRYPT_COST = 12; // matches src/features/installation/installer.service.ts
async function main() {
const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase();
if (!email) {
console.log("[ensure-super-admin] SUPER_ADMIN_EMAIL not set — skipping (no Super Administrator configured).");
return;
}
const password = process.env.SUPER_ADMIN_PASSWORD;
if (!password || password.length < 8) {
console.error(
"[ensure-super-admin] SUPER_ADMIN_EMAIL is set but SUPER_ADMIN_PASSWORD is missing or shorter than 8 characters. Refusing to provision a Super Administrator with no usable password.",
);
process.exitCode = 1;
return;
}
const firstName = process.env.SUPER_ADMIN_FIRST_NAME?.trim() ?? "";
const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? "";
const name = [firstName, lastName].filter(Boolean).join(" ") || null;
const adapter = new PrismaMariaDb(buildMysqlPoolConfig());
const prisma = new PrismaClient({ adapter });
try {
const passwordHash = await bcrypt.hash(password, BCRYPT_COST);
const existing = await prisma.user.findUnique({ where: { email } });
if (existing && !existing.isSuperAdmin) {
console.error(
`[ensure-super-admin] A user with email ${email} already exists and is not a Super Administrator. Refusing to overwrite it — use a different SUPER_ADMIN_EMAIL or remove the conflicting account first.`,
);
process.exitCode = 1;
return;
}
if (existing) {
await prisma.user.update({
where: { id: existing.id },
data: { name, passwordHash, emailVerified: existing.emailVerified ?? new Date() },
});
console.log(`[ensure-super-admin] Synced credentials for existing Super Administrator (${email}).`);
} else {
await prisma.user.create({
data: { email, name, passwordHash, isSuperAdmin: true, emailVerified: new Date() },
});
console.log(`[ensure-super-admin] Created Super Administrator (${email}).`);
}
} finally {
await prisma.$disconnect();
}
}
main().catch((error) => {
console.error("[ensure-super-admin] Failed:", error);
process.exitCode = 1;
});
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
// The Manager must not load the business Auth.js module even if a stale
// client requests `/api/auth/session`.
vi.mock("@/auth", () => {
throw new Error("Business Auth.js must not load in Manager mode.");
});
import { GET } from "@/app/api/auth/[...nextauth]/route";
describe("Manager business-auth boundary", () => {
const originalManagerMode = process.env.MANAGER_MODE;
afterEach(() => {
if (originalManagerMode === undefined) delete process.env.MANAGER_MODE;
else process.env.MANAGER_MODE = originalManagerMode;
});
it("returns 404 without importing business Auth.js", async () => {
process.env.MANAGER_MODE = "true";
const response = await GET(new NextRequest("https://manager.example.com/api/auth/session"));
expect(response.status).toBe(404);
});
});
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db/prisma", () => {
throw new Error("Business Prisma must not load in Manager framework routes.");
});
vi.mock("@/lib/business/branding", () => {
throw new Error("Business branding must not load in Manager framework routes.");
});
import { GET as health } from "@/app/api/health/route";
import manifest from "@/app/manifest";
describe("Manager framework routes", () => {
const originalManagerMode = process.env.MANAGER_MODE;
afterEach(() => {
if (originalManagerMode === undefined) delete process.env.MANAGER_MODE;
else process.env.MANAGER_MODE = originalManagerMode;
});
it("serves health without Prisma", async () => {
process.env.MANAGER_MODE = "true";
const response = await health();
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ status: "healthy", deployment: "manager" });
});
it("serves Manager manifest without business branding", async () => {
process.env.MANAGER_MODE = "true";
const result = await manifest();
expect(result.name).toBe("NextChair Manager");
expect(result.start_url).toBe("/manager");
});
});
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const isInstalledForRequest = vi.fn();
// Keep the Auth.js wrapper observable. Manager requests must not construct or
// invoke it; business requests still must use it for req.auth handling.
const { nextAuth, handleProxyRequestForTest } = vi.hoisted(() => {
const handleProxyRequestForTest = vi.fn(async (_request: NextRequest) => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => ({
auth: (request: NextRequest) => handleProxyRequestForTest(request),
}));
return { nextAuth, handleProxyRequestForTest };
});
// If proxy.ts or its Manager request path statically imports Prisma, this
// mock throws while the test module is loading. Manager mode must not reach it.
vi.mock("@/lib/db/prisma", () => {
throw new Error("Prisma must not be imported by the Manager proxy path.");
});
vi.mock("@/lib/installation/installation-state", () => ({ isInstalledForRequest }));
vi.mock("next-auth", () => ({ default: nextAuth }));
import proxy, { handleProxyRequest } from "@/proxy";
function request(pathname: string) {
return new NextRequest(`https://manager.example.com${pathname}`);
}
describe("proxy deployment separation", () => {
const originalManagerMode = process.env.MANAGER_MODE;
afterEach(() => {
if (originalManagerMode === undefined) delete process.env.MANAGER_MODE;
else process.env.MANAGER_MODE = originalManagerMode;
isInstalledForRequest.mockReset();
nextAuth.mockClear();
handleProxyRequestForTest.mockClear();
});
it("routes the Manager root before loading or checking business installation state", async () => {
process.env.MANAGER_MODE = "true";
const response = await proxy(request("/"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("https://manager.example.com/manager");
expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
expect(isInstalledForRequest).not.toHaveBeenCalled();
expect(nextAuth).not.toHaveBeenCalled();
});
it("serves Manager routes without business installation state", async () => {
process.env.MANAGER_MODE = "true";
const response = await proxy(request("/manager"));
expect(response.status).toBe(200);
expect(isInstalledForRequest).not.toHaveBeenCalled();
});
it("checks installation state for a normal business request", async () => {
process.env.MANAGER_MODE = "false";
isInstalledForRequest.mockResolvedValue(false);
const response = await handleProxyRequest(request("/"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("https://manager.example.com/install");
expect(isInstalledForRequest).toHaveBeenCalledOnce();
});
it("uses Auth.js for normal business requests", async () => {
process.env.MANAGER_MODE = "false";
await proxy(request("/"));
expect(nextAuth).toHaveBeenCalledOnce();
expect(handleProxyRequestForTest).toHaveBeenCalledOnce();
});
it("preserves protected business route redirects after installation", async () => {
process.env.MANAGER_MODE = "false";
isInstalledForRequest.mockResolvedValue(true);
const response = await handleProxyRequest(request("/owner"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/sign-in?callbackUrl=%2Fowner");
});
});
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("next/navigation", () => ({
redirect: (location: string) => {
throw new Error(`redirect:${location}`);
},
}));
vi.mock("@/lib/db/prisma", () => {
throw new Error("Business Prisma must not load while importing the Manager root.");
});
vi.mock("@/features/welcome/pages/business-welcome-page", () => {
throw new Error("Business welcome page must not load while importing the Manager root.");
});
import RootPage from "@/app/page";
describe("root deployment entry point", () => {
const originalManagerMode = process.env.MANAGER_MODE;
afterEach(() => {
if (originalManagerMode === undefined) delete process.env.MANAGER_MODE;
else process.env.MANAGER_MODE = originalManagerMode;
});
it("redirects Manager root without evaluating business page or Prisma", async () => {
process.env.MANAGER_MODE = "true";
await expect(RootPage()).rejects.toThrow("redirect:/manager");
});
});
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import Link from "next/link";
import { prisma } from "@/lib/db/prisma";
import { validateInvitationToken } from "@/services/staff/staff-invitation.service";
import { AcceptInvitationForm } from "@/features/auth/components/accept-invitation-form";
export const metadata: Metadata = { title: "Accept Invitation" };
export const dynamic = "force-dynamic";
export default async function AcceptInvitationPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const { token } = await searchParams;
if (!token) {
return <InvalidInvitation message="This invitation link is missing its token." />;
}
const check = await validateInvitationToken(prisma, token);
if (!check.valid) {
const message =
check.reason === "EXPIRED"
? "This invitation link has expired. Ask the business owner to send you a new one."
: check.reason === "ALREADY_ACCEPTED"
? "This invitation has already been used."
: "This invitation link isn't valid.";
return <InvalidInvitation message={message} />;
}
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Welcome, {check.invitation.staff.user.name}</h1>
<p className="text-muted mb-10">Finish setting up your staff account.</p>
<AcceptInvitationForm token={token} businessName={check.invitation.staff.business.name} />
</div>
);
}
function InvalidInvitation({ message }: { message: string }) {
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Invitation Not Available</h1>
<p className="text-muted mb-10">{message}</p>
<Link href="/sign-in" className="border-ink text-ink border-b font-bold text-sm">
Go to Sign In
</Link>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { getBranding } from "@/lib/business/branding";
import { AuthHeader } from "@/features/auth/components/auth-header";
/**
* Every auth page (sign-in, register, forgot-password, verify) shares
* this shell, so the business logo is shown once here rather than on
* each page — see the brief's "Authentication Branding" requirement.
* Unreachable pre-install: src/proxy.ts redirects auth routes to
* /install until a business exists, so `getBranding()` never falls back
* to neutral wording here in practice.
*
* Forced dynamic: without this, a production build can statically
* prerender /sign-in and /register with whatever branding (or the
* neutral fallback) happened to be in the database at *build* time,
* baked into static HTML served forever after — a real business's name
* would never actually reach these pages in that deployment. See
* docs/phase-8-summary.md, "Known limitations" for how this was found
* (a Docker build with no live database at build time).
*/
export const dynamic = "force-dynamic";
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
const branding = await getBranding();
return (
<div className="flex min-h-screen flex-col bg-stone p-6">
<div id="main-content" className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center">
<AuthHeader logoUrl={branding.logoUrl} />
{children}
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { RegisterForm } from "@/features/auth/components/register-form";
import { getBranding } from "@/lib/business/branding";
export const metadata: Metadata = {
title: "Create Account",
};
export default async function RegisterPage() {
const branding = await getBranding();
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Join {branding.name}</h1>
<p className="mb-5 text-sm text-muted">Create an account to track your appointments &amp; loyalty.</p>
{/* useSearchParams in RegisterForm requires a Suspense boundary. */}
<Suspense>
<RegisterForm />
</Suspense>
</div>
);
}
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { SignInForm } from "@/features/auth/components/sign-in-form";
import { getBranding } from "@/lib/business/branding";
export const metadata: Metadata = {
title: "Sign In",
};
export default async function SignInPage() {
const branding = await getBranding();
return (
<div>
<h1 className="mb-2 text-2xl font-bold tracking-tight">Welcome back to {branding.name}</h1>
<p className="mb-12 text-sm leading-relaxed text-muted">
Please enter your credentials to access your appointments.
</p>
{/* useSearchParams in SignInForm requires a Suspense boundary. */}
<Suspense>
<SignInForm />
</Suspense>
</div>
);
}
@@ -0,0 +1,190 @@
import type { Metadata } from "next";
import { CalendarPlus, Phone } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import {
assertCanAccessAppointment,
PermissionDeniedError,
} from "@/features/booking/authorization";
import { BookAgainButton } from "@/features/booking/components/book-again-button";
import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment Details" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
const RESCHEDULABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
const CANCELLABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
const REBOOKABLE_STATUSES = new Set(["COMPLETED", "CANCELLED", "NO_SHOW"]);
export default async function AppointmentDetailsPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ token?: string }>;
}) {
const { id } = await params;
const { token } = await searchParams;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
const session = await auth();
try {
await assertCanAccessAppointment(prisma, appointment, { sessionUserId: session?.user?.id, guestToken: token });
} catch (error) {
if (error instanceof PermissionDeniedError) notFound(); // don't confirm the id exists to someone who can't see it
throw error;
}
const business = await getPrimaryBusiness();
const totalDuration = appointment.services.reduce(
(sum, s) => sum + s.durationAtBookingMinutes,
0,
);
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
let cancelledByLabel: string | null = null;
if (appointment.status === "CANCELLED" && appointment.cancelledByUserId) {
const canceller = await prisma.user.findUnique({
where: { id: appointment.cancelledByUserId },
select: { name: true },
});
cancelledByLabel = canceller?.name ?? null;
}
return (
<div className="pb-12">
<TopNav title="Appointment Details" onBack={true} sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Ref: {appointment.id.slice(-8)}
</span>
</div>
<div className="border-line space-y-1 border-t pt-4">
<p className="text-lg font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")}
</p>
<p className="text-muted text-sm">
with {appointment.staff.user.name} · {business.name}
</p>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Date
</span>
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: business.timezone,
})}
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Time
</span>
{appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: business.timezone,
})}
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration
</span>
{totalDuration} min
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Total
</span>
£{totalPrice.toFixed(2)}
</div>
</div>
{appointment.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Your Notes
</span>
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
</div>
)}
{appointment.status === "CANCELLED" && (
<div className="border-line border-t pt-4 text-sm">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Cancellation
</span>
<p className="text-muted mt-1">
Cancelled{" "}
{appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: business.timezone })}
{cancelledByLabel ? ` by ${cancelledByLabel}` : ""}
</p>
{appointment.cancellationReason && (
<p className="text-muted mt-1 italic">
&ldquo;{appointment.cancellationReason}&rdquo;
</p>
)}
</div>
)}
</Card>
<div className="flex flex-col gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics${token ? `?token=${token}` : ""}`}
className="rounded-md border-line flex w-full items-center justify-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{business.phone && (
<a
href={`tel:${business.phone}`}
className="rounded-md border-line flex w-full items-center justify-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Contact Business
</a>
)}
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
<Link
href={`/appointments/${appointment.id}/reschedule${token ? `?token=${token}` : ""}`}
className="rounded-md border-line block w-full border bg-white px-5 py-3 text-center text-sm font-bold"
>
Reschedule
</Link>
)}
{CANCELLABLE_STATUSES.has(appointment.status) && (
<CancelAppointmentDialog appointmentId={appointment.id} guestToken={token} className="w-full" />
)}
{REBOOKABLE_STATUSES.has(appointment.status) && (
<BookAgainButton appointmentId={appointment.id} guestToken={token} className="w-full" />
)}
</div>
</main>
</div>
);
}
@@ -0,0 +1,183 @@
import type { Metadata } from "next";
import { CalendarPlus, LogIn } from "lucide-react";
import Link from "next/link";
import { auth } from "@/auth";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { BookAgainButton } from "@/features/booking/components/book-again-button";
import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointments" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
const CANCELLED_STATUSES = ["CANCELLED", "NO_SHOW"] as const;
const RESCHEDULABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
const CANCELLABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
export default async function AppointmentsPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="pb-12">
<TopNav title="Appointments" sticky className="bg-stone" />
<div className="mx-auto max-w-2xl px-6 pt-6">
<EmptyState
icon={LogIn}
title="Sign in to see your appointments"
description="Create an account or sign in to view, reschedule, and cancel your bookings."
action={
<Button asChild>
<Link href="/sign-in?callbackUrl=/appointments">Sign In</Link>
</Button>
}
/>
</div>
</div>
);
}
const [business, unreadNotificationCount] = await Promise.all([
getPrimaryBusiness(),
getUnreadNotificationCount(prisma, session.user.id),
]);
const customerProfile = await prisma.customerProfile.findUnique({
where: { userId: session.user.id },
});
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
if (!customerProfile) {
return (
<div className="pb-12">
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
<div className="mx-auto max-w-2xl px-6 pt-6">
<EmptyState
icon={CalendarPlus}
title="No appointments yet"
description="Book your first appointment to see it here."
action={
<Button asChild>
<Link href="/book">Book Now</Link>
</Button>
}
/>
</div>
</div>
);
}
const [upcoming, past, cancelled] = await Promise.all([
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...UPCOMING_STATUSES] }),
listAppointments(prisma, { customerId: customerProfile.id, statuses: ["COMPLETED"] }),
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...CANCELLED_STATUSES] }),
]);
const renderList = (appointments: typeof upcoming, kind: "upcoming" | "past" | "cancelled") => {
if (appointments.length === 0) {
return (
<EmptyState
icon={CalendarPlus}
title={
kind === "upcoming"
? "No upcoming appointments"
: kind === "past"
? "No past appointments"
: "No cancelled appointments"
}
description={kind === "upcoming" ? "Book your next visit in a few taps." : undefined}
action={
kind === "upcoming" ? (
<Button asChild>
<Link href="/book">Book Now</Link>
</Button>
) : undefined
}
/>
);
}
return (
<div className="space-y-4">
{appointments.map((appointment) => (
<AppointmentCard
key={appointment.id}
status={STATUS_MAP[appointment.status] ?? "confirmed"}
serviceName={appointment.services.map((s) => s.service.name).join(" + ")}
person={{ id: appointment.staffId, name: appointment.staff.user.name ?? "Barber" }}
formattedDate={appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: business.timezone,
})}
formattedTime={appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: business.timezone,
})}
actions={
<div className="flex flex-1 flex-wrap gap-2">
<Link
href={`/appointments/${appointment.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
{kind === "upcoming" && RESCHEDULABLE_STATUSES.has(appointment.status) && (
<Link
href={`/appointments/${appointment.id}/reschedule`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
Reschedule
</Link>
)}
{kind === "upcoming" && CANCELLABLE_STATUSES.has(appointment.status) && (
<CancelAppointmentDialog appointmentId={appointment.id} />
)}
{kind !== "upcoming" && <BookAgainButton appointmentId={appointment.id} />}
</div>
}
/>
))}
</div>
);
};
return (
<div className="pb-12">
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
<div className="mx-auto max-w-2xl px-6 pt-6">
<Tabs defaultValue="upcoming">
<TabsList>
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
<TabsTrigger value="past">Past</TabsTrigger>
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">{renderList(upcoming, "upcoming")}</TabsContent>
<TabsContent value="past">{renderList(past, "past")}</TabsContent>
<TabsContent value="cancelled">{renderList(cancelled, "cancelled")}</TabsContent>
</Tabs>
</div>
</div>
);
}
@@ -0,0 +1,8 @@
"use client";
import { ErrorState } from "@/components/feedback/error-state";
/** Keeps (customer)/layout.tsx's bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
export default function CustomerError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <ErrorState onRetry={reset} />;
}
@@ -0,0 +1,296 @@
import type { Metadata } from "next";
import { Star } from "lucide-react";
import Link from "next/link";
import { auth } from "@/auth";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ImageGallery } from "@/components/ui/image-gallery";
import { GalleryPlacement, Weekday } from "@/generated/prisma/enums";
import type { Prisma } from "@/generated/prisma/client";
import { computeOpeningStatus } from "@/lib/business/opening-status";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { getAvailableServices } from "@/services/scheduling/availability.service";
import { listGalleryImages } from "@/services/gallery/gallery.service";
import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getBusinessReviews } from "@/services/reviews/reviews.service";
import { HomeTabs } from "@/features/home/components/home-tabs";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Home" };
export const dynamic = "force-dynamic";
const WEEKDAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
const WEEKDAY_LABEL: Record<Weekday, string> = {
MONDAY: "Monday",
TUESDAY: "Tuesday",
WEDNESDAY: "Wednesday",
THURSDAY: "Thursday",
FRIDAY: "Friday",
SATURDAY: "Saturday",
SUNDAY: "Sunday",
};
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
};
export default async function CustomerHomePage() {
const [business, session] = await Promise.all([getPrimaryBusiness(), auth()]);
const [services, categories, staff, reviews, heroImages, portfolioImages] = await Promise.all([
getAvailableServices(prisma, { businessId: business.id }),
prisma.serviceCategory.findMany({ where: { businessId: business.id, isActive: true }, orderBy: { displayOrder: "asc" } }),
prisma.staffProfile.findMany({
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
include: { user: { select: { name: true, image: true } } },
}),
getBusinessReviews(business.id),
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.HERO, activeOnly: true }),
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.PORTFOLIO, activeOnly: true }),
]);
const openingStatus = computeOpeningStatus(business.hours, business.timezone);
const location = business.locations[0];
// Hero: owner-managed gallery first; falls back to a single cover/logo
// image (still fully functional with zero gallery rows configured —
// see docs/phase-9-summary.md, "Home Screen Redesign").
const heroGalleryImages =
heroImages.length > 0
? heroImages.map((i) => ({ id: i.id, url: i.imageUrl, caption: i.caption }))
: business.coverImageUrl || business.logoUrl
? [{ id: "fallback", url: (business.coverImageUrl ?? business.logoUrl)!, caption: null }]
: [];
const hoursRows = WEEKDAY_ORDER.map((day) => {
const dayHours = business.hours.find((h) => h.dayOfWeek === day);
const hoursLabel =
!dayHours || dayHours.isClosed || dayHours.opensAtMinutes === null || dayHours.closesAtMinutes === null
? "Closed"
: `${String(Math.floor(dayHours.opensAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.opensAtMinutes % 60).padStart(2, "0")} – ${String(Math.floor(dayHours.closesAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.closesAtMinutes % 60).padStart(2, "0")}`;
return { label: WEEKDAY_LABEL[day], hoursLabel };
});
// Keyless Google Maps embed — no API key/SDK needed for a simple pinned
// map, unlike the JS Maps SDK. Prefers exact coordinates; falls back to
// the street address when they're not set.
const mapQuery =
location?.latitude && location?.longitude
? `${location.latitude},${location.longitude}`
: location
? `${location.addressLine1}, ${location.city} ${location.postalCode}`
: null;
const mapEmbedSrc = mapQuery ? `https://www.google.com/maps?q=${encodeURIComponent(mapQuery)}&output=embed` : null;
type UpcomingAppointment = Prisma.AppointmentGetPayload<{
include: { services: { include: { service: true } }; staff: { include: { user: true } } };
}>;
let customerProfile: Awaited<ReturnType<typeof prisma.customerProfile.findUnique>> = null;
let upcomingAppointment: UpcomingAppointment | null = null;
let loyalty: LoyaltySummary | null = null;
let preferredBarberName: string | null = null;
let lastCompletedAppointment: Awaited<ReturnType<typeof prisma.appointment.findFirst>> = null;
if (session?.user?.id) {
customerProfile = await prisma.customerProfile.findUnique({
where: { userId: session.user.id },
});
if (customerProfile) {
[upcomingAppointment, loyalty, lastCompletedAppointment] = await Promise.all([
prisma.appointment.findFirst({
where: {
customerId: customerProfile.id,
status: { in: ["PENDING", "CONFIRMED", "CHECKED_IN"] },
startAt: { gte: new Date() },
},
orderBy: { startAt: "asc" },
include: { services: { include: { service: true } }, staff: { include: { user: true } } },
}),
getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id }),
prisma.appointment.findFirst({
where: { customerId: customerProfile.id, status: "COMPLETED" },
orderBy: { startAt: "desc" },
}),
]);
if (customerProfile.preferredBarberId) {
const preferred = await prisma.staffProfile.findUnique({
where: { id: customerProfile.preferredBarberId },
include: { user: { select: { name: true } } },
});
preferredBarberName = preferred?.user.name ?? null;
}
}
}
return (
<div>
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
{heroGalleryImages.length > 0 && (
<div className="mx-auto max-w-2xl pb-1 bg-white border-line border-b">
<ImageGallery images={heroGalleryImages} />
</div>
)}
<main className="mx-auto max-w-2xl bg-white px-6 pt-3 pb-6">
{/* {session?.user && (
<p className="text-muted mb-1 text-sm">
Good to see you, {session.user.name?.split(" ")[0] ?? "there"}
</p>
)} */}
<h1 className="mb-1 text-2xl font-bold tracking-tight">{business.name}</h1>
{location && (
<p className="text-muted mb-3 text-xs">
{location.addressLine1}, {location.city} {location.postalCode}
</p>
)}
<div className="mb-6 flex items-center gap-3 text-sm">
<div className="text-accent-loyalty flex items-center gap-1">
<Star className="h-4 w-4 fill-current" aria-hidden="true" />
<span className="text-ink font-bold">{reviews.averageRating.toFixed(1)}</span>
</div>
<span className="text-muted">· {reviews.totalReviews.toLocaleString()} reviews</span>
<span
className={openingStatus.isOpen ? "text-accent-foreground font-bold" : "text-danger font-bold"}
>
{openingStatus.label}
</span>
</div>
{/* Upcoming appointment */}
{upcomingAppointment && (
<section className="mb-6">
{session?.user && (
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
{session.user.name?.split(" ")[0] ?? "Your Next Appointment"}, Your Next Appointment
</h2>
)}
<AppointmentCard
status={STATUS_MAP[upcomingAppointment.status] ?? "confirmed"}
serviceName={upcomingAppointment.services.map((s) => s.service.name).join(" + ")}
person={{
id: upcomingAppointment.staffId,
name: upcomingAppointment.staff.user.name ?? "Barber",
}}
formattedDate={upcomingAppointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: business.timezone,
})}
formattedTime={upcomingAppointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: business.timezone,
})}
actions={
<Link
href="/appointments"
className="text-ink text-xs font-bold tracking-widest uppercase underline"
>
View details
</Link>
}
/>
</section>
)}
{/* Loyalty summary */}
{loyalty?.isEnabled && loyalty.accountId && (
<Link
href="/loyalty"
className={
loyalty.availableReward
? "rounded-md border-accent-loyalty bg-accent-loyalty/10 mb-6 block border p-5 shadow-sm"
: "rounded-md border-line mb-6 block border bg-white p-5 shadow-sm"
}
>
{loyalty.availableReward ? (
<div className="flex items-center justify-between">
<p className="text-sm font-bold">Reward available — {loyalty.availableReward.label}</p>
<span className="text-accent-loyalty-foreground text-xs font-bold uppercase tracking-widest">View</span>
</div>
) : (
<>
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-bold">Loyalty Progress</p>
<span className="text-muted text-xs">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
</span>
</div>
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
<div
className="bg-accent-loyalty h-full rounded-full"
style={{
width: `${Math.min(100, (loyalty.currentStamps / resolveRewardThreshold(loyalty)) * 100)}%`,
}}
/>
</div>
</>
)}
</Link>
)}
{/* Preferred barber + book again */}
{(preferredBarberName || lastCompletedAppointment) && (
<div className="mb-6 grid grid-cols-2 gap-3">
{preferredBarberName && (
<Card className="p-4">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
Preferred Barber
</p>
<p className="mt-1 text-sm font-bold">{preferredBarberName}</p>
</Card>
)}
{/* {lastCompletedAppointment && (
<Button asChild variant="secondary" className="h-full w-full">
<Link href="/appointments">Book Again</Link>
</Button>
)} */}
</div>
)}
{/* Booking CTA */}
<Button asChild className="mb-8 w-full">
<Link href="/book">Book an Appointment</Link>
</Button>
<HomeTabs
services={services}
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
portfolioImages={portfolioImages}
description={business.description}
phone={business.phone}
email={business.email}
website={business.website}
instagramUrl={business.instagramUrl}
facebookUrl={business.facebookUrl}
hoursRows={hoursRows}
staff={staff.map((s) => ({
id: s.id,
name: s.user.name ?? "Barber",
avatarUrl: s.user.image ?? undefined,
isPreferred: customerProfile?.preferredBarberId === s.id,
}))}
mapEmbedSrc={mapEmbedSrc}
/>
</main>
</div>
);
}
@@ -0,0 +1,36 @@
import { auth } from "@/auth";
import { BottomNav, type BottomNavItem } from "@/components/navigation/bottom-nav";
import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding";
import { NotificationBell } from "@/features/notifications/components/notification-bell";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
const NAV_ITEMS: BottomNavItem[] = [
{ href: "/home", label: "Home", icon: "house" },
{ href: "/appointments", label: "Bookings", icon: "calendar-check" },
{ href: "/loyalty", label: "Loyalty", icon: "gift" },
{ href: "/settings", label: "Profile", icon: "user" },
];
/**
* No auth gate here — Home must work for guests too (the Phase 4 brief:
* "For guests, show the public business experience without personalised
* content"). /appointments and /settings each check their own session
* requirement and show an in-context sign-in prompt rather than bouncing
* a guest away with no explanation — see those pages. The bell itself is
* only rendered for a signed-in session — a guest has no recipientUserId,
* so there's nothing in the in-app centre for them (see
* notification.service.ts's IN_APP channel resolution).
*/
export default async function CustomerLayout({ children }: { children: React.ReactNode }) {
const [session, branding] = await Promise.all([auth(), getBranding()]);
const unreadNotificationCount = session?.user ? await getUnreadNotificationCount(prisma, session.user.id) : 0;
return (
<div className="min-h-screen bg-stone pb-24">
{session?.user && <NotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
<div id="main-content">{children}</div>
<BottomNav items={NAV_ITEMS} variant="customer" showLabels={branding.bottomNavShowLabels} />
</div>
);
}
@@ -0,0 +1,155 @@
import type { Metadata } from "next";
import { Gift, LogIn } from "lucide-react";
import Link from "next/link";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { EmptyState } from "@/components/feedback/empty-state";
import { LoyaltyCard } from "@/components/ui/loyalty-card";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
export const metadata: Metadata = { title: "Loyalty" };
export const dynamic = "force-dynamic";
export default async function LoyaltyPage() {
const [business, session] = await Promise.all([getPrimaryBusiness(), auth()]);
if (!session?.user) {
return (
<div className="pb-12">
<TopNav title="Loyalty" sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<EmptyState
icon={LogIn}
title="Sign in to see your loyalty card"
description="Track your stamps and rewards across visits."
action={
<Button asChild>
<Link href="/sign-in?callbackUrl=/loyalty">Sign In</Link>
</Button>
}
/>
</main>
</div>
);
}
const [customerProfile, unreadNotificationCount] = await Promise.all([
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
getUnreadNotificationCount(prisma, session.user.id),
]);
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
const loyalty = customerProfile
? await getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id })
: null;
if (!customerProfile || !loyalty?.isEnabled) {
return (
<div className="pb-12">
<TopNav title="Loyalty" sticky className="bg-stone" action={bell} />
<main className="mx-auto max-w-2xl px-6 py-6">
<EmptyState
icon={Gift}
title="Loyalty isn't available here yet"
description={`${business.name} hasn't turned on a loyalty programme.`}
/>
</main>
</div>
);
}
const history = loyalty.accountId
? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 20 })
: [];
const expiresLabel = loyalty.availableReward?.expiresAt
? loyalty.availableReward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
: null;
// The number of stamps that actually earns the reward — not always the
// same as the card's stampsRequired slot count, see resolveRewardThreshold.
const rewardThreshold = resolveRewardThreshold(loyalty);
return (
<div className="pb-12">
<TopNav title="Loyalty" sticky className="bg-stone" action={bell} />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<LoyaltyCard
businessName={business.name}
memberNumber={customerProfile.id.slice(-6).toUpperCase()}
stampsRequired={loyalty.stampsRequired}
stampsEarned={loyalty.currentStamps}
rewardLabel={loyalty.rewardLabel ?? "Reward"}
termsLine={loyalty.termsLine ?? undefined}
cardBackgroundColor={loyalty.cardBackgroundColor}
cardBackgroundImageUrl={loyalty.cardBackgroundImageUrl}
stampIcon={loyalty.stampIcon}
rewardPosition={loyalty.rewardPosition}
cardTitleText={loyalty.cardTitleText}
cardFont={loyalty.cardFont}
cardTitleTextSize={loyalty.cardTitleTextSize}
cardTitleTextColor={loyalty.cardTitleTextColor}
cardBorderColor={loyalty.cardBorderColor}
cardMemberNumberColor={loyalty.cardMemberNumberColor}
/>
{loyalty.availableReward ? (
<Card className="border-accent-loyalty bg-accent-loyalty/10 space-y-1 border p-5">
<p className="text-sm font-bold">Reward available: {loyalty.availableReward.label}</p>
<p className="text-muted text-xs">
Show this to your barber at your next visit to redeem it.
{expiresLabel ? ` Expires ${expiresLabel}.` : ""}
</p>
</Card>
) : (
<Card className="space-y-1 p-5">
<div className="flex items-center justify-between">
<p className="text-sm font-bold">Progress</p>
<span className="text-muted text-xs">
{loyalty.currentStamps}/{rewardThreshold} stamps
</span>
</div>
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
<div
className="bg-accent-loyalty h-full rounded-full"
style={{ width: `${Math.min(100, (loyalty.currentStamps / rewardThreshold) * 100)}%` }}
/>
</div>
</Card>
)}
<section>
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">History</h2>
{history.length === 0 ? (
<p className="text-muted text-sm">No loyalty activity yet.</p>
) : (
<Card className="divide-line divide-y px-5">
{history.map((t) => (
<div key={t.id} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-medium">{t.type.replace(/_/g, " ").toLowerCase()}</p>
<p className="text-muted text-xs">
{t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}
{t.reason ? ` · ${t.reason}` : ""}
</p>
</div>
<span className="font-bold">
{t.amount > 0 ? "+" : ""}
{t.amount}
</span>
</div>
))}
</Card>
)}
</section>
</main>
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import Link from "next/link";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { listInAppNotifications } from "@/services/notifications/notification.service";
import { NotificationList } from "@/features/notifications/components/notification-list";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
export default async function CustomerNotificationsPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="mx-auto max-w-2xl px-6 pt-12">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Notifications</h1>
<p className="text-muted mb-4 text-sm">Sign in to see your notifications.</p>
<Button asChild>
<Link href="/sign-in?callbackUrl=/notifications">Sign In</Link>
</Button>
</div>
);
}
const notifications = await listInAppNotifications(prisma, { userId: session.user.id });
return (
<div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<NotificationList notifications={notifications} />
</main>
</div>
);
}
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { getNotificationPreferences } from "@/services/notifications/notification.service";
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
import { saveCustomerNotificationPreferencesAction } from "@/features/notifications/actions/preferences";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
export default async function CustomerNotificationSettingsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/notifications");
const rows = await getNotificationPreferences(prisma, session.user.id);
const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({
channel,
isEnabled:
rows.find((r) => r.type === NotificationType.BOOKING_REMINDER && r.channel === channel)?.isEnabled ?? true,
}));
return (
<div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<NotificationPreferencesForm initial={cells} action={saveCustomerNotificationPreferencesAction} />
<div className="rounded-md border-line border bg-white p-5">
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
<PushSubscribeToggle />
</div>
</main>
</div>
);
}
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { Bell, ChevronRight, Gift, LogIn, User } from "lucide-react";
import Link from "next/link";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { ProfileCard } from "@/components/ui/profile-card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
export const metadata: Metadata = { title: "Settings" };
export const dynamic = "force-dynamic";
const SETTINGS_LINKS = [
{ href: "/settings/preferred-barber", label: "Preferred Barber", icon: User },
{ href: "/loyalty", label: "Loyalty Card", icon: Gift },
{ href: "/settings/notifications", label: "Notifications", icon: Bell },
] as const;
export default async function CustomerSettingsPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="pb-12">
<TopNav title="Settings" sticky className="bg-stone" />
<div className="mx-auto max-w-2xl px-6 pt-6">
<EmptyState
icon={LogIn}
title="Sign in to manage your account"
description="Save your details, track loyalty rewards, and manage your bookings."
action={
<Button asChild>
<Link href="/sign-in?callbackUrl=/settings">Sign In</Link>
</Button>
}
/>
</div>
</div>
);
}
const [customerProfile, user, branding, unreadNotificationCount] = await Promise.all([
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
getBranding(),
getUnreadNotificationCount(prisma, session.user.id),
]);
return (
<div className="pb-12">
<TopNav
title="Settings"
sticky
className="bg-stone"
action={<InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-6">
<ProfileCard
name={
customerProfile
? `${customerProfile.firstName} ${customerProfile.lastName}`
: (session.user.name ?? "Signed-in customer")
}
meta={customerProfile?.email ?? session.user.email}
avatarUrl={user?.image ?? undefined}
editHref="/settings/profile"
/>
<ul className="divide-line divide-y rounded-md border border-line bg-white">
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
<span className="flex-1">{label}</span>
<ChevronRight className="text-muted h-4 w-4" aria-hidden="true" />
</Link>
</li>
))}
</ul>
<div className="border-line rounded-md border bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton businessName={branding.name} />
</div>
<SignOutButton />
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { PreferredBarberPicker } from "@/features/customer/components/preferred-barber-picker";
export const metadata: Metadata = { title: "Preferred Barber" };
export const dynamic = "force-dynamic";
export default async function PreferredBarberPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/preferred-barber");
const [business, customerProfile] = await Promise.all([
getPrimaryBusiness(),
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
]);
const staff = await prisma.staffProfile.findMany({
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
include: { user: { select: { name: true, image: true } } },
});
return (
<div className="pb-12">
<TopNav title="Preferred Barber" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<p className="text-muted text-sm">Chosen for you by default when booking — you can still pick anyone else.</p>
<PreferredBarberPicker
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Barber", avatarUrl: s.user.image ?? undefined }))}
initialPreferredId={customerProfile?.preferredBarberId ?? null}
/>
</main>
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { CustomerProfileForm } from "@/features/customer/components/customer-profile-form";
export const metadata: Metadata = { title: "Edit Profile" };
export const dynamic = "force-dynamic";
export default async function CustomerEditProfilePage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/profile");
const [customerProfile, user] = await Promise.all([
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
]);
const [sessionFirstName, ...sessionLastNameParts] = (session.user.name ?? "").trim().split(" ");
return (
<div className="pb-12">
<TopNav title="Edit Profile" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<CustomerProfileForm
initial={{
firstName: customerProfile?.firstName ?? sessionFirstName ?? "",
lastName: customerProfile?.lastName ?? sessionLastNameParts.join(" "),
email: customerProfile?.email ?? session.user.email ?? "",
phone: customerProfile?.phone ?? "",
image: user?.image ?? "",
}}
/>
</main>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { ErrorState } from "@/components/feedback/error-state";
/** Keeps (owner)/layout.tsx's sidebar/bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
export default function OwnerError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <ErrorState onRetry={reset} />;
}
+41
View File
@@ -0,0 +1,41 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { StaffShell } from "@/components/navigation/staff-shell";
import { getNavRole, hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { getNavItems } from "@/lib/navigation/nav-config";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
/**
* Owner and Super Administrator are permissions, not separate account
* types (see docs/implementation-plan.md §3, §11) — this layout reuses the
* exact same shell/nav as (staff), and only adds a permission check on
* top. There is no "switch to owner tools" control anywhere in the app —
* a user who can manage staff simply sees "Manage" in their one merged
* nav (see lib/navigation/nav-config.ts).
*/
export default async function OwnerLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) {
redirect("/sign-in?callbackUrl=/owner");
}
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManageStaff) {
redirect("/staff");
}
const role = (await getNavRole(session.user.id)) ?? "owner";
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
return (
<StaffShell
items={getNavItems(role)}
notificationsHref="/owner/notifications"
unreadNotificationCount={unreadNotificationCount}
>
{children}
</StaffShell>
);
}
@@ -0,0 +1,270 @@
import type { Metadata } from "next";
import { CalendarPlus, Mail, Phone } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set";
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment Details" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
export default async function OwnerAppointmentDetailsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
// `id` is unused by assertAppointmentInBusiness (only checks businessId)
// — see src/features/staff/authorization.ts. context.userId stands in
// for it since a Super Administrator has no StaffProfile id to offer.
assertAppointmentInBusiness(appointment, { id: context.userId, businessId: context.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
prisma.customerNote.findMany({
where: { customerId: appointment.customerId },
orderBy: { createdAt: "desc" },
take: 5,
include: { author: { include: { user: { select: { name: true } } } } },
}),
prisma.loyaltyTransaction.findMany({
where: { appointmentId: appointment.id },
orderBy: { createdAt: "desc" },
}),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: context.businessId }),
appointment.cancelledByUserId
? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } })
: null,
]);
const tz = context.businessTimezone;
const totalDuration = appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
const total = subtotal - discount;
const canRedeemReward =
loyalty?.availableReward &&
appointment.discountAmount === null &&
appointment.status !== "CANCELLED";
return (
<div className="pb-12">
<TopNav title="Appointment Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Ref: {appointment.id.slice(-8)}
</span>
</div>
<div className="border-line space-y-1 border-t pt-4">
<Link href={`/owner/team/${appointment.staffId}`} className="text-muted text-xs font-bold tracking-widest uppercase underline">
{appointment.staff.user.name ?? "Staff"}
</Link>
<Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline">
{appointment.customer.firstName} {appointment.customer.lastName}
</Link>
<p className="text-muted text-sm">
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<div className="flex flex-wrap gap-3">
{appointment.customer.phone && (
<a
href={`tel:${appointment.customer.phone}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${appointment.customer.email}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Services</span>
<p className="font-bold">{appointment.services.map((s) => s.service.name).join(" + ")}</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Date</span>
<p className="font-bold">
{appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: tz })}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Time</span>
<p className="font-bold">
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })} –{" "}
{appointment.endAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Duration</span>
<p className="font-bold">{totalDuration} min</p>
</div>
</div>
<div className="border-line border-t pt-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted">Subtotal</span>
<span className="font-bold">£{subtotal.toFixed(2)}</span>
</div>
{discount > 0 && (
<div className="text-accent-loyalty flex items-center justify-between">
<span>Loyalty reward</span>
<span className="font-bold">-£{discount.toFixed(2)}</span>
</div>
)}
<div className="border-line mt-1 flex items-center justify-between border-t pt-1">
<span className="font-bold">Total</span>
<span className="font-bold">£{total.toFixed(2)}</span>
</div>
</div>
{appointment.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Booking Notes</span>
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Customer Notes</span>
<div className="mt-2 space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Loyalty</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && <span className="text-accent-loyalty"> · Reward available</span>}
</p>
{loyaltyTransactions.length > 0 && (
<ul className="mt-2 space-y-1">
{loyaltyTransactions.map((t) => (
<li key={t.id} className="text-muted text-xs">
{t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
</li>
))}
</ul>
)}
</div>
)}
{appointment.status === "CANCELLED" && (
<div className="border-line border-t pt-4 text-sm">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Cancellation</span>
<p className="text-muted mt-1">
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
</p>
{appointment.cancellationReason && (
<p className="text-muted mt-1 italic">&ldquo;{appointment.cancellationReason}&rdquo;</p>
)}
</div>
)}
</Card>
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/owner/appointments/${appointment.id}/reschedule`}
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule / Reassign
</Link>
)}
{appointment.status === "COMPLETED" && <BookFollowUpButton appointmentId={appointment.id} />}
</div>
<AppointmentStatusActions
appointmentId={appointment.id}
status={appointment.status}
actions={ownerAppointmentActions}
/>
<div className="flex flex-wrap gap-3">
{loyalty?.isEnabled && <AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />}
{canRedeemReward && loyalty.availableReward && (
<RedeemRewardButton
appointmentId={appointment.id}
rewardId={loyalty.availableReward.id}
rewardLabel={loyalty.availableReward.label}
subtotal={subtotal}
/>
)}
</div>
</main>
</div>
);
}
@@ -0,0 +1,291 @@
import type { Metadata } from "next";
import { CalendarX2, Plus } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
todayIn,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { ViewSwitch } from "@/features/staff/components/view-switch";
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
import { StaffFilterPills } from "@/features/owner/components/staff-filter-pills";
import type { StaffFilterOption } from "@/features/owner/components/staff-filter-pills";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "All-Staff Calendar" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
function formatRange(startMinutes: number, endMinutes: number): string {
const fmt = (m: number) => {
const h = Math.floor(m / 60);
const min = m % 60;
const period = h >= 12 ? "PM" : "AM";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(min).padStart(2, "0")} ${period}`;
};
return `${fmt(startMinutes)} – ${fmt(endMinutes)}`;
}
export default async function OwnerCalendarPage({
searchParams,
}: {
searchParams: Promise<{ date?: string; view?: string; staffId?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/owner/calendar");
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
const today = todayIn(context.businessTimezone);
const date: CalendarDate = dateParam ?? today;
const view = viewParam === "agenda" ? "agenda" : "day";
const weekday = weekdayOf(date);
const dateUtcMidnight = calendarDateToUtcMidnight(date);
const businessTimezone = context.businessTimezone;
const allStaff = await prisma.staffProfile.findMany({
where: { businessId: context.businessId, status: "ACTIVE" },
include: { user: { select: { id: true, name: true, image: true } } },
orderBy: { createdAt: "asc" },
});
const selectedStaff = staffIdParam ? allStaff.find((s) => s.id === staffIdParam) : undefined;
const [appointments, workingHoursAll, breakDaysAll, timeOffAll] = await Promise.all([
listAppointments(prisma, {
businessId: context.businessId,
staffId: selectedStaff?.id,
fromDate: date,
toDate: date,
businessTimezone,
}),
prisma.workingHours.findMany({
where: { staff: { businessId: context.businessId }, dayOfWeek: weekday },
}),
prisma.breakDay.findMany({
where: { dayOfWeek: weekday, break: { staff: { businessId: context.businessId } } },
include: { break: true },
}),
prisma.timeOff.findMany({
where: {
staff: { businessId: context.businessId },
status: "APPROVED",
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
},
}),
]);
const rewardFlags = await getAvailableRewardFlags(prisma, {
businessId: context.businessId,
customerIds: [...new Set(appointments.map((a) => a.customerId))],
});
const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), businessTimezone) : null;
const staffToRender = selectedStaff ? [selectedStaff] : allStaff;
function buildColumnFor(staffId: string) {
const workingHours = workingHoursAll.find((h) => h.staffId === staffId);
const workingWindow =
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
: null;
const blocks: CalendarBlock[] = [
...breakDaysAll
.filter((bd) => bd.break.staffId === staffId)
.map((bd) => ({
id: `break-${bd.id}`,
startMinutes: bd.break.startMinutes,
endMinutes: bd.break.endMinutes,
label: bd.break.label,
kind: "break" as const,
})),
...timeOffAll
.filter((t) => t.staffId === staffId && t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({
id: `timeoff-${t.id}`,
startMinutes: t.startMinutes!,
endMinutes: t.endMinutes!,
label: t.reason ?? "Blocked",
kind: "timeOff" as const,
})),
];
const wholeDayOff = timeOffAll.some((t) => t.staffId === staffId && t.startMinutes === null);
const staffAppointments: CalendarAppointment[] = appointments
.filter((a) => a.staffId === staffId)
.map((a) => ({
id: a.id,
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
customerName: `${a.customer.firstName} ${a.customer.lastName}`,
serviceNames: a.services.map((s) => s.service.name).join(" + "),
status: STATUS_MAP[a.status] ?? "confirmed",
hasNotes: !!a.notes,
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
}));
return { workingWindow, blocks, wholeDayOff, staffAppointments };
}
const columns: CalendarColumn[] = staffToRender.map((s) => {
const column = buildColumnFor(s.id);
return {
id: s.id,
name: s.user.name ?? "Staff",
avatarUrl: s.user.image,
hoursLabel: column.workingWindow ? formatRange(column.workingWindow.startMinutes, column.workingWindow.endMinutes) : null,
wholeDayOff: column.wholeDayOff,
appointments: column.staffAppointments,
blocks: column.blocks,
workingWindow: column.workingWindow,
};
});
// Every active staff member gets a filter card (not just staffToRender,
// which narrows to the current selection) — the filter row needs to show
// everyone to filter *between*.
const staffFilterOptions: StaffFilterOption[] = allStaff.map((s) => {
const workingHours = workingHoursAll.find((h) => h.staffId === s.id);
const hoursLabel =
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
? formatRange(workingHours.startMinutes, workingHours.endMinutes)
: null;
return { id: s.id, name: s.user.name ?? "Staff", avatarUrl: s.user.image, hoursLabel };
});
const staffFilterHrefFor = (staffId: string | null) =>
`/owner/calendar?date=${date}&view=${view}${staffId ? `&staffId=${staffId}` : ""}`;
return (
<div className="pb-6">
<TopNav
title="Calendar"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
action={
<div className="flex gap-2">
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
<Button asChild size="sm">
{/* /staff/book already lets whoever is signed in book with any eligible barber — the
owner is a staff member too, so this is the same "book for a customer" flow Phase 5
built, not a duplicate. See docs/phase-6-summary.md, "Owner appointment access". */}
<Link href="/staff/book">
<Plus className="h-4 w-4" aria-hidden="true" />
New
</Link>
</Button>
</div>
}
/>
<DateStrip
selectedDate={date}
businessTimezone={businessTimezone}
basePath="/owner/calendar"
view={view}
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
/>
<div className="mt-4 px-6">
<ViewSwitch
view={view}
dayHref={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
agendaHref={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
/>
</div>
<div className="mt-4">
<StaffFilterPills options={staffFilterOptions} selectedId={selectedStaff?.id} hrefFor={staffFilterHrefFor} />
</div>
<div className="mt-4">
{view === "day" ? (
staffToRender.length === 0 ? (
<div className="px-6">
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
</div>
) : (
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} appointmentHrefBase="/owner/appointments" />
)
) : appointments.length === 0 ? (
<div className="px-6">
<EmptyState icon={CalendarX2} title="No appointments" description="Nothing scheduled for this day." />
</div>
) : (
<div className="space-y-4 px-6 lg:mx-auto lg:max-w-3xl">
{[...appointments]
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime())
.map((a) => (
<div key={a.id}>
{!selectedStaff && (
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">
{a.staff.user.name}
</p>
)}
<AppointmentCard
status={STATUS_MAP[a.status] ?? "confirmed"}
serviceName={a.services.map((s) => s.service.name).join(" + ")}
person={{ id: a.customerId, name: `${a.customer.firstName} ${a.customer.lastName}` }}
personLabel="for"
formattedTime={formatRange(
minutesFromMidnight(a.startAt, businessTimezone),
minutesFromMidnight(a.endAt, businessTimezone),
)}
hasNotes={!!a.notes}
hasAvailableReward={rewardFlags.get(a.customerId) ?? false}
actions={
<Link
href={`/owner/appointments/${a.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
}
/>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listInAppNotifications } from "@/services/notifications/notification.service";
import { NotificationList } from "@/features/notifications/components/notification-list";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
/** Access to this route is already gated by (owner)/layout.tsx's MANAGE_STAFF check — this page only fetches this owner's own notifications, same as the staff/customer variants. */
export default async function OwnerNotificationsPage() {
const context = await getActingBusinessContext();
if (!context) redirect("/home");
const notifications = await listInAppNotifications(prisma, { userId: context.userId });
return (
<div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<NotificationList notifications={notifications} />
</main>
</div>
);
}
@@ -0,0 +1,256 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
AlertTriangle,
BarChart3,
Building2,
Calendar,
CalendarClock,
CalendarX2,
CheckCheck,
ClipboardList,
Crown,
Gift,
Images,
ScissorsSquare,
Smartphone,
Stamp,
Star,
TrendingUp,
UserX,
Users,
} from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { todayIn } from "@/lib/time/business-time";
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
export const metadata: Metadata = { title: "Business Dashboard" };
export const dynamic = "force-dynamic";
function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; label: string; value: string | number; hint?: string }) {
return (
<Card className="p-5">
<div className="text-muted mb-2 flex items-center gap-2">
<Icon className="h-4 w-4" aria-hidden="true" />
<span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>
</div>
<p className="text-2xl font-bold">{value}</p>
{hint && <p className="text-muted mt-1 text-xs">{hint}</p>}
</Card>
);
}
/** App Icon & Home Screen is Super Administrator only (see /owner/settings/app) — everything else here is any Owner. */
function getManageSections(isSuper: boolean) {
return [
{
title: "Business",
links: [
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
...(isSuper
? [{ href: "/owner/settings/app", label: "App Icon & Home Screen", icon: Smartphone }]
: []),
],
},
{
title: "Booking",
links: [
{ href: "/owner/services", label: "Services", icon: ScissorsSquare },
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
],
},
{
title: "Customers",
links: [{ href: "/owner/settings/reviews", label: "Reviews", icon: Star }],
},
{
title: "Team",
links: [{ href: "/owner/team", label: "Team Management", icon: Users }],
},
{
title: "Reports",
links: [{ href: "/owner/reports", label: "Reports", icon: BarChart3 }],
},
];
}
function ManageSection({ title, links }: ReturnType<typeof getManageSections>[number]) {
return (
<div>
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">{title}</p>
<ul className="divide-line rounded-md border-line divide-y border bg-white">
{links.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
{label}
</Link>
</li>
))}
</ul>
</div>
);
}
export default async function OwnerDashboardPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/owner");
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
if (!canView) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const today = todayIn(context.businessTimezone);
const [summary, unreadNotificationCount, isSuper] = await Promise.all([
getDashboardSummary(prisma, {
businessId: context.businessId,
businessTimezone: context.businessTimezone,
date: today,
}),
getUnreadNotificationCount(prisma, session.user.id),
isSuperAdmin(session.user.id),
]);
const manageSections = getManageSections(isSuper);
return (
<div className="pb-12">
<TopNav
title="Manage"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="space-y-6 px-6 pt-6">
<p className="text-muted text-sm">{context.businessName} — today&apos;s overview</p>
{summary.warnings.length > 0 && (
<section className="space-y-2">
{summary.warnings.map((w, i) => (
<div
key={`${w.code}-${i}`}
role="alert"
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-md border p-4 text-sm"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p>{w.message}</p>
</div>
))}
</section>
)}
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={Calendar} label="Today's appointments" value={summary.totals.total} />
<StatTile icon={CheckCheck} label="Completed" value={summary.totals.completed} />
<StatTile icon={CalendarX2} label="Cancelled" value={summary.totals.cancelled} />
<StatTile icon={UserX} label="No-shows" value={summary.totals.noShow} />
</section>
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={TrendingUp} label="In progress now" value={summary.totals.inProgress} />
<StatTile icon={Calendar} label="Still to come today" value={summary.totals.upcoming} />
<StatTile
icon={TrendingUp}
label="Estimated occupancy"
value={summary.occupancy ? `${summary.occupancy.percent}%` : "—"}
hint={summary.occupancy ? "Booked vs. working minutes today — an estimate, not a financial figure." : "No staff working today."}
/>
<StatTile icon={Gift} label="Customers with a reward" value={summary.customersWithAvailableRewards} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Users className="text-accent h-4 w-4" aria-hidden="true" /> Staff Today
</h2>
{summary.staffWorkingToday.length === 0 ? (
<p className="text-muted text-sm">No one is scheduled to work today.</p>
) : (
<ul className="space-y-2">
{summary.staffWorkingToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-accent text-xs font-bold tracking-widest uppercase">Working</span>
</li>
))}
</ul>
)}
{summary.staffOnTimeOffToday.length > 0 && (
<ul className="border-line mt-3 space-y-2 border-t pt-3">
{summary.staffOnTimeOffToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted text-xs font-bold tracking-widest uppercase">
{s.reason ?? "Time off"}
</span>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<TrendingUp className="text-accent h-4 w-4" aria-hidden="true" /> Popular Services
</h2>
<p className="text-muted mb-3 text-xs">Completed visits, last 30 days.</p>
{summary.popularServices.length === 0 ? (
<p className="text-muted text-sm">No completed visits in the last 30 days yet.</p>
) : (
<ul className="space-y-2">
{summary.popularServices.map((s) => (
<li key={s.serviceId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted">{s.count}</span>
</li>
))}
</ul>
)}
</Card>
</section>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Stamp className="text-accent h-4 w-4" aria-hidden="true" /> Recent Loyalty Activity
</h2>
{summary.recentLoyaltyActivity.length === 0 ? (
<p className="text-muted text-sm">No loyalty activity yet.</p>
) : (
<ul className="space-y-2">
{summary.recentLoyaltyActivity.map((t) => (
<li key={t.id} className="text-muted text-xs">
<span className="text-ink font-bold">
{t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName}
</span>{" "}
— {t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} ({t.type.replace(/_/g, " ").toLowerCase()})
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
</li>
))}
</ul>
)}
</Card>
<div className="space-y-5">
{manageSections.map((section) => (
<ManageSection key={section.title} {...section} />
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,201 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { addDays, endOfBusinessDay, startOfBusinessDay, todayIn } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import {
getAppointmentReport,
getCustomerReport,
getLoyaltyReport,
getNotificationReport,
getServiceReport,
getStaffReport,
} from "@/services/reporting/reports.service";
export const metadata: Metadata = { title: "Reports" };
export const dynamic = "force-dynamic";
const PRESETS = [
{ label: "7 days", days: 7 },
{ label: "30 days", days: 30 },
{ label: "90 days", days: 90 },
] as const;
function StatTile({ label, value, hint }: { label: string; value: string | number; hint?: string }) {
return (
<Card className="p-5">
<p className="text-muted mb-2 text-[10px] font-bold tracking-widest uppercase">{label}</p>
<p className="text-2xl font-bold">{value}</p>
{hint && <p className="text-muted mt-1 text-xs">{hint}</p>}
</Card>
);
}
export default async function OwnerReportsPage({
searchParams,
}: {
searchParams: Promise<{ from?: string; to?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/owner/reports");
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
if (!canView) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const tz = context.businessTimezone;
const today = todayIn(tz);
const { from: fromParam, to: toParam } = await searchParams;
const toDate: CalendarDate = toParam ?? today;
const fromDate: CalendarDate = fromParam ?? addDays(toDate, -29);
const range = { from: startOfBusinessDay(fromDate, tz), to: endOfBusinessDay(toDate, tz) };
const [appointmentReport, serviceReport, staffReport, customerReport, loyaltyReport, notificationReport] =
await Promise.all([
getAppointmentReport(prisma, { businessId: context.businessId, range }),
getServiceReport(prisma, { businessId: context.businessId, range }),
getStaffReport(prisma, { businessId: context.businessId, range }),
getCustomerReport(prisma, { businessId: context.businessId, range }),
getLoyaltyReport(prisma, { businessId: context.businessId, range }),
getNotificationReport(prisma, { businessId: context.businessId, range }),
]);
return (
<div className="space-y-6 px-6 pt-12 pb-12">
<div>
<h1 className="text-2xl font-bold tracking-tight">Reports</h1>
<p className="text-muted text-sm">
{fromDate} to {toDate}
</p>
</div>
<div className="flex flex-wrap gap-2">
{PRESETS.map((preset) => (
<Link
key={preset.days}
href={`/owner/reports?from=${addDays(today, -(preset.days - 1))}&to=${today}`}
className="border-line rounded-full border bg-white px-4 py-2 text-xs font-bold"
>
Last {preset.label}
</Link>
))}
</div>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Appointments</h2>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="Total" value={appointmentReport.totalAppointments} />
<StatTile label="Completed" value={appointmentReport.completed} hint={`${appointmentReport.completionRate}% of total`} />
<StatTile label="Cancelled" value={appointmentReport.cancelled} hint={`${appointmentReport.cancellationRate}% of total`} />
<StatTile label="No-shows" value={appointmentReport.noShow} hint={`${appointmentReport.noShowRate}% of total`} />
</div>
<div className="grid grid-cols-3 gap-3">
<StatTile label="Gross revenue" value={`£${appointmentReport.grossRevenue}`} />
<StatTile label="Discounts given" value={`£${appointmentReport.discountsGiven}`} />
<StatTile label="Net revenue" value={`£${appointmentReport.netRevenue}`} />
</div>
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Services</h2>
<Card className="divide-line divide-y px-5">
{serviceReport.length === 0 ? (
<p className="text-muted py-4 text-sm">No completed appointments in this range.</p>
) : (
serviceReport.map((s) => (
<div key={s.serviceId} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-bold">{s.name}</p>
<p className="text-muted text-xs">{s.timesCompleted} completed</p>
</div>
<span className="font-bold">£{s.revenue}</span>
</div>
))
)}
</Card>
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Staff</h2>
<Card className="divide-line divide-y px-5">
{staffReport.length === 0 ? (
<p className="text-muted py-4 text-sm">No appointments in this range.</p>
) : (
staffReport.map((s) => (
<div key={s.staffId} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-bold">{s.name}</p>
<p className="text-muted text-xs">
{s.completed} completed · {s.cancelled} cancelled · {s.noShow} no-show
</p>
</div>
<span className="font-bold">£{s.revenue}</span>
</div>
))
)}
</Card>
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Customers</h2>
<div className="grid grid-cols-2 gap-3">
<StatTile label="New customers" value={customerReport.newCustomers} />
<StatTile label="Returning customers" value={customerReport.returningCustomers} />
</div>
{customerReport.topCustomers.length > 0 && (
<Card className="divide-line divide-y px-5">
{customerReport.topCustomers.map((c) => (
<div key={c.customerId} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-bold">{c.name}</p>
<p className="text-muted text-xs">{c.visits} visit{c.visits === 1 ? "" : "s"}</p>
</div>
<span className="font-bold">£{c.spend}</span>
</div>
))}
</Card>
)}
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Loyalty</h2>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="Stamps issued" value={loyaltyReport.stampsIssued} />
<StatTile label="Stamps removed" value={loyaltyReport.stampsRemoved} />
<StatTile label="Rewards earned" value={loyaltyReport.rewardsEarned} />
<StatTile label="Rewards redeemed" value={loyaltyReport.rewardsRedeemed} />
</div>
{loyaltyReport.rewardsExpired > 0 && <StatTile label="Rewards expired" value={loyaltyReport.rewardsExpired} />}
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Notifications</h2>
<div className="grid grid-cols-2 gap-3">
<StatTile label="Sent" value={notificationReport.totalSent} />
<StatTile label="Failed" value={notificationReport.totalFailed} />
</div>
{notificationReport.byChannel.length > 0 && (
<Card className="divide-line divide-y px-5">
{notificationReport.byChannel.map((c) => (
<div key={c.channel} className="flex items-center justify-between py-3 text-sm">
<span className="font-bold">{c.channel}</span>
<span className="text-muted">
{c.sent} sent{c.failed > 0 ? ` · ${c.failed} failed` : ""}
</span>
</div>
))}
</Card>
)}
</section>
</div>
);
}
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listCategories } from "@/services/catalogue/service-catalogue.service";
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
export const metadata: Metadata = { title: "Services" };
export const dynamic = "force-dynamic";
export default async function OwnerServicesPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const categories = await listCategories(prisma, context.businessId);
return (
<div className="px-6 pt-12 pb-12 lg:max-w-3xl">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Services</h1>
<CatalogueManager
categories={categories.map((c) => ({
id: c.id,
name: c.name,
isActive: c.isActive,
services: c.services.map((s) => ({
id: s.id,
categoryId: s.categoryId,
name: s.name,
description: s.description ?? "",
imageUrl: s.imageUrl,
durationMinutes: s.durationMinutes,
price: s.price.toString(),
bufferBeforeMinutes: s.bufferBeforeMinutes,
bufferAfterMinutes: s.bufferAfterMinutes,
isActive: s.isActive,
allowOnlineBooking: s.allowOnlineBooking,
})),
}))}
/>
</div>
);
}
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { isSuperAdmin } from "@/lib/auth/permissions";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { parseSplashScreens } from "@/lib/business/branding";
import { getBusinessProfile } from "@/services/business/business.service";
import { AppIconSettingsForm } from "@/features/owner/components/app-icon-settings-form";
export const metadata: Metadata = { title: "App Icon & Home Screen" };
export const dynamic = "force-dynamic";
/** Super Administrator only — not Owner, unlike every other /owner/settings/* page. See requireSuperAdminBusinessManager in features/owner/actions/business.ts for the matching action-layer check. */
export default async function OwnerAppIconSettingsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
if (!(await isSuperAdmin(session.user.id))) redirect("/owner");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
return (
<SettingsPageShell title="App Icon & Home Screen">
<AppIconSettingsForm
initial={{
shortName: business.shortName?.trim() || business.name,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor,
backgroundColor: business.backgroundColor,
icon192Url: business.icon192Url,
icon512Url: business.icon512Url,
maskableIcon192Url: business.maskableIcon192Url,
maskableIcon512Url: business.maskableIcon512Url,
splashScreens: parseSplashScreens(business.splashScreens),
bottomNavShowLabels: business.bottomNavShowLabels,
}}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
export const metadata: Metadata = { title: "Booking Rules" };
export const dynamic = "force-dynamic";
export default async function OwnerBookingRulesPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const rule = await getBookingRule(prisma, context.businessId);
if (!rule) redirect("/owner");
return (
<SettingsPageShell title="Booking Rules">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,63 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessProfile } from "@/services/business/business.service";
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
export const metadata: Metadata = { title: "Business Details" };
export const dynamic = "force-dynamic";
export default async function OwnerBusinessDetailsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
const location = business.locations[0];
return (
<SettingsPageShell title="Business Details">
<BusinessDetailsForm
initial={{
name: business.name,
description: business.description ?? "",
logoUrl: business.logoUrl,
coverImageUrl: business.coverImageUrl,
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
faviconUrl: business.faviconUrl,
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
instagramUrl: business.instagramUrl ?? "",
facebookUrl: business.facebookUrl ?? "",
timezone: business.timezone,
currency: business.currency,
locale: business.locale,
addressLine1: location?.addressLine1 ?? "",
addressLine2: location?.addressLine2 ?? "",
city: location?.city ?? "",
region: location?.region ?? "",
postalCode: location?.postalCode ?? "",
country: location?.country ?? "GB",
cancellationPolicy: business.settings?.cancellationPolicy ?? "",
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
bookingTerms: business.settings?.bookingTerms ?? "",
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
}}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { GalleryPlacement } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listGalleryImages } from "@/services/gallery/gallery.service";
import { GalleryManager } from "@/features/owner/components/gallery-manager";
export const metadata: Metadata = { title: "Gallery" };
export const dynamic = "force-dynamic";
export default async function OwnerGalleryPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [heroImages, portfolioImages] = await Promise.all([
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.HERO }),
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.PORTFOLIO }),
]);
return (
<SettingsPageShell title="Gallery" mainClassName="space-y-8">
<GalleryManager
placement={GalleryPlacement.HERO}
title="Home Hero Gallery"
description="Shown at the top of the customer Home page. Falls back to your cover image, then your logo, when empty."
showCategory={false}
initialImages={heroImages}
/>
<GalleryManager
placement={GalleryPlacement.PORTFOLIO}
title="Portfolio"
description="Shown in the Portfolio tab on Home. Add a category to each image to power the filter pills there."
showCategory
initialImages={portfolioImages}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getOpeningHours } from "@/services/business/business.service";
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
export const metadata: Metadata = { title: "Opening Hours" };
export const dynamic = "force-dynamic";
export default async function OwnerOpeningHoursPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const hours = await getOpeningHours(prisma, context.businessId);
return (
<SettingsPageShell title="Opening Hours" mainClassName="lg:max-w-2xl">
<OpeningHoursForm initialRows={hours} />
</SettingsPageShell>
);
}
@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { LoyaltyCardFont, LoyaltyCardTextSize, Permission, RewardPositionMode } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
export const metadata: Metadata = { title: "Loyalty Programme" };
export const dynamic = "force-dynamic";
export default async function OwnerLoyaltyConfigPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [programme, services, staff] = await Promise.all([
getProgrammeConfig(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
prisma.staffProfile.findMany({
where: { businessId: context.businessId, status: "ACTIVE" },
include: { user: { select: { name: true } } },
}),
]);
const initialConfig = programme
? {
isEnabled: programme.isEnabled,
stampsRequired: programme.stampsRequired,
rewardPosition: programme.rewardPosition,
rewardType: programme.rewardType,
rewardValue: programme.rewardValue?.toString(),
rewardLabel: programme.rewardLabel ?? undefined,
maxRewardValue: programme.maxRewardValue?.toString(),
freeServiceId: programme.freeServiceId ?? undefined,
autoApplyReward: programme.autoApplyReward,
requiresStaffApproval: programme.requiresStaffApproval,
stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined,
rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined,
earnOnCancelled: programme.earnOnCancelled,
earnOnNoShow: programme.earnOnNoShow,
cardBackgroundColor: programme.cardBackgroundColor ?? undefined,
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
stampIcon: programme.stampIcon ?? undefined,
termsLine: programme.termsLine ?? undefined,
cardTitleText: programme.cardTitleText ?? undefined,
cardFont: programme.cardFont,
cardTitleTextSize: programme.cardTitleTextSize,
cardTitleTextColor: programme.cardTitleTextColor ?? undefined,
cardBorderColor: programme.cardBorderColor ?? undefined,
cardMemberNumberColor: programme.cardMemberNumberColor ?? undefined,
}
: {
isEnabled: false,
stampsRequired: 10,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE" as const,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTitleTextSize: LoyaltyCardTextSize.MD,
};
return (
<SettingsPageShell title="Loyalty Programme">
<LoyaltyConfigForm
businessName={context.businessName}
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
services={services.map((s) => ({ id: s.id, name: s.name }))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
/**
* Kept as a redirect rather than deleted so any existing bookmark to
* `/owner/settings` still works (see the Phase 11 merged-nav brief: "avoid
* unnecessary route changes... add redirects only where old nav entry
* routes become obsolete"). The page it used to render — a mix of personal
* settings (Notifications) and business settings (Business Details,
* Gallery, Hours, Booking Rules, Loyalty, Reviews) plus a "Switch to
* Barber Tools" link — is now split cleanly: personal settings live at the
* one shared `/staff/settings` hub, and business settings are reachable
* from the "Manage" nav item's grouped links (see (owner)/owner/page.tsx).
*/
export default function OwnerSettingsRedirectPage() {
redirect("/staff/settings");
}
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessProfile } from "@/services/business/business.service";
import { ReviewsManager } from "@/features/owner/components/reviews-manager";
export const metadata: Metadata = { title: "Reviews" };
export const dynamic = "force-dynamic";
export default async function OwnerReviewsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_REVIEWS);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
return (
<SettingsPageShell title="Reviews">
<ReviewsManager
initialGooglePlaceId={business.googlePlaceId ?? ""}
googleRating={business.googleRating ? Number(business.googleRating) : null}
googleRatingCount={business.googleRatingCount}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listBreaks } from "@/services/staff/staff.service";
import { BreaksList } from "@/features/staff/components/breaks-list";
import { createOwnerBreakActionSet } from "@/features/owner/actions/break-action-set";
export const metadata: Metadata = { title: "Staff Breaks" };
export const dynamic = "force-dynamic";
export default async function OwnerStaffBreaksPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: context.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
const breaks = await listBreaks(prisma, id);
return (
<div className="pb-12">
<TopNav title={`${target.user.name ?? "Staff"} — Breaks`} onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<BreaksList
breaks={breaks.map((b) => ({
id: b.id,
label: b.label,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
days: b.days.map((d) => d.dayOfWeek),
}))}
actions={createOwnerBreakActionSet(id)}
appointmentHrefBase="/owner/appointments"
/>
</main>
</div>
);
}
@@ -0,0 +1,84 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission, Weekday } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getWorkingHours } from "@/services/staff/staff.service";
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
export const metadata: Metadata = { title: "Staff Working Hours" };
export const dynamic = "force-dynamic";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
function formatMinutes(m: number): string {
const h = Math.floor(m / 60);
const min = m % 60;
return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
}
export default async function OwnerStaffWorkingHoursPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: context.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
const [workingHours, businessHours] = await Promise.all([
getWorkingHours(prisma, id),
prisma.businessHours.findMany({ where: { businessId: context.businessId } }),
]);
const businessHoursLabel = Object.fromEntries(
DAY_ORDER.map((day) => {
const hours = businessHours.find((h) => h.dayOfWeek === day);
const label =
!hours || hours.isClosed || hours.opensAtMinutes === null || hours.closesAtMinutes === null
? "Closed"
: `${formatMinutes(hours.opensAtMinutes)}–${formatMinutes(hours.closesAtMinutes)}`;
return [day, label];
}),
) as Record<Weekday, string>;
return (
<div className="pb-12">
<TopNav title={`${target.user.name ?? "Staff"} — Working Hours`} onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
{/* .bind(null, id), not an arrow-function wrapper — a Client Component can only
receive an actual Server Action reference as a prop. An inline arrow closure
here throws "Event handlers cannot be passed to Client Component props" at
render time (found empirically in Phase 7 — this page never actually worked
until this fix; see docs/phase-7-summary.md and
src/features/owner/actions/break-action-set.ts for the same issue). */}
<WorkingHoursForm
initialRows={workingHours}
businessHoursLabel={businessHoursLabel}
onSave={updateStaffWorkingHoursAction.bind(null, id)}
appointmentHrefBase="/owner/appointments"
/>
</main>
</div>
);
}
@@ -0,0 +1,108 @@
import type { Metadata } from "next";
import { Mail, Phone } from "lucide-react";
import Image from "next/image";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
export const metadata: Metadata = { title: "Staff Details" };
export const dynamic = "force-dynamic";
export default async function OwnerStaffDetailPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const [target, roles, allServices, calendarConnection] = await Promise.all([
getStaffDetailById(prisma, { staffId: id, businessId: context.businessId }),
listRolesForBusiness(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
// Status only — never tokens (see the brief's "Owners must not see
// OAuth tokens, refresh tokens, or unrelated private calendar
// events"). Owners can see whether a barber has connected, not
// connect on their behalf.
prisma.staffCalendarConnection.findUnique({ where: { staffId: id }, select: { revokedAt: true, lastSyncError: true } }),
]);
if (!target) notFound();
return (
<div className="pb-12">
<TopNav title="Staff Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6 lg:max-w-4xl lg:grid lg:max-w-none lg:grid-cols-[320px_1fr] lg:items-start lg:gap-6 lg:space-y-0">
<div className="space-y-4">
<Card className="flex flex-col items-center p-6 text-center">
{target.user.image ? (
<Image
src={target.user.image}
alt=""
width={96}
height={96}
className="border-stone mb-4 h-24 w-24 rounded-full border-4 object-cover"
/>
) : (
<div className="bg-stone text-muted mb-4 flex h-24 w-24 items-center justify-center rounded-full text-2xl font-bold">
{(target.user.name ?? "?").charAt(0)}
</div>
)}
<h2 className="text-xl font-bold">{target.user.name}</h2>
<p className="text-accent mt-1 text-xs font-bold tracking-widest uppercase">
{target.role.name} · {target.status === "ACTIVE" ? "Active" : target.status === "ON_BREAK" ? "On Break" : "Inactive"}
</p>
<p className="text-muted mt-2 text-xs">Joined {target.createdAt.toLocaleDateString("en-GB", { month: "short", year: "numeric" })}</p>
{target.bio && <p className="text-muted mt-3 text-sm">{target.bio}</p>}
</Card>
<div className="grid grid-cols-2 gap-3">
{target.user.phone && (
<a href={`tel:${target.user.phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
</a>
)}
{target.user.email && (
<a href={`mailto:${target.user.email}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
<Mail className="text-accent h-4 w-4" aria-hidden="true" /> Email
</a>
)}
</div>
<Card className="p-4">
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">Google Calendar</p>
<p className="text-sm font-bold">
{!calendarConnection || calendarConnection.revokedAt
? "Not connected"
: calendarConnection.lastSyncError
? "Sync error"
: "Connected"}
</p>
{calendarConnection?.lastSyncError && !calendarConnection.revokedAt && (
<p className="text-danger mt-1 text-xs">{calendarConnection.lastSyncError}</p>
)}
</Card>
</div>
<StaffDetailPanel
staffId={target.id}
currentRoleId={target.roleId}
roles={roles.map((r) => ({ id: r.id, name: r.name }))}
allServices={allServices.map((s) => ({ id: s.id, name: s.name }))}
initialServiceIds={target.services.map((s) => s.serviceId)}
status={target.status}
hasPendingInvitation={!!target.invitation && !target.invitation.acceptedAt}
/>
</main>
</div>
);
}
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listTimeOff } from "@/services/staff/staff.service";
import { TimeOffList } from "@/features/staff/components/time-off-list";
import { createOwnerTimeOffActionSet } from "@/features/owner/actions/time-off-action-set";
export const metadata: Metadata = { title: "Staff Time Off" };
export const dynamic = "force-dynamic";
function toCalendarDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
export default async function OwnerStaffTimeOffPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: context.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
const { upcoming, past } = await listTimeOff(prisma, id);
// Whole-day rows only — partial-day "Block Time" entries are managed from the owner calendar (see owner-block-time-button.tsx).
const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null);
const toEditable = (rows: typeof upcoming) =>
wholeDay(rows).map((t) => ({
id: t.id,
startDate: toCalendarDate(t.startDate),
endDate: toCalendarDate(t.endDate),
reason: t.reason,
}));
return (
<div className="pb-12">
<TopNav title={`${target.user.name ?? "Staff"} — Time Off`} onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<TimeOffList
upcoming={toEditable(upcoming)}
past={toEditable(past)}
actions={createOwnerTimeOffActionSet(id)}
appointmentHrefBase="/owner/appointments"
/>
</main>
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listRolesForBusiness } from "@/services/staff/staff.service";
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
export const metadata: Metadata = { title: "Add Staff" };
export const dynamic = "force-dynamic";
export default async function OwnerAddStaffPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [roles, services] = await Promise.all([
listRolesForBusiness(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
]);
return (
<div className="pb-12">
<TopNav title="Add Staff" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<AddStaffForm roles={roles.map((r) => ({ id: r.id, name: r.name }))} services={services.map((s) => ({ id: s.id, name: s.name }))} />
</main>
</div>
);
}
@@ -0,0 +1,96 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Plus, Search } from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listStaffForBusiness } from "@/services/staff/staff.service";
import { cn } from "@/lib/utils/cn";
export const metadata: Metadata = { title: "Team" };
export const dynamic = "force-dynamic";
export default async function OwnerTeamPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { q = "" } = await searchParams;
const staff = await listStaffForBusiness(prisma, { businessId: context.businessId, q, includeInactive: true });
return (
<div className="px-6 pt-12 pb-12 lg:max-w-4xl">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Team</h1>
<Link
href="/owner/team/add"
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
aria-label="Add staff member"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
<form action="/owner/team" method="GET" className="relative mb-4">
<Search className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
<label htmlFor="team-search" className="sr-only">
Search staff by name or email
</label>
<input
id="team-search"
type="search"
name="q"
defaultValue={q}
placeholder="Search by name or email"
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
/>
</form>
<div className="grid gap-3 lg:grid-cols-2">
{staff.length === 0 ? (
<p className="text-muted col-span-full rounded-md border border-line bg-white p-5 text-center text-sm">
No staff match &ldquo;{q}&rdquo;.
</p>
) : (
staff.map((s) => (
<Link
key={s.id}
href={`/owner/team/${s.id}`}
className={cn(
"rounded-md border-line flex items-center gap-4 border bg-white p-5",
s.status === "INACTIVE" && "opacity-60",
)}
>
<div className="bg-stone text-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{(s.user.name ?? "?").charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold">{s.user.name}</p>
<p className="text-muted truncate text-xs">{s.user.email}</p>
<p className="text-accent mt-0.5 text-[10px] font-bold tracking-widest uppercase">
{s.role.name}
{s.status === "INACTIVE" && <span className="text-muted"> · Inactive</span>}
{s.status === "ACTIVE" && s.invitation && !s.invitation.acceptedAt && (
<span className="text-warning-foreground"> · Invite Pending</span>
)}
</p>
</div>
</Link>
))
)}
</div>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { ErrorState } from "@/components/feedback/error-state";
/** Keeps (staff)/layout.tsx's sidebar/bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
export default function StaffError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <ErrorState onRetry={reset} />;
}
+39
View File
@@ -0,0 +1,39 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { StaffShell } from "@/components/navigation/staff-shell";
import { getNavRole } from "@/lib/auth/permissions";
import { getNavItems } from "@/lib/navigation/nav-config";
import { prisma } from "@/lib/db/prisma";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
export default async function StaffLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) {
redirect("/sign-in?callbackUrl=/staff");
}
// Real role gating, not just "is signed in" — a registered customer with
// no StaffProfile (and no Super Administrator flag) is bounced back to
// their own home rather than seeing an empty staff shell. See
// docs/implementation-plan.md §3 (role conflicts) and §11 (permission
// enforcement). A Super Administrator has no StaffProfile by design (see
// isSuperAdmin) but is still admitted here — they need this route group
// for Clients and the personal Settings hub (Profile/Notifications/Sign
// Out), even though they have no personal barber schedule.
const role = await getNavRole(session.user.id);
if (!role) {
redirect("/home");
}
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
return (
<StaffShell
items={getNavItems(role)}
notificationsHref="/staff/notifications"
unreadNotificationCount={unreadNotificationCount}
>
{children}
</StaffShell>
);
}
@@ -0,0 +1,296 @@
import type { Metadata } from "next";
import { CalendarPlus, Mail, Phone } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment Details" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
export default async function StaffAppointmentDetailsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
assertOwnAppointment(appointment, staff);
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
prisma.customerNote.findMany({
where: { customerId: appointment.customerId },
orderBy: { createdAt: "desc" },
take: 5,
include: { author: { include: { user: { select: { name: true } } } } },
}),
prisma.loyaltyTransaction.findMany({
where: { appointmentId: appointment.id },
orderBy: { createdAt: "desc" },
}),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: staff.businessId }),
appointment.cancelledByUserId
? prisma.user.findUnique({
where: { id: appointment.cancelledByUserId },
select: { name: true },
})
: null,
]);
const tz = staff.business.timezone;
const totalDuration = appointment.services.reduce(
(sum, s) => sum + s.durationAtBookingMinutes,
0,
);
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
const canRedeemReward =
loyalty?.availableReward && appointment.discountAmount === null && appointment.status !== "CANCELLED";
return (
<div className="pb-12">
<TopNav title="Appointment Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Ref: {appointment.id.slice(-8)}
</span>
</div>
<div className="border-line space-y-1 border-t pt-4">
<Link
href={`/staff/clients/${appointment.customerId}`}
className="text-lg font-bold underline"
>
{appointment.customer.firstName} {appointment.customer.lastName}
</Link>
<p className="text-muted text-sm">
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<div className="flex flex-wrap gap-3">
{appointment.customer.phone && (
<a
href={`tel:${appointment.customer.phone}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${appointment.customer.email}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Services
</span>
<p className="font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Date
</span>
<p className="font-bold">
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: tz,
})}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Time
</span>
<p className="font-bold">
{appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}{" "}
–{" "}
{appointment.endAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration
</span>
<p className="font-bold">{totalDuration} min</p>
</div>
</div>
<div className="border-line border-t pt-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted">Subtotal</span>
<span className="font-bold">£{totalPrice.toFixed(2)}</span>
</div>
{discount > 0 && (
<div className="text-accent-loyalty flex items-center justify-between">
<span>Loyalty reward</span>
<span className="font-bold">-£{discount.toFixed(2)}</span>
</div>
)}
<div className="border-line mt-1 flex items-center justify-between border-t pt-1">
<span className="font-bold">Total</span>
<span className="font-bold">£{(totalPrice - discount).toFixed(2)}</span>
</div>
</div>
{appointment.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Booking Notes
</span>
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Customer Notes
</span>
<div className="mt-2 space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Loyalty
</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
</p>
{loyaltyTransactions.length > 0 && (
<ul className="mt-2 space-y-1">
{loyaltyTransactions.map((t) => (
<li key={t.id} className="text-muted text-xs">
{t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} —{" "}
{t.type.replace("_", " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
</li>
))}
</ul>
)}
</div>
)}
{appointment.status === "CANCELLED" && (
<div className="border-line border-t pt-4 text-sm">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Cancellation
</span>
<p className="text-muted mt-1">
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
</p>
{appointment.cancellationReason && (
<p className="text-muted mt-1 italic">
&ldquo;{appointment.cancellationReason}&rdquo;
</p>
)}
</div>
)}
</Card>
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/staff/appointments/${appointment.id}/reschedule`}
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule
</Link>
)}
{appointment.status === "COMPLETED" && (
<BookFollowUpButton appointmentId={appointment.id} />
)}
</div>
<AppointmentStatusActions appointmentId={appointment.id} status={appointment.status} />
<div className="flex flex-wrap gap-3">
{loyalty?.isEnabled && (
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />
)}
{canRedeemReward && loyalty?.availableReward && (
<RedeemRewardButton
appointmentId={appointment.id}
rewardId={loyalty.availableReward.id}
rewardLabel={loyalty.availableReward.label}
subtotal={totalPrice}
/>
)}
</div>
</main>
</div>
);
}
@@ -0,0 +1,281 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { CalendarX2, Mail, Phone } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { EmptyState } from "@/components/feedback/empty-state";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button";
import { CustomerNotes } from "@/features/staff/components/customer-notes";
import type { CustomerNoteItem } from "@/features/staff/components/customer-notes";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Client Details" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
export default async function StaffCustomerDetailsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const context = await getActingBusinessContext();
if (!context) redirect("/home");
const { id } = await params;
const customer = await prisma.customerProfile.findUnique({
where: { id },
include: {
preferredBarber: { include: { user: { select: { name: true } } } },
user: { select: { image: true } },
},
});
if (!customer) notFound();
// "Belongs to this business" is via the appointment relation — see
// docs/phase-5-summary.md, same reasoning as the clients list query.
const belongsToBusiness = await prisma.appointment.findFirst({
where: { customerId: id, businessId: context.businessId },
select: { id: true },
});
if (!belongsToBusiness) notFound();
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
listAppointments(prisma, {
customerId: id,
businessId: context.businessId,
statuses: ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"],
}),
listAppointments(prisma, {
customerId: id,
businessId: context.businessId,
statuses: ["COMPLETED"],
}),
listAppointments(prisma, {
customerId: id,
businessId: context.businessId,
statuses: ["CANCELLED", "NO_SHOW"],
}),
prisma.customerNote.findMany({
where: { customerId: id },
orderBy: { createdAt: "desc" },
include: { author: { include: { user: { select: { name: true } } } } },
}),
getLoyaltySummary(prisma, { customerId: id, businessId: context.businessId }),
]);
const loyaltyTransactions = loyalty?.accountId
? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 10 })
: [];
const notes: CustomerNoteItem[] = notesRaw.map((n) => ({
id: n.id,
body: n.body,
createdAt: n.createdAt.toISOString(),
updatedAt: n.updatedAt.toISOString(),
authorStaffId: n.authorStaffId,
authorName: n.author.user.name ?? "Staff",
}));
const tz = context.businessTimezone;
const renderList = (appointments: typeof upcoming, emptyLabel: string) => {
if (appointments.length === 0) {
return <EmptyState icon={CalendarX2} title={emptyLabel} />;
}
return (
<div className="space-y-3">
{appointments.map((a) => (
<AppointmentCard
key={a.id}
status={STATUS_MAP[a.status] ?? "confirmed"}
serviceName={a.services.map((s) => s.service.name).join(" + ")}
person={{ id: a.staffId, name: a.staff.user.name ?? "Barber" }}
formattedDate={a.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: tz,
})}
formattedTime={a.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
actions={
<Link
href={`/staff/appointments/${a.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
}
/>
))}
</div>
);
};
return (
<div className="pb-12">
<TopNav title="Client Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6 lg:max-w-5xl lg:items-start lg:gap-6 lg:space-y-0 lg:[display:grid] lg:grid-cols-[360px_1fr]">
<Card className="space-y-5 p-6">
<div className="flex items-center gap-4">
{customer.user?.image ? (
<Image
src={customer.user.image}
alt=""
width={56}
height={56}
className="h-14 w-14 rounded-full object-cover"
/>
) : (
<div className="bg-stone text-muted flex h-14 w-14 items-center justify-center rounded-full text-lg font-bold">
{customer.firstName.charAt(0)}
{customer.lastName.charAt(0)}
</div>
)}
<div>
<p className="text-lg font-bold">
{customer.firstName} {customer.lastName}
</p>
<p className="text-muted text-xs">
{customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
</div>
<div className="border-line space-y-1 border-t pt-4 text-sm">
<p className="text-muted">{customer.email}</p>
{customer.phone && <p className="text-muted">{customer.phone}</p>}
{customer.preferredBarber && (
<p className="text-muted">
Preferred barber: {customer.preferredBarber.user.name ?? "—"}
</p>
)}
</div>
<div className="border-line flex flex-wrap gap-3 border-t pt-4">
{customer.phone && (
<a
href={`tel:${customer.phone}`}
className="border-line flex h-12 min-w-[104px] flex-1 items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${customer.email}`}
className="border-line flex h-12 min-w-[104px] flex-1 items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
<Button asChild size="md" className="min-w-[104px] flex-1">
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
</Button>
</div>
{loyalty?.isEnabled && loyalty.accountId && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Loyalty
</span>
<p className="mb-3 text-sm font-bold">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
</p>
<div className="mb-3 flex flex-wrap gap-2">
<AddStampButton customerId={id} className="min-w-[140px] flex-1" />
<CorrectStampButton
loyaltyAccountId={loyalty.accountId}
className="min-w-[140px] flex-1"
/>
</div>
{loyaltyTransactions.length > 0 && (
<ul className="space-y-1">
{loyaltyTransactions.map((t) => (
<li key={t.id} className="text-muted text-xs">
{t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}{" "}
— {t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
</li>
))}
</ul>
)}
</div>
)}
<div className="border-line grid grid-cols-3 gap-3 border-t pt-4 text-center">
<div>
<p className="text-lg font-bold">{past.length}</p>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Visits</p>
</div>
<div>
<p className="text-lg font-bold">{upcoming.length}</p>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Upcoming</p>
</div>
<div>
<p className="text-lg font-bold">{cancelled.length}</p>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
Cancelled
</p>
</div>
</div>
</Card>
<div className="mt-4 space-y-4 lg:mt-0">
<Card className="p-6">
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
{/* A Super Administrator has no StaffProfile/staffId — passing "" is a
deliberate never-matches sentinel (see CustomerNotes' isOwn check),
so they can view every note but own none. Adding a note as a
Super Administrator fails gracefully server-side (see
addCustomerNoteAction: "You must be signed in as staff.") rather
than fabricating a staff identity for them. */}
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={context.staffId ?? ""} />
</Card>
<Tabs defaultValue="upcoming">
<TabsList>
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
<TabsTrigger value="past">Past</TabsTrigger>
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">
{renderList(upcoming, "No upcoming appointments")}
</TabsContent>
<TabsContent value="past">{renderList(past, "No past appointments")}</TabsContent>
<TabsContent value="cancelled">
{renderList(cancelled, "No cancelled appointments")}
</TabsContent>
</Tabs>
</div>
</main>
</div>
);
}
@@ -0,0 +1,184 @@
import type { Metadata } from "next";
import { Search, Users } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { matchesCustomerSearch } from "@/services/customer/customer.service";
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import { cn } from "@/lib/utils/cn";
export const metadata: Metadata = { title: "Clients" };
export const dynamic = "force-dynamic";
const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN"];
export default async function StaffClientsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; sort?: string }>;
}) {
const context = await getActingBusinessContext();
if (!context) redirect("/home");
const { q = "", sort } = await searchParams;
const unreadNotificationCount = await getUnreadNotificationCount(prisma, context.userId);
// Scoped via the appointment relation, not "every CustomerProfile row" —
// a real business boundary even though this is a single-business v1 app
// (see docs/phase-5-summary.md). `select`-scoped rather than the full
// row + full appointment `include` this used to fetch — the card only
// ever reads status/startAt off each appointment, never the service
// lines/notes/discount that a full include would also pull down for
// every appointment of every client on every page view (see the Phase 7
// performance pass).
const customers = await prisma.customerProfile.findMany({
where: { appointments: { some: { businessId: context.businessId } } },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
appointments: {
where: { businessId: context.businessId },
orderBy: { startAt: "desc" },
select: { status: true, startAt: true },
},
},
});
const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, {
businessId: context.businessId,
customerIds: customers.map((c) => c.id),
});
const filtered = customers.filter((c) => matchesCustomerSearch(c, q));
const sorted = [...filtered].sort((a, b) => {
if (sort === "recent") {
const aLatest = a.appointments[0]?.startAt.getTime() ?? 0;
const bLatest = b.appointments[0]?.startAt.getTime() ?? 0;
return bLatest - aLatest;
}
return `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`);
});
const now = new Date();
return (
<div className="pb-8 lg:max-w-5xl">
<TopNav
title="Clients"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="px-6 pt-6">
<form action="/staff/clients" method="GET" className="relative mb-4">
{sort && <input type="hidden" name="sort" value={sort} />}
<Search
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<label htmlFor="client-search" className="sr-only">
Search clients by name, email, or phone
</label>
<input
id="client-search"
type="search"
name="q"
defaultValue={q}
placeholder="Search by name, email, or phone"
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
/>
</form>
<div className="mb-4 flex gap-2">
<Link
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=name`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
sort !== "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Alphabetical
</Link>
<Link
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=recent`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
sort === "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Recent
</Link>
</div>
{sorted.length === 0 ? (
<EmptyState
icon={Users}
title={q ? "No matching clients" : "No clients yet"}
description={
q
? "Try a different name, email, or phone number."
: "Clients will show up here once they've booked with you."
}
/>
) : (
<div className="grid gap-3 lg:grid-cols-2">
{sorted.map((customer) => {
const completedVisits = customer.appointments.filter(
(a) => a.status === "COMPLETED",
).length;
const lastVisit = customer.appointments.find((a) => a.status === "COMPLETED");
const upcoming = customer.appointments.find(
(a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now,
);
const loyalty = loyaltySummaries.get(customer.id);
return (
<Link
key={customer.id}
href={`/staff/clients/${customer.id}`}
className="rounded-md border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
>
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{customer.firstName.charAt(0)}
{customer.lastName.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold">
{customer.firstName} {customer.lastName}
</p>
<p className="text-muted truncate text-xs">
{customer.phone ?? customer.email}
{lastVisit &&
` · Last visit ${lastVisit.startAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
</p>
<p className="text-muted mt-0.5 text-[10px] font-bold tracking-widest uppercase">
{completedVisits} visit{completedVisits === 1 ? "" : "s"}
{upcoming && <span className="text-accent"> · Upcoming</span>}
{loyalty && (
<span className={loyalty.hasAvailableReward ? "text-accent-loyalty" : ""}>
{" "}
· {loyalty.currentStamps}/{loyalty.rewardThreshold} stamps
</span>
)}
</p>
</div>
</Link>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,249 @@
import type { Metadata } from "next";
import { Mail, NotebookText, Phone, Scissors } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { todayIn } from "@/lib/time/business-time";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import {
listAppointments,
selectCurrentAppointment,
} from "@/services/scheduling/appointment.service";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Current Appointment" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
export default async function StaffCurrentPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const today = todayIn(staff.business.timezone);
const todaysAppointments = await listAppointments(prisma, {
businessId: staff.businessId,
staffId: staff.id,
fromDate: today,
toDate: today,
businessTimezone: staff.business.timezone,
});
const current = selectCurrentAppointment(todaysAppointments, new Date());
const unreadNotificationCount = await getUnreadNotificationCount(prisma, staff.user.id);
if (!current) {
return (
<div className="pb-8">
<TopNav
title="Current Appointment"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="px-6 pt-6">
<EmptyState
icon={Scissors}
title="Nothing scheduled right now"
description="Your next appointment today will show up here."
/>
</div>
</div>
);
}
const [notes, lastCompleted, loyalty] = await Promise.all([
prisma.customerNote.findMany({
where: { customerId: current.customerId },
orderBy: { createdAt: "desc" },
take: 2,
include: { author: { include: { user: { select: { name: true } } } } },
}),
prisma.appointment.findFirst({
where: { customerId: current.customerId, status: "COMPLETED", id: { not: current.id } },
orderBy: { startAt: "desc" },
include: { services: { include: { service: true } } },
}),
getLoyaltySummary(prisma, { customerId: current.customerId, businessId: staff.businessId }),
]);
const totalDuration = current.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
const totalPrice = current.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const tz = staff.business.timezone;
return (
<div className="pb-8">
<TopNav
title="Current Appointment"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="space-y-4 px-6 pt-6">
<Card className="space-y-5 p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-lg font-bold">
{current.customer.firstName} {current.customer.lastName}
</p>
<p className="text-muted text-xs">
{current.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<StatusPill status={STATUS_MAP[current.status] ?? "confirmed"} />
</div>
<div className="flex flex-wrap gap-3">
{current.customer.phone && (
<a
href={`tel:${current.customer.phone}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${current.customer.email}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
<Link
href={`/staff/clients/${current.customerId}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
View Customer
</Link>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Services
</span>
<p className="font-bold">{current.services.map((s) => s.service.name).join(" + ")}</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Time
</span>
<p className="font-bold">
{current.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}{" "}
–{" "}
{current.endAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration
</span>
<p className="font-bold">{totalDuration} min</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Price
</span>
<p className="font-bold">£{totalPrice.toFixed(2)}</p>
</div>
</div>
{current.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Booking Notes
</span>
<p className="text-muted mt-1 text-sm italic">{current.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted mb-2 flex items-center gap-1.5 text-[10px] font-bold tracking-widest uppercase">
<NotebookText className="h-3 w-3" aria-hidden="true" /> Customer Notes
</span>
<div className="space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Loyalty
</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
</p>
</div>
)}
{lastCompleted && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Last Visit
</span>
<p className="text-muted text-sm">
{lastCompleted.services.map((s) => s.service.name).join(" + ")} ·{" "}
{lastCompleted.startAt.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
timeZone: tz,
})}
</p>
</div>
)}
</Card>
<AppointmentStatusActions appointmentId={current.id} status={current.status} layout="stack" />
<div className="flex flex-col gap-2">
{loyalty?.isEnabled && (
<AddStampButton
customerId={current.customerId}
appointmentId={current.id}
size="lg"
className="w-full"
/>
)}
<BookFollowUpButton appointmentId={current.id} className="w-full" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listInAppNotifications } from "@/services/notifications/notification.service";
import { NotificationList } from "@/features/notifications/components/notification-list";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
export default async function StaffNotificationsPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
return (
<div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<NotificationList notifications={notifications} />
</main>
</div>
);
}
@@ -0,0 +1,277 @@
import type { Metadata } from "next";
import { CalendarX2, Plus } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
todayIn,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { ViewSwitch } from "@/features/staff/components/view-switch";
import { BlockTimeButton } from "@/features/staff/components/block-time-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Calendar" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
export default async function StaffCalendarPage({
searchParams,
}: {
searchParams: Promise<{ date?: string; view?: string }>;
}) {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { date: dateParam, view: viewParam } = await searchParams;
const today = todayIn(staff.business.timezone);
const date: CalendarDate = dateParam ?? today;
const view = viewParam === "agenda" ? "agenda" : "day";
const weekday = weekdayOf(date);
const dateUtcMidnight = calendarDateToUtcMidnight(date);
const [workingHours, breakDays, timeOffRows, appointments] = await Promise.all([
prisma.workingHours.findUnique({
where: { staffId_dayOfWeek: { staffId: staff.id, dayOfWeek: weekday } },
}),
prisma.breakDay.findMany({
where: { dayOfWeek: weekday, break: { staffId: staff.id } },
include: { break: true },
}),
prisma.timeOff.findMany({
where: {
staffId: staff.id,
status: "APPROVED",
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
},
}),
listAppointments(prisma, {
businessId: staff.businessId,
staffId: staff.id,
fromDate: date,
toDate: date,
businessTimezone: staff.business.timezone,
}),
]);
const wholeDayOff = timeOffRows.find((t) => t.startMinutes === null);
const rewardFlags = await getAvailableRewardFlags(prisma, {
businessId: staff.businessId,
customerIds: [...new Set(appointments.map((a) => a.customerId))],
});
const workingWindow =
workingHours &&
!workingHours.isOff &&
workingHours.startMinutes !== null &&
workingHours.endMinutes !== null
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
: null;
const blocks: CalendarBlock[] = [
...breakDays.map((bd) => ({
id: `break-${bd.id}`,
startMinutes: bd.break.startMinutes,
endMinutes: bd.break.endMinutes,
label: bd.break.label,
kind: "break" as const,
})),
...timeOffRows
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({
id: `timeoff-${t.id}`,
startMinutes: t.startMinutes!,
endMinutes: t.endMinutes!,
label: t.reason ?? "Blocked",
kind: "timeOff" as const,
})),
];
const calendarAppointments: CalendarAppointment[] = appointments.map((a) => ({
id: a.id,
startMinutes: minutesFromMidnight(a.startAt, staff.business.timezone),
endMinutes: minutesFromMidnight(a.endAt, staff.business.timezone),
customerName: `${a.customer.firstName} ${a.customer.lastName}`,
serviceNames: a.services.map((s) => s.service.name).join(" + "),
status: STATUS_MAP[a.status] ?? "confirmed",
hasNotes: !!a.notes,
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
}));
const currentTimeMinutes =
date === today ? minutesFromMidnight(new Date(), staff.business.timezone) : null;
const columns: CalendarColumn[] = [
{
id: staff.id,
name: staff.user.name ?? "You",
avatarUrl: staff.user.image,
hoursLabel: workingWindow ? formatRange(workingWindow.startMinutes, workingWindow.endMinutes) : null,
wholeDayOff: false,
appointments: calendarAppointments,
blocks,
workingWindow,
},
];
// Desktop-only: a "Right Now" panel beside the calendar so a wide screen
// does more than center the mobile layout (see docs/phase-5-summary.md,
// "Responsive Design"). Only meaningful when viewing today.
const rightNow = date === today ? selectCurrentAppointment(appointments, new Date()) : null;
const unreadNotificationCount = await getUnreadNotificationCount(prisma, staff.user.id);
return (
<div className="pb-6">
<TopNav
title="Calendar"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
action={
<div className="flex gap-2">
<BlockTimeButton date={date} />
<Button asChild size="sm">
<Link href="/staff/book">
<Plus className="h-4 w-4" aria-hidden="true" />
New
</Link>
</Button>
</div>
}
/>
<DateStrip
selectedDate={date}
businessTimezone={staff.business.timezone}
basePath="/staff"
view={view}
/>
<div className="mt-4 px-6">
<ViewSwitch view={view} dayHref={`/staff?date=${date}&view=day`} agendaHref={`/staff?date=${date}&view=agenda`} />
</div>
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
<div className="lg:flex-1">
{wholeDayOff ? (
<EmptyState
icon={CalendarX2}
title="You're off this day"
description={wholeDayOff.reason ?? "Approved time off."}
/>
) : view === "day" ? (
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} />
) : calendarAppointments.length === 0 ? (
<div className="px-6 lg:px-0">
<EmptyState
icon={CalendarX2}
title="No appointments today"
description="Your schedule is clear for this day."
/>
</div>
) : (
<div className="space-y-4 px-6 lg:px-0">
{calendarAppointments.map((a) => (
<AppointmentCard
key={a.id}
status={a.status}
serviceName={a.serviceNames}
person={{ id: a.id, name: a.customerName }}
personLabel="for"
formattedTime={formatRange(a.startMinutes, a.endMinutes)}
hasNotes={a.hasNotes}
hasAvailableReward={a.hasAvailableReward}
actions={
<Link
href={`/staff/appointments/${a.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
}
/>
))}
</div>
)}
</div>
{/* Desktop-only "Right Now" panel — a wide screen shows the current
appointment alongside the calendar instead of requiring a
separate tab, since there's room for both at once. */}
<aside className="hidden shrink-0 lg:block lg:w-80">
<div className="rounded-md border-line sticky top-6 border bg-white p-5">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Right Now
</span>
{rightNow ? (
<div className="mt-3 space-y-3">
<AppointmentCard
status={STATUS_MAP[rightNow.status] ?? "confirmed"}
serviceName={rightNow.services.map((s) => s.service.name).join(" + ")}
person={{ id: rightNow.customerId, name: `${rightNow.customer.firstName} ${rightNow.customer.lastName}` }}
personLabel="for"
formattedTime={formatRange(
minutesFromMidnight(rightNow.startAt, staff.business.timezone),
minutesFromMidnight(rightNow.endAt, staff.business.timezone),
)}
/>
<Link
href={`/staff/appointments/${rightNow.id}`}
className="border-line block rounded-lg border px-4 py-2 text-center text-xs font-bold tracking-widest uppercase"
>
Open Details
</Link>
</div>
) : (
<p className="text-muted mt-3 text-sm">Nothing in progress or up next right now.</p>
)}
<Link
href="/staff/current"
className="text-accent mt-4 block text-center text-xs font-bold tracking-widest uppercase underline"
>
Full Current Appointment View
</Link>
</div>
</aside>
</div>
</div>
);
}
function formatRange(startMinutes: number, endMinutes: number): string {
const fmt = (m: number) => {
const h = Math.floor(m / 60);
const min = m % 60;
const period = h >= 12 ? "PM" : "AM";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(min).padStart(2, "0")} ${period}`;
};
return `${fmt(startMinutes)} – ${fmt(endMinutes)}`;
}
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listBreaks } from "@/services/staff/staff.service";
import { BreaksList } from "@/features/staff/components/breaks-list";
export const metadata: Metadata = { title: "Breaks" };
export const dynamic = "force-dynamic";
export default async function StaffBreaksPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const breaks = await listBreaks(prisma, staff.id);
return (
<div className="pb-12">
<TopNav title="Breaks" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<BreaksList
breaks={breaks.map((b) => ({
id: b.id,
label: b.label,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
days: b.days.map((d) => d.dayOfWeek),
}))}
/>
</main>
</div>
);
}
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding";
import { CalendarConnectionPanel } from "@/features/calendar/components/calendar-connection-panel";
export const metadata: Metadata = { title: "Google Calendar" };
export const dynamic = "force-dynamic";
export default async function StaffCalendarSettingsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/sign-in?callbackUrl=/staff/settings/calendar");
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staffProfile) redirect("/home");
const [connection, branding] = await Promise.all([
prisma.staffCalendarConnection.findUnique({ where: { staffId: staffProfile.id } }),
getBranding(),
]);
const connected = !!connection && !connection.revokedAt;
const configured = !!process.env.GOOGLE_CALENDAR_CLIENT_ID && !!process.env.GOOGLE_CALENDAR_CLIENT_SECRET;
return (
<div className="space-y-6 px-6 pt-12 pb-12">
<TopNav title="Google Calendar" onBack sticky className="bg-stone" />
<p className="text-muted text-sm">
One-way sync: {branding.name} stays the source of truth, and your confirmed appointments are pushed to
Google Calendar. Nothing is ever imported back from Google.
</p>
<CalendarConnectionPanel
configured={configured}
initial={
connected
? {
connected: true,
accountEmail: connection.accountEmail,
selectedCalendarName: connection.selectedCalendarName,
lastSyncAt: connection.lastSyncAt?.toISOString(),
lastSyncError: connection.lastSyncError,
}
: { connected: false }
}
/>
</div>
);
}
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { getNotificationPreferences } from "@/services/notifications/notification.service";
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
import { saveNotificationPreferencesAction } from "@/features/staff/actions/profile";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
export default async function StaffNotificationsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/sign-in?callbackUrl=/staff/settings/notifications");
const rows = await getNotificationPreferences(prisma, session.user.id);
const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({
channel,
isEnabled:
rows.find((r) => r.type === NotificationType.STAFF_APPOINTMENT_REMINDER && r.channel === channel)
?.isEnabled ?? true,
}));
return (
<div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<NotificationPreferencesForm initial={cells} action={saveNotificationPreferencesAction} />
<div className="rounded-md border-line border bg-white p-5">
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
<PushSubscribeToggle />
</div>
</main>
</div>
);
}
Loaded 100 of 690 files, more files were not shown because too many files have changed in this diff. Show more