Phase 8
This commit is contained in:
82 files changed
+4846
-230
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
generated
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
blob-report
|
||||||
|
playwright/.cache
|
||||||
|
coverage
|
||||||
|
e2e
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
+21
-1
@@ -26,7 +26,11 @@ EMAIL_SMTP_PORT="587"
|
|||||||
EMAIL_SMTP_USERNAME=""
|
EMAIL_SMTP_USERNAME=""
|
||||||
EMAIL_SMTP_PASSWORD=""
|
EMAIL_SMTP_PASSWORD=""
|
||||||
EMAIL_FROM_ADDRESS=""
|
EMAIL_FROM_ADDRESS=""
|
||||||
EMAIL_FROM_NAME="NextChair"
|
# EMAIL_FROM_NAME is optional and is only used when no business name can
|
||||||
|
# be resolved. Normal business emails use the installed business name
|
||||||
|
# automatically (see docs/phase-8-email-sender-summary.md) — you do not
|
||||||
|
# need to set or keep this in sync with Business Settings.
|
||||||
|
EMAIL_FROM_NAME=""
|
||||||
|
|
||||||
# Web Push (Phase 7) — generate with: npx web-push generate-vapid-keys
|
# Web Push (Phase 7) — generate with: npx web-push generate-vapid-keys
|
||||||
# NEXT_PUBLIC_VAPID_PUBLIC_KEY must be the same value as VAPID_PUBLIC_KEY —
|
# NEXT_PUBLIC_VAPID_PUBLIC_KEY must be the same value as VAPID_PUBLIC_KEY —
|
||||||
@@ -42,3 +46,19 @@ VAPID_SUBJECT="mailto:owner@example.com"
|
|||||||
# scheduler presents as `Authorization: Bearer <value>` to
|
# scheduler presents as `Authorization: Bearer <value>` to
|
||||||
# /api/cron/process-notifications. Generate with: openssl rand -hex 32
|
# /api/cron/process-notifications. Generate with: openssl rand -hex 32
|
||||||
CRON_SECRET=""
|
CRON_SECRET=""
|
||||||
|
|
||||||
|
# First-run installation wizard (Phase 8) — required before /install will
|
||||||
|
# create the first business/administrator. Generate with:
|
||||||
|
# openssl rand -hex 32
|
||||||
|
INSTALLATION_TOKEN=""
|
||||||
|
|
||||||
|
# Staff Google Calendar sync (Phase 8) — separate OAuth client from
|
||||||
|
# AUTH_GOOGLE_ID/SECRET above (different scopes; see
|
||||||
|
# src/services/calendar/google-calendar.provider.ts). Leave unset to keep
|
||||||
|
# calendar sync disabled — Settings shows a "not configured" state rather
|
||||||
|
# than crashing.
|
||||||
|
GOOGLE_CALENDAR_CLIENT_ID=""
|
||||||
|
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
||||||
|
GOOGLE_CALENDAR_REDIRECT_URI="http://localhost:3000/api/staff/calendar/google/callback"
|
||||||
|
# Encrypts stored refresh tokens at rest. Generate with: openssl rand -hex 32
|
||||||
|
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
||||||
@@ -47,3 +47,7 @@ yarn-error.log*
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
/generated/prisma
|
/generated/prisma
|
||||||
|
.env.example
|
||||||
|
.env.local.example
|
||||||
|
.env.example
|
||||||
|
.env.example
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
# 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 . .
|
||||||
|
# DATABASE_URL isn't needed to generate the client (no DB connection at
|
||||||
|
# generate time) — only real values needed at runtime, never baked in here.
|
||||||
|
RUN npx prisma generate
|
||||||
|
# `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_URL just to construct its
|
||||||
|
# connection-pool config object, even though nothing actually connects at
|
||||||
|
# build time. This placeholder only exists to satisfy that check; the
|
||||||
|
# real value is supplied at container runtime via env_file (see
|
||||||
|
# docker-compose.local.yaml) and this one is never used to connect to
|
||||||
|
# anything or baked into a layer that matters.
|
||||||
|
ENV DATABASE_URL="mysql://build:build@localhost:3306/build"
|
||||||
|
RUN 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/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
|
||||||
|
|
||||||
|
USER nextchair
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["./docker/entrypoint.sh"]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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"
|
||||||
|
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
|
||||||
|
|
||||||
|
networks:
|
||||||
|
next-chair:
|
||||||
|
name: next-chair
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
echo "[entrypoint] Applying database migrations..."
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
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}"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Phase 8 Follow-Up — Business Name as Email Sender: Summary
|
||||||
|
|
||||||
|
A focused fix on top of Phase 8: every outgoing email's visible sender **name** now comes from the installed business's own name, not `EMAIL_FROM_NAME` or the internal product name. The sender **email address** stays exactly as it was — environment-configured, since it has to match whatever address/domain is actually verified with the SMTP relay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where the business name is resolved
|
||||||
|
|
||||||
|
In `src/services/notifications/render-email.ts`'s `renderEmailForNotification()` — the one function that turns a stored `Notification` row into an actual email, already responsible for loading the business (for the existing name/logo/address/policy fields every template uses). It now also does:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const business = await prisma.business.findUnique({ where: { id: notification.businessId }, include: {...} });
|
||||||
|
const sender: EmailSender = { name: resolveSenderName(business.name) };
|
||||||
|
```
|
||||||
|
|
||||||
|
`notification.businessId` is a required, non-nullable column (`prisma/schema.prisma`) — every `Notification` in this schema is already business-scoped, so this is a `findUnique` on the notification's own explicit relationship, never a global `findFirst()`. Verified directly with an integration test that creates two businesses and confirms a notification scoped to business A never resolves business B's name, even though B exists in the same database (`sender-identity.integration.test.ts`, "never lets one business's notification resolve another business's name").
|
||||||
|
|
||||||
|
This lookup happens **fresh, every time** — `renderEmailForNotification` is called by the background processor right before a `PENDING` delivery is actually sent (`reminder-worker.ts`), never at notification-creation time. That was already true before this change (see that file's own header, written in Phase 7); this change just rides the same guarantee for the sender name. No caching beyond the implicit request-scoped Prisma round trip — nothing is cached across requests or worker runs, so a business rename takes effect on the very next email sent, with no restart and no environment-variable change. Verified directly: `sender-identity.integration.test.ts`'s "uses the updated business name for the very next email after a rename" test sends one email, renames the business via a plain `prisma.business.update()` (the same call `BusinessService.updateBusinessProfile` makes from Owner Settings), sends a second email in the same process, and asserts the second one uses the new name.
|
||||||
|
|
||||||
|
## How it reaches the Brevo provider
|
||||||
|
|
||||||
|
`renderEmailForNotification()` now returns `RenderedEmailWithSender` (`{ subject, html, text, sender }`) instead of the old `RenderedEmail`. `reminder-worker.ts`'s `processEmailDelivery()` — the **only** call site of `EmailProvider.sendEmail()` in the entire codebase (confirmed by grep before making this change) — passes `sender: email.sender` straight through:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const email = await renderEmailForNotification(prisma, delivery.notification);
|
||||||
|
const sendResult = await emailProvider.sendEmail({
|
||||||
|
to: { email: recipientEmail },
|
||||||
|
sender: email.sender,
|
||||||
|
subject: email.subject,
|
||||||
|
html: email.html,
|
||||||
|
text: email.text,
|
||||||
|
idempotencyKey: delivery.id,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`SendEmailParams` (`src/services/notifications/providers/email-provider.ts`) gained a required `sender: EmailSender` field (`{ name: string; email?: string }`). `BrevoEmailProvider.sendEmail()` uses `params.sender.name` for the SMTP `from` display name, and **always** its own `this.fromAddress` (from `BrevoEmailProviderConfig`, env-configured) for the address — `params.sender.email` is accepted in the shape for forward-compatibility with a future provider that might need it, but Brevo's own implementation ignores it, documented inline and covered by a test (`brevo-email-provider.test.ts`, "sends with the per-message sender name and the provider's own configured address, ignoring any sender.email"). `BrevoEmailProviderConfig` itself dropped `fromName` entirely — it now only carries `host`/`port`/`username`/`password`/`fromAddress`, i.e. only static, non-business-derived provider settings, per the architecture this was explicitly asked to follow. `loadBrevoEmailProviderConfigFromEnv()` no longer reads `EMAIL_FROM_NAME` at all — that env var is read exactly once, inside `resolveSenderName()` (see below), not at provider-construction time.
|
||||||
|
|
||||||
|
`ConsoleEmailProvider` (the dev default) also prints the resolved sender name (`From: {name}`) so local development shows exactly what would be sent — this is what the manual verification below used.
|
||||||
|
|
||||||
|
## The sender-name fallback order
|
||||||
|
|
||||||
|
`src/services/notifications/sender-identity.ts` — a small, pure, DB-free function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function resolveSenderName(businessName: string | null | undefined): string {
|
||||||
|
const trimmedBusinessName = businessName?.trim();
|
||||||
|
if (trimmedBusinessName) return trimmedBusinessName;
|
||||||
|
|
||||||
|
const envFallback = process.env.EMAIL_FROM_NAME?.trim();
|
||||||
|
if (envFallback) return envFallback;
|
||||||
|
|
||||||
|
return "Booking Notifications";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **The installed business's own name** — the normal, always-taken path for a real installed application (every `Notification` is business-scoped, and `Business.name` is a required, non-empty column populated by the installer/seed).
|
||||||
|
2. **`EMAIL_FROM_NAME`**, only reached if tier 1 somehow resolves to nothing (a blank business name, or, in principle, a future notification type with no business association at all — no such type exists in this codebase today). Never required to be kept in sync with the business name.
|
||||||
|
3. **`"Booking Notifications"`** — a neutral, non-product name, reached only if neither of the above is set. `NextChair` never appears anywhere in this function.
|
||||||
|
|
||||||
|
Unit-tested directly (`sender-identity.test.ts`): business name wins even when `EMAIL_FROM_NAME` is also set; `EMAIL_FROM_NAME` is used only when the business name is null/undefined/empty/whitespace; the neutral fallback is used when neither is available; and a regression guard asserting the function never returns `"NextChair"` at any tier.
|
||||||
|
|
||||||
|
## Why the sender email stays environment-configured
|
||||||
|
|
||||||
|
Brevo (and SMTP relays generally) require the sending address to belong to a domain/address actually verified with the account — an arbitrary per-message address would either be silently rewritten by the relay or rejected outright. The business name has no such constraint (any string is a valid display name), so it was safe to make per-message and business-derived; the address was not, and stays exactly where it was before this change — a single environment variable (`EMAIL_FROM_ADDRESS`) loaded once into `BrevoEmailProviderConfig`. `.env.example` and `.env.local.example` were updated to make this explicit for `EMAIL_FROM_NAME` specifically (now documented as "optional... only used when no business name can be resolved... you do not need to set or keep this in sync with Business Settings"); `EMAIL_FROM_ADDRESS`'s own existing documentation was untouched, since its behaviour didn't change.
|
||||||
|
|
||||||
|
## Delivery-record snapshotting
|
||||||
|
|
||||||
|
**Not snapshotted.** `NotificationDelivery` gained no new column for this. This follows the existing, explicit design philosophy stated in `render-email.ts`'s own header (written in Phase 7, unchanged by this follow-up): never store rendered content on the row, always re-derive it from current data at send time. Adding a `senderNameUsed` column would cut against that stated design for a "where useful" (the brief's own wording — optional) requirement, and — because `Business` itself has no history/versioning table — a snapshot column would only be reliable going forward, not retroactively reconstructable for anything sent before it existed. `providerMessageId`/`providerName` already exist on `NotificationDelivery` for send-forensics; reconstructing "what name did we actually show" for a historical delivery would require resolving what the business's name was at `sentAt`, which this app cannot do today (documented here as a known limitation, not fixed, since it wasn't asked for and would need a real audit/versioning mechanism to do properly).
|
||||||
|
|
||||||
|
## Background notification processing
|
||||||
|
|
||||||
|
No change was needed to `processDueNotificationDeliveries`'s own structure — it already calls `renderEmailForNotification()` fresh for every delivery it processes, so the "resolve current business identity when rendering/sending, don't assume creation-time branding is still current" requirement was already satisfied by the existing Phase 7 architecture. This follow-up only added the sender-name resolution to that same, already-correct call. Verified directly: `sender-identity.integration.test.ts`'s "passes the resolved business name all the way through the background processor to the email provider" test goes through the real `processDueNotificationDeliveries()` entry point (not just `renderEmailForNotification()` directly) and asserts the `MockEmailProvider` captured the correct sender name.
|
||||||
|
|
||||||
|
## Email types covered
|
||||||
|
|
||||||
|
Every email type is covered by construction, not type-by-type special-casing: `renderEmailForNotification()` is the **only** path to an outgoing email (confirmed: `EmailProvider.sendEmail()` has exactly one call site, `reminder-worker.ts`), and it resolves and attaches the sender identically regardless of which `NotificationType` it's rendering or which template (a specific booking/loyalty template, or the generic fallback template) ends up producing the subject/html/text. This includes `STAFF_INVITATION_RECEIVED` (currently rendered via the generic fallback template — the specialized `renderStaffInvitationEmail`/`renderStaffScheduleChangedEmail` functions in `templates/staff-emails.ts` are not wired into `renderEmailForNotification`'s dispatch switch, a pre-existing gap from Phase 7 left untouched here since it's out of this follow-up's scope), which is why a dedicated integration test exists for it specifically (`sender-identity.integration.test.ts`, "resolves the business name for a staff-invitation notification").
|
||||||
|
|
||||||
|
## Tests added
|
||||||
|
|
||||||
|
**Unit** (`src/services/notifications/__tests__/sender-identity.test.ts`, 6 tests): business name used when present; business name wins over `EMAIL_FROM_NAME`; `EMAIL_FROM_NAME` used only when no business name; neutral fallback used when neither is set (including whitespace-only business names); `NextChair` never returned at any tier.
|
||||||
|
|
||||||
|
**Unit, updated** (`brevo-email-provider.test.ts`): existing tests updated for the new `sender` field; one new test asserts the provider uses the per-message sender name and its own configured address, explicitly ignoring `sender.email`.
|
||||||
|
|
||||||
|
**Integration, real MySQL** (`src/services/notifications/__tests__/sender-identity.integration.test.ts`, 5 tests): a booking/loyalty-style notification resolves the installed business's name; a staff-invitation notification resolves it too (via the generic template); the full background-processor path (`processDueNotificationDeliveries`) passes the resolved name through to the email provider; renaming the business mid-process changes the very next email's sender with no restart; one business's notification never resolves another business's name even when both exist in the same database.
|
||||||
|
|
||||||
|
Full regression run after these changes: 236 unit tests (was 230), 163 integration tests (was 158) — all passing, zero new failures.
|
||||||
|
|
||||||
|
## Manual verification
|
||||||
|
|
||||||
|
Ran directly against the real seeded local MySQL database (not a scripted mock), using the actual `ConsoleEmailProvider` the app selects when `EMAIL_PROVIDER=console` (this environment's local `.env` setting) — deliberately with `.env`'s `EMAIL_FROM_NAME` left at its old, stale, misconfigured value (`"NextChair"`) specifically to prove that value is no longer used as the primary source:
|
||||||
|
|
||||||
|
- Every real, seeded notification (loyalty stamp, staff new-appointment, owner new-booking) rendered and "sent" through the actual `processDueNotificationDeliveries` → `renderEmailForNotification` → `ConsoleEmailProvider` pipeline showed `From: Blake Heritage Barbershop` — never `From: NextChair`, despite the misconfigured env var.
|
||||||
|
- Renamed the business directly via `prisma.business.update()` (the same underlying call Owner Settings' "Business Details" form makes) to `"Verify Rename Barbershop"`, in the same running process, no restart.
|
||||||
|
- The very next notification sent immediately afterward showed `From: Verify Rename Barbershop` — confirming no caching, no restart required, no environment-variable change required.
|
||||||
|
- Reverted the business name back to its original value afterward, leaving the seeded database as found.
|
||||||
|
|
||||||
|
Not independently re-verified this specific change through a full Docker Compose install cycle: `docker-compose.local.yaml`/`Dockerfile` were not touched by this follow-up, the email-sending code path is identical in a container (same source, same `ConsoleEmailProvider`/`BrevoEmailProvider` classes), and Phase 8's own summary already recorded a full, real Docker Compose lifecycle verification (build → empty volume → install → seed → restart-persists → teardown). Re-running that entire cycle here would not exercise anything Docker-specific about this particular change; the sender-name behaviour itself was verified against the real database and real (non-mocked, for the "would send" log) code path instead, which is the part that's actually new.
|
||||||
|
|
||||||
|
Not verified (unchanged from Phase 8's own "Unverified external-provider behaviour" section, still true here): a real Brevo SMTP send with the new sender-name behaviour — no live Brevo credentials were available in this environment. `BrevoEmailProvider`'s exact `nodemailer.sendMail({ from: { address, name } })` call is unit-tested (mocked transporter, asserting the correct `from` object is constructed), which is the same level of provider-boundary confidence this whole project has used for Brevo throughout every prior phase.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# Phase 8 — Installation Wizard, White-Label Branding, Guest-Booking Defaults, Google Calendar Sync and Local Docker Deployment: Summary
|
||||||
|
|
||||||
|
A focused post-Phase-7 pass making the application deployable as a real, white-label product for a single business: a secure first-run installation wizard replaces seeded demo data as the production bootstrap path, every customer/staff/owner-facing surface now reflects the installed business's own name and branding instead of the internal "NextChair" product name, guest booking defaults off, staff can sync their own appointments to their personal Google Calendar, and a complete `docker-compose.local.yaml` stack brings the whole thing up on Docker Desktop with one command. No broad rewrite and no repeat of Phase 7's audits — every change here is additive on top of the existing domain services (BusinessService, BookingRule, StaffService, the notification/PWA infrastructure), which remain the authoritative logic for everything they already owned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Installation-state architecture
|
||||||
|
|
||||||
|
A single `ApplicationInstallation` table (`prisma/schema.prisma`) is the one source of truth for "has this deployment been set up yet" — never inferred from "does any table have rows." It uses a **fixed, well-known id (`"singleton"`)** rather than a separate lock table: the installer's transaction opens with `tx.applicationInstallation.create({ id: "singleton", status: INSTALLING })` as its very first statement (`src/features/installation/installer.service.ts`). MySQL's row-level locking on that primary-key insert is the entire concurrency story — a second concurrent install attempt blocks on the same insert and then fails with a unique-constraint violation once the first transaction commits, or proceeds cleanly if the first one instead rolls back. This was verified directly, not assumed: an integration test fires two concurrent `installApplication()` calls and asserts exactly one business is ever created (`src/features/installation/__tests__/installer.service.integration.test.ts`).
|
||||||
|
|
||||||
|
Two read paths, deliberately different for different callers:
|
||||||
|
- `getInstallationState()` (`src/lib/installation/installation-state.ts`) — React `cache()`-deduped, used by Server Components (`/install`, the auth layout, etc.).
|
||||||
|
- `isInstalledForRequest()` — used only by `src/proxy.ts` and the notification cron route. Once a process observes `INSTALLED`, it caches that fact in a module-level variable for the rest of that process's lifetime, so the routing gate that runs on *every single request* doesn't do a fresh database round-trip forever after a one-time, one-way transition. Before installation (the only window where re-checking actually matters), every request still does the real check.
|
||||||
|
|
||||||
|
## 2. Installation security
|
||||||
|
|
||||||
|
- **Token**: `INSTALLATION_TOKEN` env var, required before setup can begin. Checked twice — once by a cheap, rate-limited `verifyInstallationTokenAction` the wizard's first step calls for immediate feedback, and again, unconditionally, as the very first thing `installApplication()` does before touching the database — so a client that skips straight to final submission without ever calling the first check is still rejected. Comparison is via `secretsMatch()` (`src/lib/security/secrets-match.ts`, extracted this phase and now shared with the cron route's own secret check), using Node's `timingSafeEqual`. Neither check reveals *why* a token was wrong. If `INSTALLATION_TOKEN` isn't set at all, `/install` shows a plain "not configured" message rather than silently allowing setup with no gate.
|
||||||
|
- **Rate limiting**: both the token-verify action and the final submission action are rate-limited (`checkRateLimit`, 10 attempts/15 min, keyed by IP) — the same primitive Phase 7 built for sign-in/registration/guest-booking abuse.
|
||||||
|
- **No token persistence**: the raw token is never stored or logged anywhere; it's compared and discarded.
|
||||||
|
|
||||||
|
## 3. Installation wizard steps
|
||||||
|
|
||||||
|
Nine screens (`src/features/installation/components/install-wizard.tsx`): Welcome (token), Administrator, Business Details, Location & Contact (UK defaults — `Europe/London` / `GBP` / `en-GB` — editable), Opening Hours (reuses the same day-by-day UI pattern as the owner's existing hours editor, plus "copy Monday to weekdays/all days"), Booking Settings (guest booking **off** by default, with the brief's exact recommended copy), Branding (optional logo/cover/theme/background — plain URL fields, matching how Business Details already handles `logoUrl` everywhere else in the app), Starter Services (a small editable/removable prefilled set — Classic Haircut / Beard Trim / Haircut & Beard Combo across three categories — the installer must explicitly reach Review to submit, so nothing is silently created), and Review (a no-secrets summary with explicit "Confirm & Install" confirmation).
|
||||||
|
|
||||||
|
State lives entirely in the wizard's own React state, not a signed draft cookie — a deliberate simplification, documented inline in the component: nothing is validated or written to the database until the single final submission, so a page refresh mid-wizard just means starting over, which is an acceptable trade-off for a page one person visits once per deployment. This also means "no partial production records after every step" is true by construction, not by extra bookkeeping.
|
||||||
|
|
||||||
|
## 4. Initial administrator creation
|
||||||
|
|
||||||
|
Created inside the installer's own transaction: `User` (bcrypt-hashed password, same cost factor as registration) + `StaffProfile` bound to a freshly-created **Owner** role holding the full `OWNER_PERMISSIONS` set. No separate "super-admin" authentication system — the brief was explicit that this isn't a platform-wide concept, just the first, fully-permissioned owner in this app's existing Role/Permission model. `OWNER_PERMISSIONS`/`BARBER_PERMISSIONS`/`syncRolePermissions` were extracted this phase from `prisma/seed.ts` into `src/lib/roles/default-permissions.ts` so the installer and the dev seed script share one definition — a permission added to one can no longer silently miss the other, closing a real drift risk the two paths would otherwise have had. No `isInitialAdministrator` flag was added: the existing last-owner protection (any role holding `MANAGE_STAFF`) already covers "protect the last owner from self-demotion/removal" without a parallel authorization concept.
|
||||||
|
|
||||||
|
## 5. Initial business creation
|
||||||
|
|
||||||
|
One transaction creates `Business` (with all the new branding columns — see §13), `BusinessLocation` (primary), `BusinessSettings`, `BusinessHours` (all 7 days), `BookingRule` (guest booking off unless the installer explicitly enabled it), and a `LoyaltyProgramme` row with `isEnabled: false` — loyalty is never silently turned on. A collision-safe slug is generated from the business name plus a base-36 timestamp suffix.
|
||||||
|
|
||||||
|
## 6. Starter service approach
|
||||||
|
|
||||||
|
Option B from the brief: a small, clearly-editable starter set (3 services across 3 categories), not an empty catalogue and not a large invented one. The installer explicitly reviews (and can remove or add to) the set before Review; only entries with a non-empty name at submission time are created. `ServiceCategory` rows are created once per distinct category name in submission order, then one `Service` row per entry, then `StaffService` eligibility rows link the new owner to every created service — matching the brief's "initial owner is service-eligible," verified by an integration test.
|
||||||
|
|
||||||
|
## 7. Installation transaction behaviour
|
||||||
|
|
||||||
|
Everything in §4–§6 happens inside **one** Prisma interactive transaction, alongside the `AuditLog` entry (`APPLICATION_INSTALLED`) and the final `ApplicationInstallation` update to `INSTALLED`. Any failure anywhere in that transaction rolls back atomically — including the singleton lock row itself, since it was created in the same transaction — so a failed install leaves the database exactly as it was before, not a stuck `INSTALLING`/`FAILED` row, and a retry sees "not installed" again cleanly. Verified directly with an integration test that submits a wrong token and asserts no `Business` row and no `ApplicationInstallation` row exist afterward.
|
||||||
|
|
||||||
|
## 8. Guest-booking default and toggle
|
||||||
|
|
||||||
|
`BookingRule.allowGuestBooking`'s schema default changed from `true` to `false` this phase (a genuine, intentional breaking change to the column's default — existing rows are untouched, only future `bookingRule: { create: {} }` calls with no explicit value are affected). The dev/demo seed (`prisma/seed.ts`) explicitly opts back in (`allowGuestBooking: true`) so the existing guest-booking E2E journey and manual testing keep working — documented inline as a deliberate seed-vs-production divergence. The existing owner-facing toggle (`src/features/owner/components/booking-rules-form.tsx`, already built in Phase 6) needed no new field — only its label/description text was updated to match the brief's exact recommended copy ("Allow customers to book as guests" / the two-sentence explanation).
|
||||||
|
|
||||||
|
## 9. Guest-booking enforcement
|
||||||
|
|
||||||
|
Enforced at the one place that actually matters — `confirmBookingAction` (`src/features/booking/actions/confirm.ts`), the only Server Action that creates an `Appointment` row — re-checking `BookingRule.allowGuestBooking` from the database immediately before resolving a guest customer, regardless of how the request arrived (the UI gate, a stale draft cookie started before the owner disabled guest booking mid-flow, or a direct Server Action call). The same check is duplicated one step earlier in `saveDetailsStepAction` (`src/features/booking/actions/draft.ts`) purely for a better error message at the right step, but `confirm.ts`'s check is the authoritative one. The UI side: `/book/details` (`src/app/book/details/page.tsx`) shows the brief's exact "Sign in to complete your booking" copy with Sign In / Create Account actions — never "Continue as Guest" — when an unauthenticated visitor reaches that step with guest booking disabled; browsing, service selection, and barber/time selection remain guest-accessible either way, since the gate only applies from the details step onward.
|
||||||
|
|
||||||
|
Booking-draft continuity across authentication: `callbackUrl` now flows end-to-end — Sign In and Create Account links from the guest gate carry `?callbackUrl=/book/details`, `SignInForm`/`RegisterForm` read and forward it (`registerAction` previously hardcoded `redirectTo: "/home"`; it now accepts and validates a caller-supplied path), and the existing signed booking-draft cookie (unrelated to auth) already survives the round trip through `/sign-in`/`/register` since it's just an httpOnly cookie, not session state. **Verified live in a real browser, not just by reading the code**: signed out → browse → select a slot → hit the guest gate at `/book/details` → Sign In → land back on `/book/details`, now rendering the authenticated details form with the same in-progress booking.
|
||||||
|
|
||||||
|
All caller-supplied redirect targets are validated by a new `safeRedirectPath()` helper (`src/lib/utils/safe-redirect.ts` — must start with exactly one `/`, reject `//host`, reject any `://`) before ever reaching a redirect — covered by unit tests for the protocol-relative and absolute-URL open-redirect cases specifically.
|
||||||
|
|
||||||
|
## 10. Existing guest-appointment behaviour
|
||||||
|
|
||||||
|
Unaffected. `GuestAppointmentAccessToken` lookups (`/appointments/[id]?token=...`), viewing, rescheduling, and cancelling an existing guest appointment never consult `allowGuestBooking` — that flag only gates the creation of *new* appointments. No guest `CustomerProfile` rows were touched, deleted, or converted.
|
||||||
|
|
||||||
|
## 11. Welcome routing
|
||||||
|
|
||||||
|
`/` (`src/app/page.tsx`) already served a Welcome page to unauthenticated visitors before this phase; what changed is everything else. It now: renders the business's real logo/name (falling back to a neutral mark, never "NextChair") via `getBranding()`; shows "Continue as Guest" only when `bookingRule.allowGuestBooking` is true; always offers "Browse Services" (linking to `/home`, which has allowed guest browsing since Phase 4); and redirects a signed-in visitor by role rather than always to `/home`.
|
||||||
|
|
||||||
|
## 12. Role-based root routing
|
||||||
|
|
||||||
|
Authenticated visitors to `/` are routed by looking up their `StaffProfile` and, if one exists, checking `MANAGE_STAFF`: **owner → `/owner`**, **barber → `/staff`**, **no staff profile (a customer) → `/home`** — implemented directly in the Welcome page component rather than a separate redirect table, reusing `hasPermission()` (Phase 3) and the same `staffProfile.findUnique` pattern the `(staff)`/`(owner)` layouts already use. Owners keep the existing "Switch to Barber Tools" link in their own nav (built in Phase 6) as the documented way back to `/staff` — no new mechanism needed.
|
||||||
|
|
||||||
|
## 13. White-label branding changes
|
||||||
|
|
||||||
|
**Schema** (see §22): `Business` gained `shortName`, `coverImageUrl`, `faviconUrl`, `appIconUrl`, `themeColor`, `backgroundColor` — `logoUrl` already existed and needed no change.
|
||||||
|
|
||||||
|
**Resolution**: `src/lib/business/branding.ts` — `getBranding()`, a `cache()`-wrapped, always-non-throwing read (`prisma.business.findFirst({ orderBy: { createdAt: "asc" } })`, wrapped in try/catch) returning either the real business's identity or a neutral, no-product-name fallback (`{ name: "Book an Appointment", shortName: "Booking", ... }`). The try/catch matters for a reason beyond tidiness: it's what makes `next build`'s static-page prerendering not crash when no live database is reachable at build time (discovered, not assumed — see §34).
|
||||||
|
|
||||||
|
**Applied to**: the root layout's dynamic `generateMetadata`/`generateViewport` (title template `"%s | {business name}"`, replacing the old `"%s — NextChair"`; theme-color from `themeColor`); the PWA manifest (`src/app/manifest.ts`, now `async` and business-driven); the Welcome page; all four auth pages (logo in the shared `(auth)` layout, "Welcome back to {business}" / "Join {business}" headings); the staff/owner sidebar nav (`StaffShell` now fetches branding itself once, passing `businessName`/`logoUrl` down to the client-side `StaffSidebarNav`, so both `(staff)` and `(owner)` get it from one call site); the offline fallback page; the ICS calendar file's `PRODID` (now `-//Booking Calendar//EN`, no product name) and its business-name fallback; staff-invitation emails (already business-branded from Phase 7 — only the leftover "on NextChair" phrase was removed); the Brevo SMTP `fromName` env-var fallback (`"NextChair"` → `"Booking Notifications"`, documented as env-configured rather than DB-derived, since that config loader has no database access — see §35); the push-notification title fallback in both `reminder-worker.ts` (now resolves via `getBranding()`) and the service worker's own defensive fallback (`public/sw.js`, changed to neutral `"New notification"` — the SW is a static asset with no per-request rendering, so it can't be made dynamically business-branded itself, only its *fallback* text was fixed); and the owner's staff-invitation acceptance form.
|
||||||
|
|
||||||
|
## 14. Remaining internal product-name occurrences
|
||||||
|
|
||||||
|
A full-repo grep for `NextChair` and `TrimTime` after this phase's changes turns up only: code comments explaining the white-labeling rules themselves; the `X-NextChair-Idempotency-Key` HTTP header name in the Brevo provider (a technical/internal header, never rendered to a user — left as-is, matching the brief's own "do not blindly replace... technical identifiers" guidance); `src/app/style-guide/page.tsx`'s demo prop value `businessName="TrimTime"` (a dev-only design-system reference page, not a real user flow — template/reference-only); and this repository's own name, Docker image/container/network names, package name, and documentation — all explicitly "internal and acceptable" per the brief.
|
||||||
|
|
||||||
|
## 15. PWA branding and manifest behaviour
|
||||||
|
|
||||||
|
`src/app/manifest.ts` is now `async`, reading `name`/`short_name`/`description`/`background_color`/`theme_color` from `getBranding()`. `icons` deliberately stays pointed at the app's own pre-generated, correctly-sized/maskable icon set (192/512, standard and maskable) rather than trying to derive those from a business's single `appIconUrl` — there's no image-processing pipeline in this app to produce four correctly-sized variants from one arbitrary uploaded URL, so this is fallback rule #2 ("business name + a neutral generated icon"), chosen deliberately over risking a broken or non-square install icon (see §35).
|
||||||
|
|
||||||
|
## 16. PWA install-prompt changes
|
||||||
|
|
||||||
|
The `beforeinstallprompt` capture, standalone-mode detection, and iOS detection logic (previously duplicated inline in `InstallAppButton`) was extracted into a shared hook, `src/features/pwa/hooks/use-install-prompt.ts` — now the **one** PWA install implementation, per the brief's explicit "do not create a competing second installer." `InstallAppButton` (Settings, all three roles) now takes a `businessName` prop instead of hardcoding "NextChair is installed on this device." A new `WelcomeInstallCard` (`src/features/pwa/components/welcome-install-card.tsx`) uses the same hook to render a prominent, non-blocking inline card on the Welcome page — "Add {Business Name} to your Home Screen" — below the primary Sign In/Create Account/Continue as Guest/Browse Services actions, so it's visible immediately without ever gating them or opening as an unsolicited modal.
|
||||||
|
|
||||||
|
## 17. iOS installation behaviour
|
||||||
|
|
||||||
|
Since iOS Safari never fires `beforeinstallprompt`, both `InstallAppButton` and `WelcomeInstallCard` detect iOS via user-agent sniffing and show the brief's exact three-step instructional text ("Tap the Share button in Safari… Add to Home Screen… Tap Add") instead of claiming a native prompt exists. Instructions are gated behind a click (not shown automatically) and never appear once the app is already running standalone.
|
||||||
|
|
||||||
|
## 18. Dismissal
|
||||||
|
|
||||||
|
`WelcomeInstallCard` stores a dismissal timestamp in `localStorage` (`nextchair:install-card-dismissed-until`) with a **30-day** re-prompt interval — chosen (and documented inline) specifically because this card is one of *two* places to install (Settings always has the button too, with no dismissal at all), so a longer, less naggy interval is appropriate for the more prominent, unsolicited-feeling surface. Detection of `window`/`localStorage` happens inside `useEffect` via the same async-chain pattern Phase 7 established for `push-subscribe-toggle.tsx` (never a `useState` lazy initializer reading browser APIs — that would run during SSR and mismatch the client's first render), which also satisfies the `react-hooks/set-state-in-effect` lint rule.
|
||||||
|
|
||||||
|
## 19. Google Calendar architecture
|
||||||
|
|
||||||
|
`src/services/calendar/`:
|
||||||
|
- `types.ts` — provider-neutral shapes (`CalendarEventInput`, `RemoteCalendarInfo`, `ExchangedTokens`, and a typed `CalendarProviderError` with a closed `code` union).
|
||||||
|
- `calendar-provider.ts` — the `CalendarProvider` interface (`buildAuthorizationUrl`, `exchangeCodeForTokens`, `refreshAccessToken`, `listWritableCalendars`, `createEvent`/`updateEvent`/`deleteEvent`, `revoke`).
|
||||||
|
- `google-calendar.provider.ts` — the only implementation today, built directly against Google's OAuth 2.0 and Calendar API v3 REST endpoints via `fetch` (no `googleapis` SDK dependency — the same "build against the documented REST contract, don't guess a provider-specific shape" discipline Phase 7 applied to Brevo). Minimum scopes: `calendar.events` + `calendar.readonly` + `email` — never the full `calendar` scope, which would also grant calendar-*settings* management this app never needs.
|
||||||
|
- `mock-calendar.provider.ts` — an in-memory `CalendarProvider` for tests, recording every create/update/delete with no network call at all — see §33 for why, and §26 for the small refactor that made it actually injectable.
|
||||||
|
- `calendar-sync.service.ts` — the policy layer (see §21).
|
||||||
|
|
||||||
|
No appointment Server Action calls Google's API directly — every one goes through `syncAppointmentSafely()`/`runInitialSync()`, which internally resolve a `CalendarProvider` and never leak provider-specific detail upward.
|
||||||
|
|
||||||
|
## 20. OAuth and encrypted token storage
|
||||||
|
|
||||||
|
Two new routes: `GET /api/staff/calendar/google/authorize` and `GET /api/staff/calendar/google/callback`. `state` is a signed, staff-scoped, 10-minute-lived token (`src/lib/security/oauth-state.ts` — HMAC-SHA256 over JSON, its own signing context/key derivation, deliberately separate from `booking-draft/sign.ts`'s so the two never share cryptographic material), set as an httpOnly cookie at authorize time and re-verified against both the returned `state` query param *and* the currently signed-in staff member's id at callback time — CSRF and replay protection without any server-side session-storage table. A missing/incorrect `Google Calendar` env configuration fails closed with a friendly redirect (both from the route and from the Settings UI, which hides the Connect button entirely when `GOOGLE_CALENDAR_CLIENT_ID`/`SECRET` aren't set), never a 500. Refresh tokens (and access tokens, while valid) are encrypted at rest with AES-256-GCM (`src/lib/security/token-encryption.ts` — `CALENDAR_TOKEN_ENCRYPTION_KEY`, SHA-256'd into a 32-byte AES key so any input length still yields a valid key; output is `base64(iv + authTag + ciphertext)` in one column). Verified with unit tests: round-trip correctness, a fresh IV/ciphertext on every call for the same plaintext, and a hard failure on a tampered ciphertext (GCM's authentication tag catches it).
|
||||||
|
|
||||||
|
## 21. Calendar sync policy
|
||||||
|
|
||||||
|
One-way only (business → Google), enforced by construction: nothing in this codebase ever reads a Google Calendar event back into an `Appointment`. Status mapping, decided once in `calendar-sync.service.ts` and nowhere else:
|
||||||
|
- **PENDING** — never synced (an unapproved booking shouldn't occupy a barber's personal calendar until approved).
|
||||||
|
- **CONFIRMED / CHECKED_IN / IN_PROGRESS / COMPLETED** — event created or updated.
|
||||||
|
- **CANCELLED** — the remote event is deleted outright and the local `AppointmentCalendarEvent` row marked `CANCELLED`.
|
||||||
|
- **NO_SHOW** — retained, with the event description updated to note the outcome, rather than removed — the barber's calendar should still reflect they were scheduled to work that slot.
|
||||||
|
|
||||||
|
Event content: customer name, service name(s), business name and primary address, an appointment reference, and a link back to `/staff/appointments/{id}` — no internal customer notes, no unnecessary contact detail, `visibility: "private"` set explicitly on every Google event.
|
||||||
|
|
||||||
|
## 22. Retry and duplicate prevention
|
||||||
|
|
||||||
|
`AppointmentCalendarEvent` has a `@@unique([appointmentId, connectionId])` constraint — the actual mechanism that makes sync idempotent: an existing row means "update the same remote event," not "create a new one," verified directly by an integration test that calls `syncAppointmentSafely()` twice on the same appointment and asserts exactly one row and one mock-provider event exist afterward. A **reassignment** (appointment's `staffId` changes to a different connected staff member) is handled by scanning for any non-cancelled `AppointmentCalendarEvent` rows belonging to a *different* staff member's connection than the appointment's current one, syncing those through the `CANCELLED` path (deleting their remote event) before syncing the new staff member's connection — verified with a dedicated integration test asserting both the old connection's event is cancelled and the new connection gets a fresh `SYNCED` one. Every sync call is wrapped so a Google failure (rate limit, outage, expired token, etc.) is caught, mapped to a typed `CalendarProviderError`, and recorded on the `StaffCalendarConnection`/`AppointmentCalendarEvent` rows for the Settings page to surface — **never thrown back to the caller**, so a Google Calendar outage can never roll back or block the appointment mutation that triggered it. Sync triggers are exactly the brief's list — confirmation/approval, reschedule, reassignment, cancellation, no-show — deliberately *not* check-in/start/complete (rapid, same-day, low-value-to-sync transitions the brief's own trigger list excludes).
|
||||||
|
|
||||||
|
## 23. Schema changes
|
||||||
|
|
||||||
|
New models: `ApplicationInstallation`, `StaffCalendarConnection`, `AppointmentCalendarEvent`. New enums: `InstallationStatus`, `CalendarProvider`, `CalendarSyncStatus`. `Business` gained six branding columns (§13). `BookingRule.allowGuestBooking`'s default flipped `true` → `false`. One migration: `20260804070345_phase8_installation_branding_calendar` — its `CREATE TABLE` statements initially came out with `utf8mb4_unicode_ci` collation (Prisma's own default when it can't detect the connected database's collation at generation time) instead of this database's actual `utf8mb4_0900_ai_ci`, which broke the foreign keys to `StaffProfile`/`Appointment` (MySQL requires matching collations across an FK relationship) — caught immediately when the migration failed to apply, not silently shipped; fixed by hand-editing the generated SQL to match the rest of the schema before applying it, then reconciling the partially-applied migration with `prisma migrate resolve`.
|
||||||
|
|
||||||
|
## 24. Routes created or updated
|
||||||
|
|
||||||
|
Created: `/install`, `/staff/settings/calendar`, `/api/health`, `/api/staff/calendar/google/authorize`, `/api/staff/calendar/google/callback`. Updated (branding/routing behaviour, not new routes): `/`, `/sign-in`, `/register`, `/book/details`, `/offline`, every `(staff)`/`(owner)` page via the shared `StaffShell`, `/staff/settings`, `/owner/team/[id]` (read-only Google Calendar status card), `/owner/settings/booking-rules` (copy only).
|
||||||
|
|
||||||
|
## 25. Components created or updated
|
||||||
|
|
||||||
|
Created: `InstallWizard`, `WelcomeInstallCard`, `CalendarConnectionPanel`, the shared `useInstallPrompt` hook. Updated: `InstallAppButton` (now takes `businessName`), `StaffShell`/`StaffSidebarNav` (business branding), `DetailsForm`'s host page (`/book/details`, guest gate), `SignInForm`/`RegisterForm` (`callbackUrl` handling), `AcceptInvitationForm`'s copy, `BookingRulesForm`'s guest-booking copy.
|
||||||
|
|
||||||
|
## 26. Dockerfile changes
|
||||||
|
|
||||||
|
New, three-stage (`deps` → `builder` → `runner`), non-root (`nextchair:nodejs`, uid/gid 1001), explicit Node 22, `npm ci` (not `npm install`), no `.env` ever copied in, no secrets baked into any layer. Deliberately **not** using Next's `output: "standalone"` — tried first, reverted: 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 regardless, so a pruned standalone bundle would need those re-added anyway — a single full `npm ci` was simpler and no less correct, documented inline in the Dockerfile itself. Prisma 7's driver-adapter architecture (no native Rust query-engine binary — pure JS client) meant none of the classic "native binary on Alpine" packaging problems applied here. One real, non-obvious build issue found and fixed by actually building the image, not just writing it (see §34): `next build`'s static-page collection step imports every route module including `/api/health`, which pulls in `src/lib/db/prisma.ts`, which requires `DATABASE_URL` just to construct its connection-pool config object even though nothing connects at build time — fixed with a placeholder `ENV DATABASE_URL=...` in the builder stage only, documented inline as never used to connect to anything.
|
||||||
|
|
||||||
|
## 27. docker-compose.local.yaml
|
||||||
|
|
||||||
|
Three services under the Compose project name `next-chair`: `next-chair-mysql` (image `mysql:8.0`, matching CI; `utf8mb4`/`utf8mb4_0900_ai_ci`; named volume; `mysqladmin ping` healthcheck), `next-chair` (built from the Dockerfile; `depends_on: condition: service_healthy`; a Node-native healthcheck against `/api/health` — no `curl`/`wget` in `node:22-alpine`, so a plain `http.get()` one-liner avoids adding a package just for this), and `next-chair-seed` (same image, `entrypoint` overridden to `npx tsx prisma/seed.ts`, gated behind Compose's `profiles: ["seed"]` so it never runs on a normal `up` — see §30). All three share one `env_file: .env.local` rather than a long list of compose-level `${VAR}` substitutions, so one file is the single source of truth for both container environment variables and the two host-port overrides the compose file itself needs.
|
||||||
|
|
||||||
|
## 28. Container names
|
||||||
|
|
||||||
|
`next-chair` (application) and `next-chair-mysql` (database) — matching the brief's exact required names. Kept as two separate containers throughout, never combined.
|
||||||
|
|
||||||
|
## 29. MySQL volume and network
|
||||||
|
|
||||||
|
Named volume `next-chair-mysql-data`, named network `next-chair` — both services attached to it, matching the brief's exact required resource names.
|
||||||
|
|
||||||
|
## 30. Environment variables
|
||||||
|
|
||||||
|
`.env.local.example` (new) documents every variable the Docker stack uses, split into "Required for basic startup" (MySQL credentials, `DATABASE_URL` using the `next-chair-mysql` service hostname — never `localhost` — `AUTH_SECRET`, `AUTH_TRUST_HOST`, `NEXT_PUBLIC_APP_URL`, `INSTALLATION_TOKEN`, `CRON_SECRET`) versus clearly-marked-optional integrations (Google sign-in, Google Calendar, email, push) that the app starts and runs a complete first-install flow without — verified directly (see §34), not assumed. `.env.example` (the non-Docker local-dev file) was also updated with the same new Phase 8 variables for consistency between the two setup paths.
|
||||||
|
|
||||||
|
## 31. Production installation versus development seed
|
||||||
|
|
||||||
|
Kept strictly separate, and this phase adds an explicit, tested boundary between them: `prisma/seed.ts` now upserts its own `ApplicationInstallation` row (`status: INSTALLED`, `installedVersion: "dev-seed"`) purely so a freshly-seeded dev/CI/E2E database is treated as installed by the routing gate — documented inline as never something to run alongside a real installation, and structurally impossible to (the installer's own transaction refuses to start once any `ApplicationInstallation` row exists). Compose's default `up` never seeds; seeding is only ever the explicit `next-chair-seed` profile service (§27), matching the brief's "running the seed must be an intentional action."
|
||||||
|
|
||||||
|
## 32. Health checks
|
||||||
|
|
||||||
|
`GET /api/health` (`src/app/api/health/route.ts`) — `SELECT 1` against the database; `503` only on a genuine connectivity failure, `200` otherwise, **including before installation completes** (a migrated, reachable database with no business yet is healthy, not unhealthy — it should correctly show the installer). Never returns a stack trace or secret. Used by both the Dockerfile-adjacent Compose healthcheck and, implicitly, by anything else probing the deployment.
|
||||||
|
|
||||||
|
## 33. Automated tests
|
||||||
|
|
||||||
|
**New unit tests** (14, all passing): `safeRedirectPath` (open-redirect rejection cases), `token-encryption` (round-trip, fresh-IV-per-call, tamper detection, missing-key failure), `oauth-state` (valid/wrong-staff/tampered/malformed/expired).
|
||||||
|
|
||||||
|
**New integration tests, real MySQL** (11, all passing): `installer.service.integration.test.ts` — wrong token creates nothing; a full successful install creates the business/administrator/roles-with-correct-permission-counts/booking-rule-with-guest-off/opening-hours/starter-services/service-eligibility/audit-log all correctly; a second install attempt is rejected once already installed; **two concurrent install attempts create exactly one business** (the singleton-lock mechanism verified under real concurrent load, not just reasoned about). `calendar-sync.service.integration.test.ts` (using `MockCalendarProvider` — real database, zero network calls) — creates an event for a CONFIRMED appointment; never syncs a PENDING one; is idempotent (two syncs of the same appointment produce one remote event, not two); deletes the remote event on cancellation; correctly cancels the previous staff member's event and creates a new one on reassignment; `runInitialSync` only picks up future non-pending/non-cancelled appointments; does nothing when no connection exists. This required one small refactor: `calendar-sync.service.ts`'s two exported functions now accept an optional injectable `CalendarProvider` parameter (defaulting to the real Google provider) specifically so tests could pass `MockCalendarProvider` instead — directly satisfying the brief's "do not call real Google Calendar APIs in automated tests" rather than leaving that untestable.
|
||||||
|
|
||||||
|
**Existing suites, full regression run**: all pre-existing 216 unit tests (→ 230 with the additions above), 147 integration tests (→ 158), and the full 31-journey Playwright E2E suite were re-run after this phase's changes, against a genuinely fresh (`prisma migrate reset --force` + explicit reseed) database — **27 of 28 executed E2E tests passed, zero new failures**; the one failure ("barber changes their working hours") is the same pre-existing, already-tracked, time-of-day-sensitive flake from Phase 7 (unrelated to this phase, reproduces in isolation) — the remaining 3 tests in that spec file didn't run because Playwright's `describe.serial` mode skips subsequent tests in a file after one failure, not because of anything this phase changed.
|
||||||
|
|
||||||
|
**Not automated** (see §35): the brief's larger E2E checklist for the *new* flows this phase built (fresh-install-through-wizard, the guest-gate redirect round trip, Docker Compose lifecycle, iOS install-instruction rendering, a mocked Google Calendar OAuth round trip) was verified manually instead — see §34 — rather than by adding a second Playwright harness capable of running against a genuinely empty, not-yet-seeded database (the existing E2E setup's `global-setup.ts` always migrates *and seeds* before the suite runs, which is incompatible with testing the pre-install state without a parallel test-project configuration).
|
||||||
|
|
||||||
|
## 34. Manual verification
|
||||||
|
|
||||||
|
Performed directly, with real evidence (screenshots, DOM/API inspection, direct database queries), not assumed:
|
||||||
|
- **Full installation wizard, end to end, in a real browser against an empty database**: wrong token rejected with a generic error; correct token proceeds; all 9 steps filled and submitted; the final transaction created every expected row (confirmed via direct `mysql` queries — business, booking rule with `allowGuestBooking=0`, 3 categories, 3 services, Owner role with 15 permissions, Barber role with 6); the administrator was signed in and landed on `/owner`, which rendered real, non-demo data (a real dashboard reflecting the just-installed business, not seeded fixtures).
|
||||||
|
- **`/install` re-entry blocked after completion** — direct navigation redirects away.
|
||||||
|
- **The guest-booking gate, full round trip**: signed out → browsed → selected a slot → hit the "Sign in to complete your booking" gate at `/book/details` (guest booking correctly off from the fresh install) → Sign In → landed back on `/book/details`, now showing the authenticated details form with the same in-progress booking — the exact scenario §9 describes, actually driven through a browser rather than only reasoned about.
|
||||||
|
- **Branding across the whole app**: browser tab titles (`"Owner Settings | Riverside Barbershop"`, not `"— NextChair"`), the desktop sidebar nav showing the real business name in place of "NEXTCHAIR", the Welcome page's logo/name/copy, all confirmed visually and via the accessibility tree.
|
||||||
|
- **Full Docker Compose lifecycle**: built the image from a cold cache; started the stack against an empty MySQL volume; confirmed `prisma migrate deploy` applied all 7 migrations automatically on container start; confirmed the app's own healthcheck reported `healthy`; confirmed `GET /api/health` returned `installed: false` and `GET /` returned a `307` to `/install`; walked the installer through a real browser against the containerized app (port-mapped to the host); confirmed the seed profile service (`next-chair-seed`) runs to completion independently and is never triggered by a normal `up`; confirmed `installed: true` and a `200` from `/` after seeding; **restarted both containers and confirmed data and installation state persisted** (the named volume, not an ephemeral one); tore the whole test stack down (`down -v`) and confirmed the pre-existing local dev MySQL container was completely unaffected throughout (different Compose project name, different container names, by design).
|
||||||
|
|
||||||
|
## 35. Unverified external-provider behaviour
|
||||||
|
|
||||||
|
No live Google Cloud OAuth client or Brevo/VAPID credentials were available in this environment, so the following are implemented against Google's/the relevant provider's documented, versioned REST contracts and exercised only via the `MockCalendarProvider` integration tests (§33) and manual code review — **not** against a real account:
|
||||||
|
- The actual Google OAuth consent screen, token exchange, and refresh-token issuance/reuse behaviour (including whether `prompt=consent` reliably reissues a refresh token on reconnect, as documented).
|
||||||
|
- `listWritableCalendars`'s real response shape and `accessRole` filtering against a real Google account with multiple calendars.
|
||||||
|
- Real Google Calendar event create/update/delete responses and their exact error codes/bodies under real rate-limiting, an expired/revoked token, or a deleted calendar.
|
||||||
|
- The email/push provider paths (Brevo SMTP, web-push) — unchanged this phase, already flagged as unverified in `docs/phase-7-summary.md`, and still true here.
|
||||||
|
|
||||||
|
## 36. Known limitations
|
||||||
|
|
||||||
|
- **PWA manifest icons are not derived from a business's uploaded `appIconUrl`** — see §15; a future phase adding real image processing could close this.
|
||||||
|
- **Branding on `next-chair-seed`/precached-offline-page staleness**: the offline page (`/offline`) is only re-rendered with fresh branding whenever the service worker actually (re-)precaches it (install/activate, while online) — a device that's been offline since before a business renamed itself will show the old name until the SW next updates. Documented as an accepted characteristic, not a bug, in the file itself.
|
||||||
|
- **`EMAIL_FROM_NAME` is env-configured, not automatically derived from the installed business** — the Brevo provider's config loader is a pure, synchronous function with no database access (see §13); an owner who wants the sender name to exactly match their business name must set the env var themselves. A future phase could thread branding through the notification-sending path instead of its config-loading path to close this.
|
||||||
|
- **No dedicated "development profile" override** for `docker-compose.local.yaml` (source-mounting/hot-reload/file-watching) was built — the brief marked this explicitly optional ("if included"); the required production-style default stack was prioritized instead given the remaining scope.
|
||||||
|
- **The Google Calendar OAuth/API integration is unverified against a live account** — see §35.
|
||||||
|
- **New-flow E2E coverage is manual, not automated Playwright specs** — see §33's "Not automated" note; the underlying logic for all of these is covered by real integration tests (installer, calendar sync) plus the manual verification recorded in §34, but a future phase wanting CI-enforced regression coverage for the install wizard and guest-gate specifically would need a second Playwright project configured against a genuinely empty (unseeded) database, which the current `global-setup.ts` doesn't support without changes.
|
||||||
|
- **The residual Turbopack dev-mode cold-compile E2E flake** (the very first test of a full local dev-mode run, tracked since Phase 7) and the **pre-existing "barber changes their working hours" time-of-day flake** (also tracked since Phase 7) remain unresolved — neither is new to this phase, and CI's own pipeline already runs E2E against a production build (`next build` before the E2E step), which the dev-mode-specific flake doesn't affect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exact Docker Desktop startup commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
# edit .env.local — at minimum set AUTH_SECRET, INSTALLATION_TOKEN, CRON_SECRET,
|
||||||
|
# and the MySQL user/password/root-password values
|
||||||
|
|
||||||
|
# Start (builds the image on first run)
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local up --build -d
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local logs -f next-chair
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local down
|
||||||
|
|
||||||
|
# Remove database data too
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local down -v
|
||||||
|
|
||||||
|
# Seed development/demo data (optional, explicit — never runs automatically)
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local run --rm next-chair-seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Then visit `http://localhost:3000` (or `APP_HOST_PORT` from `.env.local`) — a freshly started stack with no seed run shows the installation wizard at `/install`.
|
||||||
@@ -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;
|
||||||
+167
-1
@@ -191,6 +191,31 @@ enum NotificationDeliveryStatus {
|
|||||||
SKIPPED
|
SKIPPED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// See src/lib/installation/installation-state.ts — the one place this is
|
||||||
|
/// read. INSTALLING exists purely as an in-transaction lock state (see
|
||||||
|
/// ApplicationInstallation's own header); no caller ever observes it
|
||||||
|
/// committed, since a failed install rolls the whole transaction back to
|
||||||
|
/// no-row-at-all rather than leaving INSTALLING or FAILED sitting around.
|
||||||
|
enum InstallationStatus {
|
||||||
|
NOT_INSTALLED
|
||||||
|
INSTALLING
|
||||||
|
INSTALLED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CalendarProvider {
|
||||||
|
GOOGLE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-appointment sync outcome, not the connection's overall health (see
|
||||||
|
/// StaffCalendarConnection.lastSyncError for that).
|
||||||
|
enum CalendarSyncStatus {
|
||||||
|
SYNCED
|
||||||
|
PENDING
|
||||||
|
FAILED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Business
|
// Business
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -202,8 +227,27 @@ model Business {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
slug String @unique
|
slug String @unique
|
||||||
name String
|
name String
|
||||||
|
/// Short form for tight spaces (PWA home-screen label, browser tab on
|
||||||
|
/// narrow viewports) — falls back to a truncated `name` when unset, see
|
||||||
|
/// src/lib/business/branding.ts.
|
||||||
|
shortName String?
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
logoUrl String?
|
logoUrl String?
|
||||||
|
/// Wide banner image for the Welcome page / booking pages — distinct
|
||||||
|
/// from `logoUrl` (square mark) and optional; falls back to a plain
|
||||||
|
/// color background when unset.
|
||||||
|
coverImageUrl String?
|
||||||
|
/// Falls back to the app's neutral favicon when unset — see
|
||||||
|
/// src/app/icon.png and the branding fallback rules in
|
||||||
|
/// src/lib/business/branding.ts.
|
||||||
|
faviconUrl String?
|
||||||
|
/// Used for the PWA manifest icon set — falls back to the same neutral
|
||||||
|
/// app icon as the favicon when unset.
|
||||||
|
appIconUrl String?
|
||||||
|
/// Hex color, e.g. "#111b15" — PWA `theme_color` / manifest and
|
||||||
|
/// browser chrome. Falls back to the app's own theme color when unset.
|
||||||
|
themeColor String?
|
||||||
|
backgroundColor String?
|
||||||
|
|
||||||
phone String?
|
phone String?
|
||||||
email String?
|
email String?
|
||||||
@@ -317,7 +361,13 @@ model BookingRule {
|
|||||||
maxAppointmentsPerStaffPerDay Int?
|
maxAppointmentsPerStaffPerDay Int?
|
||||||
|
|
||||||
customerCanChooseStaff Boolean @default(true)
|
customerCanChooseStaff Boolean @default(true)
|
||||||
allowGuestBooking Boolean @default(true)
|
/// Defaults to false as of Phase 8 (see docs/phase-8-summary.md) — a
|
||||||
|
/// freshly installed business requires sign-in/account creation before
|
||||||
|
/// a booking confirms until the owner explicitly opts in via
|
||||||
|
/// /owner/settings/booking-rules. Existing guest appointments and their
|
||||||
|
/// access-token links are unaffected either way (see
|
||||||
|
/// GuestAppointmentAccessToken) — this only gates *new* bookings.
|
||||||
|
allowGuestBooking Boolean @default(false)
|
||||||
requireApproval Boolean @default(false)
|
requireApproval Boolean @default(false)
|
||||||
/// Modelled now, not enforced anywhere yet — deposits are a future
|
/// Modelled now, not enforced anywhere yet — deposits are a future
|
||||||
/// payments phase (see docs/implementation-plan.md §6, "later phase").
|
/// payments phase (see docs/implementation-plan.md §6, "later phase").
|
||||||
@@ -326,6 +376,46 @@ model BookingRule {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Installation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Single source of truth for "has this deployment been set up yet" — see
|
||||||
|
/// src/lib/installation/installation-state.ts, the only place this is
|
||||||
|
/// read. Deliberately not inferred from "does Business have any rows" —
|
||||||
|
/// an explicit status column survives a partially-failed install cleanly.
|
||||||
|
///
|
||||||
|
/// v1 expects exactly one row ever, enforced by a fixed, well-known `id`
|
||||||
|
/// rather than a separate lock table: the installer's transaction starts
|
||||||
|
/// with `create({ id: "singleton", status: INSTALLING })` as its very
|
||||||
|
/// first statement, so a concurrent second install attempt blocks on
|
||||||
|
/// that same primary-key insert and then fails with a unique-constraint
|
||||||
|
/// violation once the first transaction commits — no read-then-write
|
||||||
|
/// race window. If the winning transaction fails partway through, the
|
||||||
|
/// whole transaction (including this row) rolls back, so a retry sees
|
||||||
|
/// "not installed" again rather than a stuck INSTALLING/FAILED row — see
|
||||||
|
/// src/features/installation/actions/install.ts.
|
||||||
|
model ApplicationInstallation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
status InstallationStatus @default(NOT_INSTALLED)
|
||||||
|
|
||||||
|
installedAt DateTime?
|
||||||
|
installedVersion String?
|
||||||
|
|
||||||
|
initialAdminUserId String?
|
||||||
|
primaryBusinessId String?
|
||||||
|
|
||||||
|
/// Small, non-secret metadata only (e.g. which starter-service option
|
||||||
|
/// was chosen) — never the installation token, never credentials.
|
||||||
|
metadata Json?
|
||||||
|
|
||||||
|
failureReason String? @db.Text
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Auth (Auth.js / NextAuth v5 + Prisma adapter standard shape)
|
// Auth (Auth.js / NextAuth v5 + Prisma adapter standard shape)
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -477,6 +567,9 @@ model StaffProfile {
|
|||||||
/// Set when this profile was created via the owner "Add Staff" flow
|
/// Set when this profile was created via the owner "Add Staff" flow
|
||||||
/// rather than seeded directly — see StaffInvitationService.
|
/// rather than seeded directly — see StaffInvitationService.
|
||||||
invitation StaffInvitation?
|
invitation StaffInvitation?
|
||||||
|
/// At most one connected Google account per staff member — see
|
||||||
|
/// StaffCalendarConnection and src/services/calendar.
|
||||||
|
calendarConnection StaffCalendarConnection?
|
||||||
|
|
||||||
@@index([businessId])
|
@@index([businessId])
|
||||||
}
|
}
|
||||||
@@ -544,6 +637,78 @@ model StaffInvitation {
|
|||||||
@@index([businessId])
|
@@index([businessId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A staff member's own connected Google account for one-way appointment
|
||||||
|
/// sync (business → Google Calendar) — entirely separate from Google
|
||||||
|
/// sign-in (Account/next-auth): a barber may authenticate with email and
|
||||||
|
/// still connect a *different* Google account here, or vice versa. See
|
||||||
|
/// src/services/calendar/calendar-sync.service.ts.
|
||||||
|
model StaffCalendarConnection {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
staffId String @unique
|
||||||
|
staff StaffProfile @relation(fields: [staffId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
provider CalendarProvider @default(GOOGLE)
|
||||||
|
externalAccountId String
|
||||||
|
accountEmail String
|
||||||
|
|
||||||
|
/// AES-256-GCM ciphertext (base64: iv + authTag + ciphertext) — see
|
||||||
|
/// src/lib/security/token-encryption.ts. CALENDAR_TOKEN_ENCRYPTION_KEY
|
||||||
|
/// is the only key that can decrypt these; never logged, never
|
||||||
|
/// returned to the client, never readable by the owner (see
|
||||||
|
/// StaffCalendarConnection's owner-facing projection in
|
||||||
|
/// calendar-sync.service.ts, which only ever selects the non-token
|
||||||
|
/// columns).
|
||||||
|
encryptedAccessToken String? @db.Text
|
||||||
|
encryptedRefreshToken String @db.Text
|
||||||
|
tokenExpiresAt DateTime?
|
||||||
|
|
||||||
|
selectedCalendarId String
|
||||||
|
selectedCalendarName String
|
||||||
|
|
||||||
|
connectedAt DateTime @default(now())
|
||||||
|
lastSyncAt DateTime?
|
||||||
|
lastSyncError String? @db.Text
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
events AppointmentCalendarEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row per (appointment, connection) pair the appointment has ever
|
||||||
|
/// been synced to — the unique constraint below is what makes sync
|
||||||
|
/// idempotent (an existing row means "update the same event", not
|
||||||
|
/// "create a new one").
|
||||||
|
model AppointmentCalendarEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
appointmentId String
|
||||||
|
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
connectionId String
|
||||||
|
connection StaffCalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
providerEventId String
|
||||||
|
providerCalendarId String
|
||||||
|
|
||||||
|
status CalendarSyncStatus @default(SYNCED)
|
||||||
|
lastSyncedAt DateTime @default(now())
|
||||||
|
lastSyncError String? @db.Text
|
||||||
|
/// Bumped on every appointment mutation that should trigger a re-push
|
||||||
|
/// (reschedule, service change) — lets the sync worker distinguish
|
||||||
|
/// "already reflects the latest appointment state" from "needs an
|
||||||
|
/// update" without re-diffing full event content each time.
|
||||||
|
sourceVersion Int @default(1)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([appointmentId, connectionId])
|
||||||
|
@@index([connectionId])
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Catalog
|
// Catalog
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -752,6 +917,7 @@ model Appointment {
|
|||||||
/// as the array Prisma requires for a one-to-many back-relation.
|
/// as the array Prisma requires for a one-to-many back-relation.
|
||||||
appliedLoyaltyRewards LoyaltyReward[]
|
appliedLoyaltyRewards LoyaltyReward[]
|
||||||
guestAccessToken GuestAppointmentAccessToken?
|
guestAccessToken GuestAppointmentAccessToken?
|
||||||
|
calendarEvents AppointmentCalendarEvent[]
|
||||||
|
|
||||||
@@index([staffId, startAt])
|
@@index([staffId, startAt])
|
||||||
@@index([businessId, startAt])
|
@@index([businessId, startAt])
|
||||||
|
|||||||
+35
-50
@@ -4,10 +4,12 @@
|
|||||||
* every relation in the schema once, not a full demo dataset.
|
* every relation in the schema once, not a full demo dataset.
|
||||||
*/
|
*/
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { PrismaClient, Permission, Weekday, RewardType, NotificationType } from "../generated/prisma/client";
|
import { PrismaClient, Weekday, RewardType, NotificationType, InstallationStatus } from "../generated/prisma/client";
|
||||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||||
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
|
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
|
||||||
import { notifySafely } from "../src/services/notifications/notification.service";
|
import { notifySafely } from "../src/services/notifications/notification.service";
|
||||||
|
import { OWNER_PERMISSIONS, BARBER_PERMISSIONS, syncRolePermissions } from "../src/lib/roles/default-permissions";
|
||||||
|
import { INSTALLATION_SINGLETON_ID } from "../src/lib/installation/installation-state";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,52 +31,6 @@ function minutesFromNow(minutes: number): Date {
|
|||||||
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
|
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
|
||||||
const prisma = new PrismaClient({ adapter });
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
/**
|
|
||||||
* `role.upsert`'s `update: {}` deliberately does nothing on an existing
|
|
||||||
* role (see the two call sites below) — re-running the seed must still be
|
|
||||||
* safe to call repeatedly without duplicating anything, but a plain
|
|
||||||
* nested `permissions: { create: [...] }` only fires on the `create`
|
|
||||||
* branch, so a permission added to OWNER_PERMISSIONS/BARBER_PERMISSIONS
|
|
||||||
* after a business was first seeded would silently never reach an
|
|
||||||
* already-seeded database. This syncs RolePermission rows to exactly
|
|
||||||
* match the target list every run — found and fixed while adding Phase 6
|
|
||||||
* permissions to a database seeded back in Phase 2.
|
|
||||||
*/
|
|
||||||
async function syncRolePermissions(roleId: string, permissions: Permission[]): Promise<void> {
|
|
||||||
await prisma.rolePermission.deleteMany({ where: { roleId, permission: { notIn: permissions } } });
|
|
||||||
await prisma.rolePermission.createMany({
|
|
||||||
data: permissions.map((permission) => ({ roleId, permission })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const OWNER_PERMISSIONS: Permission[] = [
|
|
||||||
Permission.MANAGE_BUSINESS_PROFILE,
|
|
||||||
Permission.MANAGE_BOOKING_RULES,
|
|
||||||
Permission.MANAGE_SERVICES,
|
|
||||||
Permission.MANAGE_STAFF,
|
|
||||||
Permission.MANAGE_LOYALTY_PROGRAMME,
|
|
||||||
Permission.MANAGE_NOTIFICATION_SETTINGS,
|
|
||||||
Permission.VIEW_ALL_STAFF_APPOINTMENTS,
|
|
||||||
Permission.MANAGE_ALL_APPOINTMENTS,
|
|
||||||
Permission.MANAGE_OWN_APPOINTMENTS,
|
|
||||||
Permission.CHECK_IN_CUSTOMERS,
|
|
||||||
Permission.MANAGE_LOYALTY_STAMPS,
|
|
||||||
Permission.MANAGE_OWN_AVAILABILITY,
|
|
||||||
Permission.VIEW_CUSTOMER_DIRECTORY,
|
|
||||||
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
|
|
||||||
Permission.VIEW_REPORTING,
|
|
||||||
];
|
|
||||||
|
|
||||||
const BARBER_PERMISSIONS: Permission[] = [
|
|
||||||
Permission.MANAGE_OWN_APPOINTMENTS,
|
|
||||||
Permission.CHECK_IN_CUSTOMERS,
|
|
||||||
Permission.MANAGE_LOYALTY_STAMPS,
|
|
||||||
Permission.MANAGE_OWN_AVAILABILITY,
|
|
||||||
Permission.VIEW_CUSTOMER_DIRECTORY,
|
|
||||||
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
|
|
||||||
];
|
|
||||||
|
|
||||||
const WEEKDAYS: Weekday[] = [
|
const WEEKDAYS: Weekday[] = [
|
||||||
Weekday.MONDAY,
|
Weekday.MONDAY,
|
||||||
Weekday.TUESDAY,
|
Weekday.TUESDAY,
|
||||||
@@ -112,7 +68,13 @@ async function main() {
|
|||||||
lateArrivalPolicy: "Arrivals more than 10 minutes late may need to be rescheduled.",
|
lateArrivalPolicy: "Arrivals more than 10 minutes late may need to be rescheduled.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
bookingRule: { create: {} }, // all defaults from the schema
|
// Guest booking defaults to false as of Phase 8 (see BookingRule in
|
||||||
|
// prisma/schema.prisma) — the dev/demo fixture explicitly opts back
|
||||||
|
// in so the guest-booking E2E journey and manual testing keep
|
||||||
|
// working; a real installed business starts with it off (see
|
||||||
|
// src/features/installation/installer.service.ts) until the owner
|
||||||
|
// enables it themselves.
|
||||||
|
bookingRule: { create: { allowGuestBooking: true } },
|
||||||
hours: {
|
hours: {
|
||||||
create: [
|
create: [
|
||||||
...WEEKDAYS.map((dayOfWeek) => ({
|
...WEEKDAYS.map((dayOfWeek) => ({
|
||||||
@@ -146,7 +108,7 @@ async function main() {
|
|||||||
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
|
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await syncRolePermissions(ownerRole.id, OWNER_PERMISSIONS);
|
await syncRolePermissions(prisma, ownerRole.id, OWNER_PERMISSIONS);
|
||||||
|
|
||||||
const barberRole = await prisma.role.upsert({
|
const barberRole = await prisma.role.upsert({
|
||||||
where: { businessId_name: { businessId: business.id, name: "Barber" } },
|
where: { businessId_name: { businessId: business.id, name: "Barber" } },
|
||||||
@@ -158,7 +120,7 @@ async function main() {
|
|||||||
permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) },
|
permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await syncRolePermissions(barberRole.id, BARBER_PERMISSIONS);
|
await syncRolePermissions(prisma, barberRole.id, BARBER_PERMISSIONS);
|
||||||
|
|
||||||
const category = await prisma.serviceCategory.upsert({
|
const category = await prisma.serviceCategory.upsert({
|
||||||
where: { id: `${business.id}-senior-barber` },
|
where: { id: `${business.id}-senior-barber` },
|
||||||
@@ -1013,6 +975,29 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dev/demo fixtures bypass the installation wizard entirely (see
|
||||||
|
// src/features/installation/installer.service.ts for the real,
|
||||||
|
// production first-run path) — this upsert only exists so a freshly
|
||||||
|
// seeded dev/CI/E2E database is treated as "installed" by the routing
|
||||||
|
// gate in src/proxy.ts, exactly as if a real install had completed.
|
||||||
|
// Never run alongside a real installation: the two are mutually
|
||||||
|
// exclusive by construction (the installer's own transaction refuses
|
||||||
|
// to start once this row exists — see ApplicationInstallation's model
|
||||||
|
// header).
|
||||||
|
await prisma.applicationInstallation.upsert({
|
||||||
|
where: { id: INSTALLATION_SINGLETON_ID },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: INSTALLATION_SINGLETON_ID,
|
||||||
|
status: InstallationStatus.INSTALLED,
|
||||||
|
installedAt: new Date(),
|
||||||
|
installedVersion: "dev-seed",
|
||||||
|
initialAdminUserId: ownerUser.id,
|
||||||
|
primaryBusinessId: business.id,
|
||||||
|
metadata: { source: "prisma/seed.ts" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
console.log("Seed complete:", {
|
console.log("Seed complete:", {
|
||||||
business: business.name,
|
business: business.name,
|
||||||
roles: [ownerRole.name, barberRole.name],
|
roles: [ownerRole.name, barberRole.name],
|
||||||
|
|||||||
+6
-2
@@ -1,4 +1,4 @@
|
|||||||
// NextChair service worker.
|
// Service worker — internal filename/comment only, never shown to a user.
|
||||||
//
|
//
|
||||||
// Scope, deliberately: this app is almost entirely dynamic, signed-in,
|
// Scope, deliberately: this app is almost entirely dynamic, signed-in,
|
||||||
// per-request server-rendered content (appointments, calendars, loyalty
|
// per-request server-rendered content (appointments, calendars, loyalty
|
||||||
@@ -94,7 +94,11 @@ self.addEventListener("push", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = payload.title || "NextChair";
|
// 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 = {
|
const options = {
|
||||||
body: payload.body || "",
|
body: payload.body || "",
|
||||||
data: { url: payload.url || "/" },
|
data: { url: payload.url || "/" },
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
import { Scissors } from "lucide-react";
|
import { Scissors } from "lucide-react";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
|
|
||||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
/**
|
||||||
|
* 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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-stone p-6">
|
<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">
|
<div id="main-content" className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center">
|
||||||
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-[1.25rem] bg-ink text-3xl text-white shadow-xl shadow-ink/20">
|
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-[1.25rem] bg-ink text-3xl text-white shadow-xl shadow-ink/20">
|
||||||
|
{branding.logoUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL.
|
||||||
|
<img src={branding.logoUrl} alt="" className="h-full w-full rounded-[1.25rem] object-cover" />
|
||||||
|
) : (
|
||||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { RegisterForm } from "@/features/auth/components/register-form";
|
import { RegisterForm } from "@/features/auth/components/register-form";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Account",
|
title: "Create Account",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default async function RegisterPage() {
|
||||||
|
const branding = await getBranding();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="mb-2 text-3xl font-bold tracking-tight">Create Account</h1>
|
<h1 className="mb-2 text-3xl font-bold tracking-tight">Join {branding.name}</h1>
|
||||||
<p className="mb-10 text-muted">Join to track your appointments and loyalty.</p>
|
<p className="mb-10 text-muted">Create an account to track your appointments and loyalty.</p>
|
||||||
|
{/* useSearchParams in RegisterForm requires a Suspense boundary. */}
|
||||||
|
<Suspense>
|
||||||
<RegisterForm />
|
<RegisterForm />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { SignInForm } from "@/features/auth/components/sign-in-form";
|
import { SignInForm } from "@/features/auth/components/sign-in-form";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Sign In",
|
title: "Sign In",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default async function SignInPage() {
|
||||||
|
const branding = await getBranding();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="mb-2 text-4xl font-bold tracking-tight">Welcome back</h1>
|
<h1 className="mb-2 text-4xl font-bold tracking-tight">Welcome back to {branding.name}</h1>
|
||||||
<p className="mb-12 leading-relaxed text-muted">
|
<p className="mb-12 leading-relaxed text-muted">
|
||||||
Please enter your credentials to access your appointments.
|
Please enter your credentials to access your appointments.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { EmptyState } from "@/components/feedback/empty-state";
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
import { ProfileCard } from "@/components/ui/profile-card";
|
import { ProfileCard } from "@/components/ui/profile-card";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||||
|
|
||||||
@@ -33,9 +34,10 @@ export default async function CustomerSettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerProfile = await prisma.customerProfile.findUnique({
|
const [customerProfile, branding] = await Promise.all([
|
||||||
where: { userId: session.user.id },
|
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
|
||||||
});
|
getBranding(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-12">
|
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-12">
|
||||||
@@ -65,7 +67,7 @@ export default async function CustomerSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="border-line rounded-jumbo border bg-white p-5">
|
<div className="border-line rounded-jumbo border bg-white p-5">
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
<InstallAppButton />
|
<InstallAppButton businessName={branding.name} />
|
||||||
</div>
|
</div>
|
||||||
<SignOutButton />
|
<SignOutButton />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ProfileCard } from "@/components/ui/profile-card";
|
|||||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||||
import { hasPermission } from "@/lib/auth/permissions";
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
import { Permission } from "@/generated/prisma/enums";
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Owner Settings" };
|
export const metadata: Metadata = { title: "Owner Settings" };
|
||||||
@@ -26,6 +27,7 @@ export default async function OwnerSettingsPage() {
|
|||||||
|
|
||||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||||
if (!canManage) redirect("/staff");
|
if (!canManage) redirect("/staff");
|
||||||
|
const branding = await getBranding();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||||
@@ -53,7 +55,7 @@ export default async function OwnerSettingsPage() {
|
|||||||
|
|
||||||
<div className="rounded-jumbo border-line border bg-white p-5">
|
<div className="rounded-jumbo border-line border bg-white p-5">
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
<InstallAppButton />
|
<InstallAppButton businessName={branding.name} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SignOutButton />
|
<SignOutButton />
|
||||||
|
|||||||
@@ -26,10 +26,15 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
|
|||||||
if (!owner) redirect("/staff");
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const [target, roles, allServices] = await Promise.all([
|
const [target, roles, allServices, calendarConnection] = await Promise.all([
|
||||||
getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }),
|
getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }),
|
||||||
listRolesForBusiness(prisma, owner.businessId),
|
listRolesForBusiness(prisma, owner.businessId),
|
||||||
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
prisma.service.findMany({ where: { businessId: owner.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();
|
if (!target) notFound();
|
||||||
|
|
||||||
@@ -72,6 +77,20 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<StaffDetailPanel
|
<StaffDetailPanel
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Bell, CalendarClock, CalendarOff, Clock, ShieldCheck, UserCircle } from "lucide-react";
|
import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, ShieldCheck, UserCircle } from "lucide-react";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { ProfileCard } from "@/components/ui/profile-card";
|
import { ProfileCard } from "@/components/ui/profile-card";
|
||||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||||
import { hasPermission } from "@/lib/auth/permissions";
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
import { Permission } from "@/generated/prisma/enums";
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||||
|
|
||||||
const SETTINGS_LINKS = [
|
const SETTINGS_LINKS = [
|
||||||
@@ -14,6 +15,7 @@ const SETTINGS_LINKS = [
|
|||||||
{ href: "/staff/settings/time-off", label: "Time Off", icon: CalendarOff },
|
{ href: "/staff/settings/time-off", label: "Time Off", icon: CalendarOff },
|
||||||
{ href: "/staff/settings/profile", label: "Profile", icon: UserCircle },
|
{ href: "/staff/settings/profile", label: "Profile", icon: UserCircle },
|
||||||
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
|
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
|
||||||
|
{ href: "/staff/settings/calendar", label: "Google Calendar", icon: CalendarSync },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Staff Settings" };
|
export const metadata: Metadata = { title: "Staff Settings" };
|
||||||
@@ -25,7 +27,10 @@ export default async function StaffSettingsPage() {
|
|||||||
// Real permission check, not the templates' unconditional mock "Switch to
|
// Real permission check, not the templates' unconditional mock "Switch to
|
||||||
// Admin View" button (see docs/implementation-plan.md §3) — only staff
|
// Admin View" button (see docs/implementation-plan.md §3) — only staff
|
||||||
// whose Role actually grants MANAGE_STAFF see a way into the owner area.
|
// whose Role actually grants MANAGE_STAFF see a way into the owner area.
|
||||||
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
const [canManageStaff, branding] = await Promise.all([
|
||||||
|
hasPermission(session.user.id, Permission.MANAGE_STAFF),
|
||||||
|
getBranding(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 px-6 pt-12">
|
<div className="space-y-6 px-6 pt-12">
|
||||||
@@ -55,7 +60,7 @@ export default async function StaffSettingsPage() {
|
|||||||
|
|
||||||
<div className="rounded-jumbo border border-line bg-white p-5">
|
<div className="rounded-jumbo border border-line bg-white p-5">
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
<InstallAppButton />
|
<InstallAppButton businessName={branding.name} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SignOutButton />
|
<SignOutButton />
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
const ics = generateAppointmentIcs({
|
const ics = generateAppointmentIcs({
|
||||||
appointmentId: appointment.id,
|
appointmentId: appointment.id,
|
||||||
businessName: business?.name ?? "NextChair",
|
businessName: business?.name ?? "the business",
|
||||||
businessAddress: business?.locations[0]
|
businessAddress: business?.locations[0]
|
||||||
? `${business.locations[0].addressLine1}, ${business.locations[0].city} ${business.locations[0].postalCode}`
|
? `${business.locations[0].addressLine1}, ${business.locations[0].city} ${business.locations[0].postalCode}`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { isInstalledForRequest } from "@/lib/installation/installation-state";
|
||||||
|
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||||
import { expireOverdueRewards } from "@/services/loyalty/loyalty.service";
|
import { expireOverdueRewards } from "@/services/loyalty/loyalty.service";
|
||||||
import { getEmailProvider, getPushProvider } from "@/services/notifications/providers";
|
import { getEmailProvider, getPushProvider } from "@/services/notifications/providers";
|
||||||
import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker";
|
import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker";
|
||||||
|
|
||||||
/** Same constant-time convention as src/lib/booking-draft/sign.ts and guest-access.service.ts — a plain `!==` on a secret leaks its length/prefix via timing. */
|
|
||||||
function secretsMatch(provided: string, expected: string): boolean {
|
|
||||||
const providedBuffer = Buffer.from(provided);
|
|
||||||
const expectedBuffer = Buffer.from(expected);
|
|
||||||
if (providedBuffer.length !== expectedBuffer.length) return false;
|
|
||||||
return timingSafeEqual(providedBuffer, expectedBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The external trigger point for the background notification processor —
|
* The external trigger point for the background notification processor —
|
||||||
* see reminder-worker.ts for why this exists instead of an in-process
|
* see reminder-worker.ts for why this exists instead of an in-process
|
||||||
@@ -47,6 +40,14 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No business data exists to process before the installer has run — see
|
||||||
|
// src/lib/installation/installation-state.ts. A 200 with a no-op result
|
||||||
|
// (not an error) so an external scheduler hitting this on a fixed
|
||||||
|
// interval before day-one setup doesn't page anyone.
|
||||||
|
if (!(await isInstalledForRequest())) {
|
||||||
|
return NextResponse.json({ skipped: "not_installed" });
|
||||||
|
}
|
||||||
|
|
||||||
const result = await processDueNotificationDeliveries(prisma, {
|
const result = await processDueNotificationDeliveries(prisma, {
|
||||||
emailProvider: getEmailProvider(),
|
emailProvider: getEmailProvider(),
|
||||||
pushProvider: getPushProvider(),
|
pushProvider: getPushProvider(),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getInstallationState } from "@/lib/installation/installation-state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docker/orchestrator health check — deliberately never requires
|
||||||
|
* installation to have completed: a fresh deployment with a reachable,
|
||||||
|
* migrated database but no business yet is healthy (it should correctly
|
||||||
|
* show the installer), not unhealthy. Only a database connectivity
|
||||||
|
* failure returns a non-200 status. Never returns secrets or a stack
|
||||||
|
* trace — just enough for a health probe to act on.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ status: "unhealthy", database: "unreachable" }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { installed } = await getInstallationState().catch(() => ({ installed: false }));
|
||||||
|
|
||||||
|
return NextResponse.json({ status: "healthy", database: "reachable", installed });
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { createOAuthState } from "@/lib/security/oauth-state";
|
||||||
|
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
|
||||||
|
|
||||||
|
function getRedirectUri(): string {
|
||||||
|
const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI;
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||||||
|
return `${appUrl}/api/staff/calendar/google/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the Google Calendar connect flow for the signed-in staff
|
||||||
|
* member's own account — deliberately separate from Google sign-in (see
|
||||||
|
* docs/phase-8-summary.md): a barber may connect a different Google
|
||||||
|
* account here than the one they authenticate with. `state` is a signed,
|
||||||
|
* short-lived, staff-scoped token (see oauth-state.ts) set as an httpOnly
|
||||||
|
* cookie and re-verified in the callback — CSRF/replay protection without
|
||||||
|
* server-side session storage.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
|
||||||
|
}
|
||||||
|
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
|
||||||
|
if (!staffProfile) {
|
||||||
|
return NextResponse.json({ error: "Only staff members can connect a calendar." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fails closed with a friendly redirect, not a 500 — see the brief's
|
||||||
|
// "show disabled states rather than crashing" for every optional
|
||||||
|
// integration. The Settings page itself also hides the Connect button
|
||||||
|
// when unconfigured (see calendar-connection-panel.tsx), so reaching
|
||||||
|
// this route unconfigured means a stale link or a direct hit.
|
||||||
|
if (!process.env.GOOGLE_CALENDAR_CLIENT_ID || !process.env.GOOGLE_CALENDAR_CLIENT_SECRET) {
|
||||||
|
const url = new URL("/staff/settings/calendar", process.env.NEXT_PUBLIC_APP_URL);
|
||||||
|
url.searchParams.set("error", "1");
|
||||||
|
url.searchParams.set("detail", "not_configured");
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = createOAuthState(staffProfile.id);
|
||||||
|
const provider = new GoogleCalendarProvider();
|
||||||
|
const authorizationUrl = provider.buildAuthorizationUrl({ redirectUri: getRedirectUri(), state });
|
||||||
|
|
||||||
|
const response = NextResponse.redirect(authorizationUrl);
|
||||||
|
response.cookies.set("gcal_oauth_state", state, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 600,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { encryptToken } from "@/lib/security/token-encryption";
|
||||||
|
import { verifyOAuthState } from "@/lib/security/oauth-state";
|
||||||
|
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
|
||||||
|
import { CalendarProviderError } from "@/services/calendar/types";
|
||||||
|
import { runInitialSync } from "@/services/calendar/calendar-sync.service";
|
||||||
|
|
||||||
|
function getRedirectUri(): string {
|
||||||
|
const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI;
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||||||
|
return `${appUrl}/api/staff/calendar/google/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingsRedirect(status: "connected" | "error", detail?: string): NextResponse {
|
||||||
|
const url = new URL("/staff/settings/calendar", process.env.NEXT_PUBLIC_APP_URL);
|
||||||
|
url.searchParams.set(status, "1");
|
||||||
|
if (detail) url.searchParams.set("detail", detail);
|
||||||
|
const response = NextResponse.redirect(url);
|
||||||
|
response.cookies.delete("gcal_oauth_state");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consent denial, an expired/tampered state, or a state issued for a
|
||||||
|
* different staff member than the one currently signed in are all
|
||||||
|
* treated the same way: bounce back to Settings with a generic error —
|
||||||
|
* never revealing which check failed (same principle as the installation
|
||||||
|
* token check, see installer.service.ts).
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
const returnedState = url.searchParams.get("state");
|
||||||
|
const consentDenied = url.searchParams.get("error");
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
|
||||||
|
}
|
||||||
|
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
|
||||||
|
if (!staffProfile) {
|
||||||
|
return settingsRedirect("error", "not_staff");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consentDenied) {
|
||||||
|
return settingsRedirect("error", "consent_denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieState = request.headers.get("cookie")?.match(/gcal_oauth_state=([^;]+)/)?.[1];
|
||||||
|
if (!code || !returnedState || !cookieState || returnedState !== cookieState || !verifyOAuthState(cookieState, staffProfile.id)) {
|
||||||
|
return settingsRedirect("error", "invalid_state");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const provider = new GoogleCalendarProvider();
|
||||||
|
const tokens = await provider.exchangeCodeForTokens({ code, redirectUri: getRedirectUri() });
|
||||||
|
const calendars = await provider.listWritableCalendars(tokens.accessToken);
|
||||||
|
const primaryCalendar = calendars.find((c) => c.primary) ?? calendars[0];
|
||||||
|
if (!primaryCalendar) {
|
||||||
|
return settingsRedirect("error", "no_writable_calendar");
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await prisma.staffCalendarConnection.upsert({
|
||||||
|
where: { staffId: staffProfile.id },
|
||||||
|
create: {
|
||||||
|
staffId: staffProfile.id,
|
||||||
|
externalAccountId: tokens.externalAccountId,
|
||||||
|
accountEmail: tokens.accountEmail,
|
||||||
|
encryptedAccessToken: encryptToken(tokens.accessToken),
|
||||||
|
encryptedRefreshToken: encryptToken(tokens.refreshToken),
|
||||||
|
tokenExpiresAt: tokens.expiresAt,
|
||||||
|
selectedCalendarId: primaryCalendar.id,
|
||||||
|
selectedCalendarName: primaryCalendar.name,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
externalAccountId: tokens.externalAccountId,
|
||||||
|
accountEmail: tokens.accountEmail,
|
||||||
|
encryptedAccessToken: encryptToken(tokens.accessToken),
|
||||||
|
encryptedRefreshToken: encryptToken(tokens.refreshToken),
|
||||||
|
tokenExpiresAt: tokens.expiresAt,
|
||||||
|
selectedCalendarId: primaryCalendar.id,
|
||||||
|
selectedCalendarName: primaryCalendar.name,
|
||||||
|
revokedAt: null,
|
||||||
|
lastSyncError: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runInitialSync(prisma, connection.staffId);
|
||||||
|
|
||||||
|
return settingsRedirect("connected");
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof CalendarProviderError ? error.code.toLowerCase() : "unknown";
|
||||||
|
return settingsRedirect("error", detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { LogIn } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { readDraft } from "@/lib/booking-draft/cookie";
|
import { readDraft } from "@/lib/booking-draft/cookie";
|
||||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||||
@@ -10,6 +14,8 @@ import { DetailsForm } from "@/features/booking/components/details-form";
|
|||||||
export const metadata: Metadata = { title: "Your Details" };
|
export const metadata: Metadata = { title: "Your Details" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const CALLBACK_URL = "/book/details";
|
||||||
|
|
||||||
export default async function BookDetailsPage() {
|
export default async function BookDetailsPage() {
|
||||||
const business = await getPrimaryBusiness();
|
const business = await getPrimaryBusiness();
|
||||||
const draft = await readDraft(business.id);
|
const draft = await readDraft(business.id);
|
||||||
@@ -18,6 +24,36 @@ export default async function BookDetailsPage() {
|
|||||||
if (!draft.startAt) redirect("/book/barber-time");
|
if (!draft.startAt) redirect("/book/barber-time");
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
|
// Booking progress up to this point (services, barber, time) is stored
|
||||||
|
// in the signed draft cookie, which survives navigation to /sign-in or
|
||||||
|
// /register and back — see src/lib/booking-draft/cookie.ts. Only the
|
||||||
|
// *details* step itself (and everything after it) requires an account
|
||||||
|
// when guest booking is disabled; browsing and slot selection never do.
|
||||||
|
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
|
||||||
|
if (!session?.user && !guestBookingEnabled) {
|
||||||
|
return (
|
||||||
|
<div className="pb-6">
|
||||||
|
<TopNav title="Your Details" onBack={true} sticky className="bg-stone" />
|
||||||
|
<EmptyState
|
||||||
|
icon={LogIn}
|
||||||
|
title="Sign in to complete your booking"
|
||||||
|
description="Create an account or sign in to confirm your appointment, manage your bookings and keep track of your loyalty rewards."
|
||||||
|
action={
|
||||||
|
<div className="flex w-full gap-3">
|
||||||
|
<Button asChild variant="secondary" className="flex-1">
|
||||||
|
<Link href={`/sign-in?callbackUrl=${encodeURIComponent(CALLBACK_URL)}`}>Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild className="flex-1">
|
||||||
|
<Link href={`/register?callbackUrl=${encodeURIComponent(CALLBACK_URL)}`}>Create Account</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let prefill: { firstName: string; lastName: string; email: string; phone: string } | undefined;
|
let prefill: { firstName: string; lastName: string; email: string; phone: string } | undefined;
|
||||||
|
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Deliberately not the (auth)/(customer)/(staff)/(owner) shells — this
|
||||||
|
* route renders before any business exists, so there is nothing to brand
|
||||||
|
* with yet (see the brief's "Offline and Error Branding" section: use
|
||||||
|
* neutral installation wording before install, business branding after).
|
||||||
|
* A plain neutral mark, not the app's own logo/name, keeps that boundary
|
||||||
|
* honest rather than showing product branding on the one screen where it
|
||||||
|
* explicitly shouldn't appear.
|
||||||
|
*/
|
||||||
|
export default function InstallLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-stone p-6">
|
||||||
|
<div id="main-content" className="mx-auto w-full max-w-lg flex-1 py-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getInstallationState } from "@/lib/installation/installation-state";
|
||||||
|
import { InstallWizard } from "@/features/installation/components/install-wizard";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Set Up Your Business",
|
||||||
|
};
|
||||||
|
// Must never be statically prerendered — the installation-state check on
|
||||||
|
// every request is the entire point of this page (see the brief's "do
|
||||||
|
// not rely on the form being hidden"). Without this, Next could bake a
|
||||||
|
// single build-time snapshot of `installed` into static HTML and serve
|
||||||
|
// that forever, regardless of the database's real state afterward.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function InstallPage() {
|
||||||
|
const { installed } = await getInstallationState();
|
||||||
|
// Direct navigation after a completed install — src/proxy.ts already
|
||||||
|
// redirects here for most cases, but this page enforces the same rule
|
||||||
|
// itself rather than trusting the proxy alone (see the brief's "do not
|
||||||
|
// rely on the form being hidden" requirement).
|
||||||
|
if (installed) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenConfigured = !!process.env.INSTALLATION_TOKEN;
|
||||||
|
|
||||||
|
if (!tokenConfigured) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">Installation is not configured</h1>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
This deployment has no <code className="rounded bg-white px-1.5 py-0.5 text-xs">INSTALLATION_TOKEN</code>{" "}
|
||||||
|
environment variable set, so setup can't proceed. An administrator needs to set one and restart the
|
||||||
|
application before installation can begin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <InstallWizard />;
|
||||||
|
}
|
||||||
+24
-14
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -9,28 +10,37 @@ const inter = Inter({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
/**
|
||||||
title: {
|
* Dynamic, not a static `export const metadata` — every page's title,
|
||||||
default: "NextChair",
|
* description, and app-name metadata reflect the installed business, not
|
||||||
template: "%s — NextChair",
|
* the internal product name (see src/lib/business/branding.ts). Before
|
||||||
},
|
* installation completes, `getBranding()` returns neutral wording rather
|
||||||
description:
|
* than "NextChair" (see that file's own fallback-order comment).
|
||||||
"Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
|
*/
|
||||||
appleWebApp: {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
capable: true,
|
const branding = await getBranding();
|
||||||
statusBarStyle: "default",
|
return {
|
||||||
title: "NextChair",
|
title: { default: branding.name, template: `%s | ${branding.name}` },
|
||||||
},
|
description: branding.description || "Book your next appointment in seconds.",
|
||||||
|
appleWebApp: { capable: true, statusBarStyle: "default", title: branding.shortName },
|
||||||
|
icons:
|
||||||
|
branding.faviconUrl || branding.appIconUrl
|
||||||
|
? { icon: branding.faviconUrl || undefined, apple: branding.appIconUrl || undefined }
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export async function generateViewport(): Promise<Viewport> {
|
||||||
|
const branding = await getBranding();
|
||||||
|
return {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 1,
|
||||||
userScalable: false,
|
userScalable: false,
|
||||||
viewportFit: "cover",
|
viewportFit: "cover",
|
||||||
themeColor: "#111b15",
|
themeColor: branding.themeColor,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
+20
-10
@@ -1,22 +1,32 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next.js's manifest file convention — auto-served at /manifest.webmanifest
|
* Next.js's manifest file convention — auto-served at /manifest.webmanifest
|
||||||
* with the <link rel="manifest"> tag injected automatically, so nothing in
|
* with the <link rel="manifest"> tag injected automatically. Dynamic as of
|
||||||
* layout.tsx needs to reference this directly. Single business per
|
* Phase 8: name/description/colors reflect the installed business (see
|
||||||
* deployment (see docs/implementation-plan.md), so the name/colors are
|
* src/lib/business/branding.ts) rather than the internal product name.
|
||||||
* static rather than read from the Business row.
|
*
|
||||||
|
* `icons` intentionally stays pointed at the app's own pre-generated,
|
||||||
|
* correctly-sized/maskable PWA icon set rather than a business's single
|
||||||
|
* `appIconUrl` — there's no image-processing pipeline in this app to
|
||||||
|
* derive the required 192/512/maskable variants from one arbitrary
|
||||||
|
* uploaded URL (see docs/phase-8-summary.md, "Known limitations"), so
|
||||||
|
* this is fallback rule #2 ("business name + a neutral generated icon"),
|
||||||
|
* applied deliberately rather than risking a broken/non-square install
|
||||||
|
* icon.
|
||||||
*/
|
*/
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||||
|
const branding = await getBranding();
|
||||||
return {
|
return {
|
||||||
name: "NextChair — Barber Booking",
|
name: branding.name,
|
||||||
short_name: "NextChair",
|
short_name: branding.shortName,
|
||||||
description: "Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
|
description: branding.description || "Book your next appointment in seconds.",
|
||||||
start_url: "/home",
|
start_url: "/home",
|
||||||
scope: "/",
|
scope: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#f4f6f4",
|
background_color: branding.backgroundColor,
|
||||||
theme_color: "#111b15",
|
theme_color: branding.themeColor,
|
||||||
orientation: "portrait-primary",
|
orientation: "portrait-primary",
|
||||||
icons: [
|
icons: [
|
||||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { WifiOff } from "lucide-react";
|
import { WifiOff } from "lucide-react";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "You're offline" };
|
export const metadata: Metadata = { title: "You're offline" };
|
||||||
|
// Forced dynamic so every time the service worker actually (re-)fetches
|
||||||
|
// this page (at install/activate, while online — see this file's own
|
||||||
|
// comment below), it gets a fresh render reflecting current branding.
|
||||||
|
// Without this, a production build could statically bake in whatever
|
||||||
|
// branding existed at *build* time and serve that forever, even after
|
||||||
|
// the business's branding changes.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The service worker's navigation fallback (see public/sw.js) — served
|
* The service worker's navigation fallback (see public/sw.js) — served
|
||||||
* from cache when a page request fails with no network. Deliberately
|
* from cache when a page request fails with no network. No auth, no
|
||||||
* static: no auth, no data fetching, nothing that could itself require a
|
* per-request data fetching once it's *serving* offline (the whole point
|
||||||
* network round-trip, since the whole point is that it still renders with
|
* is that it still renders with zero connectivity) — but it's still a
|
||||||
* zero connectivity. Precached at service-worker install time.
|
* normal server-rendered page whenever the service worker actually
|
||||||
|
* (re-)precaches it, which only ever happens while online (install/
|
||||||
|
* activate, see public/sw.js). So it reads business branding like any
|
||||||
|
* other page; a device that goes offline simply keeps showing whatever
|
||||||
|
* branding was current the last time it had a connection.
|
||||||
*/
|
*/
|
||||||
export default function OfflinePage() {
|
export default async function OfflinePage() {
|
||||||
|
const branding = await getBranding();
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-stone px-6 text-center">
|
<div className="flex min-h-screen flex-col items-center justify-center bg-stone px-6 text-center">
|
||||||
<div className="bg-ink mb-6 flex h-16 w-16 items-center justify-center rounded-[1.25rem] text-white shadow-xl">
|
<div className="bg-ink mb-6 flex h-16 w-16 items-center justify-center rounded-[1.25rem] text-white shadow-xl">
|
||||||
@@ -18,7 +31,7 @@ export default function OfflinePage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="mb-2 text-xl font-bold tracking-tight">You're offline</h1>
|
<h1 className="mb-2 text-xl font-bold tracking-tight">You're offline</h1>
|
||||||
<p className="text-muted max-w-xs text-sm">
|
<p className="text-muted max-w-xs text-sm">
|
||||||
NextChair needs a connection for booking, schedules, and account details. Reconnect and try again.
|
{branding.name} needs a connection for booking, schedules, and account details. Reconnect and try again.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+46
-9
@@ -3,21 +3,49 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||||
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unauthenticated entry point — always shown at `/` before
|
||||||
|
* authentication, per the brief's "Welcome routing" requirements.
|
||||||
|
* Authenticated visitors never see it: they're routed straight to their
|
||||||
|
* role's home (customer → /home, barber → /staff, owner → /owner) below.
|
||||||
|
* Unreachable at all before installation completes — src/proxy.ts
|
||||||
|
* redirects every non-exempt route (including this one) to /install
|
||||||
|
* until ApplicationInstallation flips to INSTALLED.
|
||||||
|
*/
|
||||||
export default async function WelcomePage() {
|
export default async function WelcomePage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
|
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||||
|
if (staffProfile) {
|
||||||
|
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||||
|
redirect(canManageStaff ? "/owner" : "/staff");
|
||||||
|
}
|
||||||
redirect("/home");
|
redirect("/home");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [branding, business] = await Promise.all([getBranding(), getPrimaryBusiness()]);
|
||||||
|
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="main-content" className="flex min-h-screen flex-col justify-end bg-ink px-8 pb-16 text-white">
|
<div id="main-content" className="flex min-h-screen flex-col justify-end bg-ink px-8 pb-16 text-white">
|
||||||
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-ink">
|
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-ink">
|
||||||
|
{branding.logoUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL; not a build-time-known local asset for next/image.
|
||||||
|
<img src={branding.logoUrl} alt="" className="h-full w-full rounded-2xl object-cover" />
|
||||||
|
) : (
|
||||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mb-4 text-5xl font-bold tracking-tight">NextChair</h1>
|
<h1 className="mb-4 text-5xl font-bold tracking-tight">{branding.name}</h1>
|
||||||
<p className="mb-12 max-w-xs text-lg text-white/70">
|
<p className="mb-12 max-w-xs text-lg text-white/70">
|
||||||
Precision grooming at your fingertips. Book your next visit in seconds.
|
Book your next visit in seconds — services, staff and loyalty rewards in one place.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -32,15 +60,24 @@ export default async function WelcomePage() {
|
|||||||
>
|
>
|
||||||
<Link href="/sign-in">Sign In</Link>
|
<Link href="/sign-in">Sign In</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
{guestBookingEnabled && (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
className="w-full text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<Link href="/book">Continue as Guest</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button asChild variant="ghost" size="lg" className="w-full text-white/70 hover:bg-white/10 hover:text-white">
|
||||||
|
<Link href="/home">Browse Services</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
<div className="mt-6">
|
||||||
The templates' "Continue as Guest" goes straight to the business
|
<WelcomeInstallCard businessName={branding.name} />
|
||||||
home/booking flow (template 20 → 15). That flow isn't built yet
|
</div>
|
||||||
(Phase 1: Customer Booking), so this button isn't shown rather than
|
|
||||||
linking somewhere that doesn't work yet — see
|
|
||||||
docs/implementation-plan.md §16 for the phase it belongs to.
|
|
||||||
*/}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NotificationBell } from "@/features/notifications/components/notification-bell";
|
import { NotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
import { BottomNav } from "./bottom-nav";
|
import { BottomNav } from "./bottom-nav";
|
||||||
import { StaffSidebarNav } from "./staff-sidebar-nav";
|
import { StaffSidebarNav } from "./staff-sidebar-nav";
|
||||||
import type { BottomNavItem } from "./bottom-nav";
|
import type { BottomNavItem } from "./bottom-nav";
|
||||||
@@ -10,8 +11,12 @@ import { STAFF_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
|||||||
* defaults to the staff nav; (owner) passes OWNER_NAV_ITEMS instead (see
|
* defaults to the staff nav; (owner) passes OWNER_NAV_ITEMS instead (see
|
||||||
* staff-nav-items.ts) — same shell, same sidebar/bottom-nav components,
|
* staff-nav-items.ts) — same shell, same sidebar/bottom-nav components,
|
||||||
* different item set, per the Phase 6 brief's own nav requirement.
|
* different item set, per the Phase 6 brief's own nav requirement.
|
||||||
|
*
|
||||||
|
* Reads business branding itself (async Server Component) rather than
|
||||||
|
* requiring every (staff)/(owner) layout to fetch and thread it through —
|
||||||
|
* one call site covers both.
|
||||||
*/
|
*/
|
||||||
export function StaffShell({
|
export async function StaffShell({
|
||||||
children,
|
children,
|
||||||
items = STAFF_NAV_ITEMS,
|
items = STAFF_NAV_ITEMS,
|
||||||
footerLink,
|
footerLink,
|
||||||
@@ -24,9 +29,10 @@ export function StaffShell({
|
|||||||
notificationsHref?: string;
|
notificationsHref?: string;
|
||||||
unreadNotificationCount?: number;
|
unreadNotificationCount?: number;
|
||||||
}) {
|
}) {
|
||||||
|
const branding = await getBranding();
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
|
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
|
||||||
<StaffSidebarNav items={items} footerLink={footerLink} />
|
<StaffSidebarNav items={items} footerLink={footerLink} businessName={branding.name} logoUrl={branding.logoUrl} />
|
||||||
<NotificationBell href={notificationsHref} unreadCount={unreadNotificationCount} />
|
<NotificationBell href={notificationsHref} unreadCount={unreadNotificationCount} />
|
||||||
<div id="main-content">{children}</div>
|
<div id="main-content">{children}</div>
|
||||||
<BottomNav items={items} variant="staff" />
|
<BottomNav items={items} variant="staff" />
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ import type { BottomNavItem } from "./bottom-nav";
|
|||||||
export function StaffSidebarNav({
|
export function StaffSidebarNav({
|
||||||
items,
|
items,
|
||||||
footerLink,
|
footerLink,
|
||||||
|
businessName,
|
||||||
|
logoUrl,
|
||||||
}: {
|
}: {
|
||||||
items: BottomNavItem[];
|
items: BottomNavItem[];
|
||||||
/** Owner sidebar's explicit "back to barber tools" — see the brief's "clear access back to barber tools". Omitted on the staff sidebar itself. */
|
/** Owner sidebar's explicit "back to barber tools" — see the brief's "clear access back to barber tools". Omitted on the staff sidebar itself. */
|
||||||
footerLink?: { href: string; label: string };
|
footerLink?: { href: string; label: string };
|
||||||
|
businessName: string;
|
||||||
|
logoUrl: string | null;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
@@ -31,8 +35,13 @@ export function StaffSidebarNav({
|
|||||||
className="fixed inset-y-0 left-0 z-50 hidden w-56 flex-col border-r border-white/5 bg-[#0c0c0c] px-4 py-6 lg:flex"
|
className="fixed inset-y-0 left-0 z-50 hidden w-56 flex-col border-r border-white/5 bg-[#0c0c0c] px-4 py-6 lg:flex"
|
||||||
>
|
>
|
||||||
<div className="mb-8 flex items-center gap-2 px-2 text-white">
|
<div className="mb-8 flex items-center gap-2 px-2 text-white">
|
||||||
|
{logoUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL.
|
||||||
|
<img src={logoUrl} alt="" className="h-5 w-5 rounded object-cover" />
|
||||||
|
) : (
|
||||||
<Scissors className="h-5 w-5" aria-hidden="true" />
|
<Scissors className="h-5 w-5" aria-hidden="true" />
|
||||||
<span className="text-sm font-bold tracking-widest uppercase">NextChair</span>
|
)}
|
||||||
|
<span className="truncate text-sm font-bold tracking-widest uppercase">{businessName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
{items.map(({ href, label, icon }) => {
|
{items.map(({ href, label, icon }) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { prisma } from "@/lib/db/prisma";
|
|||||||
import { registerSchema } from "@/features/auth/schemas";
|
import { registerSchema } from "@/features/auth/schemas";
|
||||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||||
import { getRequestIp } from "@/lib/security/request-ip";
|
import { getRequestIp } from "@/lib/security/request-ip";
|
||||||
|
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||||
|
|
||||||
export interface RegisterResult {
|
export interface RegisterResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -75,7 +76,8 @@ export async function registerAction(formData: FormData): Promise<RegisterResult
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await signIn("credentials", { email, password, redirectTo: "/home" });
|
const redirectTo = safeRedirectPath(formData.get("callbackUrl")?.toString(), "/home");
|
||||||
|
await signIn("credentials", { email, password, redirectTo });
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -39,8 +39,8 @@ export function AcceptInvitationForm({ token, businessName }: { token: string; b
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||||
<p className="text-muted text-sm">
|
<p className="text-muted text-sm">
|
||||||
You've been invited to join <span className="text-ink font-bold">{businessName}</span> on NextChair. Set
|
You've been invited to join <span className="text-ink font-bold">{businessName}</span>. Set a password
|
||||||
a password to finish setting up your account.
|
to finish setting up your account.
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
label="Password"
|
label="Password"
|
||||||
|
|||||||
@@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { registerAction } from "@/features/auth/actions/register";
|
import { registerAction } from "@/features/auth/actions/register";
|
||||||
import { registerSchema, type RegisterInput } from "@/features/auth/schemas";
|
import { registerSchema, type RegisterInput } from "@/features/auth/schemas";
|
||||||
|
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||||
|
|
||||||
export function RegisterForm() {
|
export function RegisterForm() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -25,6 +29,7 @@ export function RegisterForm() {
|
|||||||
setFormError(null);
|
setFormError(null);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? ""));
|
Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? ""));
|
||||||
|
formData.set("callbackUrl", callbackUrl);
|
||||||
|
|
||||||
const result = await registerAction(formData);
|
const result = await registerAction(formData);
|
||||||
|
|
||||||
@@ -111,7 +116,7 @@ export function RegisterForm() {
|
|||||||
|
|
||||||
<p className="text-muted mt-10 text-center text-sm">
|
<p className="text-muted mt-10 text-center text-sm">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link href="/sign-in" className="border-ink text-ink border-b font-bold">
|
<Link href={`/sign-in?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b font-bold">
|
||||||
Log in
|
Log in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import { useForm } from "react-hook-form";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { signInSchema, type SignInInput } from "@/features/auth/schemas";
|
import { signInSchema, type SignInInput } from "@/features/auth/schemas";
|
||||||
|
import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
||||||
|
|
||||||
export function SignInForm() {
|
export function SignInForm() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/home";
|
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -91,7 +92,7 @@ export function SignInForm() {
|
|||||||
|
|
||||||
<p className="text-muted mt-10 text-center text-sm">
|
<p className="text-muted mt-10 text-center text-sm">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link href="/register" className="border-ink text-ink border-b-2 font-bold">
|
<Link href={`/register?callbackUrl=${encodeURIComponent(callbackUrl)}`} className="border-ink text-ink border-b-2 font-bold">
|
||||||
Create one
|
Create one
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { checkRateLimit } from "@/lib/security/rate-limit";
|
|||||||
import { getRequestIp } from "@/lib/security/request-ip";
|
import { getRequestIp } from "@/lib/security/request-ip";
|
||||||
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
|
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
|
||||||
import { createBooking } from "@/services/scheduling/booking.service";
|
import { createBooking } from "@/services/scheduling/booking.service";
|
||||||
|
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||||
import { getFirstFriendlyMessage } from "../error-messages";
|
import { getFirstFriendlyMessage } from "../error-messages";
|
||||||
|
|
||||||
export interface ConfirmBookingResult {
|
export interface ConfirmBookingResult {
|
||||||
@@ -44,6 +45,16 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
|
|||||||
if (!draft.guest) {
|
if (!draft.guest) {
|
||||||
return { success: false, error: "Please provide your contact details first." };
|
return { success: false, error: "Please provide your contact details first." };
|
||||||
}
|
}
|
||||||
|
// Authoritative guest-booking gate — the UI already hides the
|
||||||
|
// guest path when disabled (see /book/details), but this is the
|
||||||
|
// one place that actually creates an Appointment row, so it's
|
||||||
|
// re-checked here regardless of how the request arrived (a direct
|
||||||
|
// Server Action call, a stale draft cookie started before the
|
||||||
|
// owner disabled guest booking mid-flow, etc.).
|
||||||
|
const bookingRule = await prisma.bookingRule.findUnique({ where: { businessId }, select: { allowGuestBooking: true } });
|
||||||
|
if (!bookingRule?.allowGuestBooking) {
|
||||||
|
return { success: false, error: "Guest booking is currently unavailable. Please sign in or create an account to continue." };
|
||||||
|
}
|
||||||
// Registered customers already have a rate-limited signin gate on
|
// Registered customers already have a rate-limited signin gate on
|
||||||
// their account; a guest has none, so this is the one place spam
|
// their account; a guest has none, so this is the one place spam
|
||||||
// bookings can otherwise be generated with no account at all.
|
// bookings can otherwise be generated with no account at all.
|
||||||
@@ -72,6 +83,11 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
|
|||||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A no-op when the appointment is PENDING (requires approval) — see
|
||||||
|
// calendar-sync.service.ts's own policy comment. Never blocks or fails
|
||||||
|
// this action either way.
|
||||||
|
await syncAppointmentSafely(prisma, result.appointment.id);
|
||||||
|
|
||||||
await writeLastBookingReference({
|
await writeLastBookingReference({
|
||||||
appointmentId: result.appointment.id,
|
appointmentId: result.appointment.id,
|
||||||
guestAccessToken: result.guestAccessToken,
|
guestAccessToken: result.guestAccessToken,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { auth } from "@/auth";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||||
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||||
|
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||||
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
|
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
|
||||||
import { getFirstFriendlyMessage } from "../error-messages";
|
import { getFirstFriendlyMessage } from "../error-messages";
|
||||||
import { customerCancelSchema, customerRescheduleSchema } from "../schemas";
|
import { customerCancelSchema, customerRescheduleSchema } from "../schemas";
|
||||||
@@ -50,6 +51,8 @@ export async function cancelCustomerAppointmentAction(input: unknown): Promise<C
|
|||||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
|
||||||
|
|
||||||
// Deliberately not revalidating here (unlike reschedule) — every affected
|
// Deliberately not revalidating here (unlike reschedule) — every affected
|
||||||
// page is already `dynamic = "force-dynamic"`, so revalidatePath buys no
|
// page is already `dynamic = "force-dynamic"`, so revalidatePath buys no
|
||||||
// extra freshness, and doing it immediately would drop the cancelled
|
// extra freshness, and doing it immediately would drop the cancelled
|
||||||
@@ -87,6 +90,8 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi
|
|||||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
|
||||||
|
|
||||||
revalidatePath("/appointments");
|
revalidatePath("/appointments");
|
||||||
revalidatePath(`/appointments/${parsed.data.appointmentId}`);
|
revalidatePath(`/appointments/${parsed.data.appointmentId}`);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ export async function saveDetailsStepAction(input: unknown): Promise<StepActionR
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
|
// Same authoritative check confirmBookingAction re-runs at the final
|
||||||
|
// commit — rejected here too so a direct call to this action (not
|
||||||
|
// just the confirm step) can't bypass a disabled guest-booking rule.
|
||||||
|
const bookingRule = await prisma.bookingRule.findUnique({
|
||||||
|
where: { businessId: parsed.data.businessId },
|
||||||
|
select: { allowGuestBooking: true },
|
||||||
|
});
|
||||||
|
if (!bookingRule?.allowGuestBooking) {
|
||||||
|
return { success: false, error: "Please sign in or create an account to continue." };
|
||||||
|
}
|
||||||
const guestParsed = guestDetailsSchema.safeParse({
|
const guestParsed = guestDetailsSchema.safeParse({
|
||||||
firstName: parsed.data.firstName,
|
firstName: parsed.data.firstName,
|
||||||
lastName: parsed.data.lastName,
|
lastName: parsed.data.lastName,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { decryptToken, encryptToken } from "@/lib/security/token-encryption";
|
||||||
|
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
|
||||||
|
import { CalendarProviderError } from "@/services/calendar/types";
|
||||||
|
import type { RemoteCalendarInfo } from "@/services/calendar/types";
|
||||||
|
import { runInitialSync } from "@/services/calendar/calendar-sync.service";
|
||||||
|
|
||||||
|
async function requireStaffId(): Promise<string> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Not signed in.");
|
||||||
|
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
|
||||||
|
if (!staffProfile) throw new Error("Not a staff member.");
|
||||||
|
return staffProfile.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarActionResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectCalendarAction(): Promise<CalendarActionResult> {
|
||||||
|
const staffId = await requireStaffId();
|
||||||
|
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||||
|
if (!connection) return { success: true };
|
||||||
|
|
||||||
|
// Best-effort revoke, then always clear locally regardless of whether
|
||||||
|
// Google's revoke call succeeded — see the brief's "Revoke credentials
|
||||||
|
// where possible" (not "must succeed before disconnecting").
|
||||||
|
const provider = new GoogleCalendarProvider();
|
||||||
|
await provider.revoke(decryptToken(connection.encryptedRefreshToken)).catch(() => {});
|
||||||
|
|
||||||
|
// Existing AppointmentCalendarEvent rows and their remote events are
|
||||||
|
// deliberately left alone — see the brief's "Leave existing events,
|
||||||
|
// stop managing them." Only the connection itself is removed, so future
|
||||||
|
// appointment changes have nothing left to sync against.
|
||||||
|
await prisma.staffCalendarConnection.update({
|
||||||
|
where: { staffId },
|
||||||
|
data: { revokedAt: new Date(), encryptedAccessToken: null, tokenExpiresAt: null },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncNowAction(): Promise<CalendarActionResult> {
|
||||||
|
const staffId = await requireStaffId();
|
||||||
|
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||||
|
if (!connection || connection.revokedAt) {
|
||||||
|
return { success: false, error: "Connect Google Calendar first." };
|
||||||
|
}
|
||||||
|
await runInitialSync(prisma, staffId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListCalendarsResult {
|
||||||
|
success: boolean;
|
||||||
|
calendars?: RemoteCalendarInfo[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAvailableCalendarsAction(): Promise<ListCalendarsResult> {
|
||||||
|
const staffId = await requireStaffId();
|
||||||
|
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||||
|
if (!connection || connection.revokedAt) {
|
||||||
|
return { success: false, error: "Connect Google Calendar first." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = new GoogleCalendarProvider();
|
||||||
|
try {
|
||||||
|
let accessToken = connection.encryptedAccessToken ? decryptToken(connection.encryptedAccessToken) : null;
|
||||||
|
const expired = !connection.tokenExpiresAt || connection.tokenExpiresAt.getTime() - Date.now() < 60_000;
|
||||||
|
if (!accessToken || expired) {
|
||||||
|
const refreshed = await provider.refreshAccessToken(decryptToken(connection.encryptedRefreshToken));
|
||||||
|
accessToken = refreshed.accessToken;
|
||||||
|
await prisma.staffCalendarConnection.update({
|
||||||
|
where: { staffId },
|
||||||
|
data: { encryptedAccessToken: encryptToken(refreshed.accessToken), tokenExpiresAt: refreshed.expiresAt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const calendars = await provider.listWritableCalendars(accessToken);
|
||||||
|
return { success: true, calendars };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof CalendarProviderError ? error.message : "Couldn't load calendars.";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectCalendarAction(calendarId: string, calendarName: string): Promise<CalendarActionResult> {
|
||||||
|
const staffId = await requireStaffId();
|
||||||
|
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||||
|
if (!connection || connection.revokedAt) {
|
||||||
|
return { success: false, error: "Connect Google Calendar first." };
|
||||||
|
}
|
||||||
|
await prisma.staffCalendarConnection.update({
|
||||||
|
where: { staffId },
|
||||||
|
data: { selectedCalendarId: calendarId, selectedCalendarName: calendarName },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
disconnectCalendarAction,
|
||||||
|
listAvailableCalendarsAction,
|
||||||
|
selectCalendarAction,
|
||||||
|
syncNowAction,
|
||||||
|
} from "../actions/calendar";
|
||||||
|
import type { RemoteCalendarInfo } from "@/services/calendar/types";
|
||||||
|
|
||||||
|
export interface ConnectionState {
|
||||||
|
connected: boolean;
|
||||||
|
accountEmail?: string;
|
||||||
|
selectedCalendarName?: string;
|
||||||
|
lastSyncAt?: string;
|
||||||
|
lastSyncError?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarConnectionPanel({ initial, configured }: { initial: ConnectionState; configured: boolean }) {
|
||||||
|
const [state, setState] = useState(initial);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [calendars, setCalendars] = useState<RemoteCalendarInfo[] | null>(null);
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
setMessage(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await syncNowAction();
|
||||||
|
setMessage(result.success ? "Sync complete." : (result.error ?? "Sync failed."));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
setMessage(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
await disconnectCalendarAction();
|
||||||
|
setState({ connected: false });
|
||||||
|
setMessage("Disconnected. Existing calendar events were left as they are.");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCalendars = () => {
|
||||||
|
setMessage(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await listAvailableCalendarsAction();
|
||||||
|
if (!result.success) {
|
||||||
|
setMessage(result.error ?? "Couldn't load calendars.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCalendars(result.calendars ?? []);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseCalendar = (calendar: RemoteCalendarInfo) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await selectCalendarAction(calendar.id, calendar.name);
|
||||||
|
if (result.success) {
|
||||||
|
setState((prev) => ({ ...prev, selectedCalendarName: calendar.name }));
|
||||||
|
setCalendars(null);
|
||||||
|
setMessage(`Now syncing to "${calendar.name}".`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state.connected) {
|
||||||
|
return (
|
||||||
|
<div className="border-line rounded-jumbo border bg-white p-5">
|
||||||
|
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
||||||
|
<p className="text-muted mb-4 text-xs">
|
||||||
|
Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately
|
||||||
|
from sign-in — you can use a different one if you like.
|
||||||
|
</p>
|
||||||
|
{message && <p className="text-danger mb-3 text-xs font-medium">{message}</p>}
|
||||||
|
{configured ? (
|
||||||
|
<Button asChild size="sm">
|
||||||
|
<a href="/api/staff/calendar/google/authorize">Connect Google Calendar</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted text-xs">Google Calendar sync is not configured on this deployment yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-line rounded-jumbo border bg-white p-5">
|
||||||
|
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
||||||
|
<div className="text-muted mb-4 space-y-1 text-xs">
|
||||||
|
<p>Connected as {state.accountEmail}</p>
|
||||||
|
<p>Syncing to “{state.selectedCalendarName}”</p>
|
||||||
|
{state.lastSyncAt && <p>Last synced {new Date(state.lastSyncAt).toLocaleString()}</p>}
|
||||||
|
{state.lastSyncError && <p className="text-danger">Last sync error: {state.lastSyncError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && <p className="text-muted mb-3 text-xs font-medium">{message}</p>}
|
||||||
|
|
||||||
|
{calendars ? (
|
||||||
|
<ul className="mb-4 space-y-2">
|
||||||
|
{calendars.map((calendar) => (
|
||||||
|
<li key={calendar.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => chooseCalendar(calendar)}
|
||||||
|
className="border-line w-full rounded-lg border px-3 py-2 text-left text-xs font-bold hover:bg-stone"
|
||||||
|
>
|
||||||
|
{calendar.name} {calendar.primary && "(primary)"}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" isLoading={isPending} onClick={sync}>
|
||||||
|
Sync Now
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" isLoading={isPending} onClick={loadCalendars}>
|
||||||
|
Change Calendar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" isLoading={isPending} onClick={disconnect}>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
import { InstallationStatus } from "@/generated/prisma/enums";
|
||||||
|
import { installApplication } from "../installer.service";
|
||||||
|
import type { InstallInput } from "../schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercises the real transactional installer against a real MySQL
|
||||||
|
* database — see installer.service.ts's own header for why the
|
||||||
|
* ApplicationInstallation singleton row is the concurrency lock. These
|
||||||
|
* tests own that row exclusively: any pre-existing installation state in
|
||||||
|
* this database is removed in `beforeEach` and never restored, since a
|
||||||
|
* test database is expected to be disposable (see
|
||||||
|
* docs/phase-8-summary.md's testing section) — do not point this at a
|
||||||
|
* real deployment's database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const INSTALLATION_TOKEN = "integration-test-install-token";
|
||||||
|
|
||||||
|
function buildInput(overrides: Partial<InstallInput> = {}): InstallInput {
|
||||||
|
return {
|
||||||
|
installationToken: INSTALLATION_TOKEN,
|
||||||
|
administrator: {
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: "Owner",
|
||||||
|
email: `install-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`,
|
||||||
|
phone: "",
|
||||||
|
password: "correct-horse-battery",
|
||||||
|
confirmPassword: "correct-horse-battery",
|
||||||
|
},
|
||||||
|
business: {
|
||||||
|
name: "Integration Test Barbershop",
|
||||||
|
shortName: "",
|
||||||
|
description: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
website: "",
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
addressLine1: "1 Test Street",
|
||||||
|
addressLine2: "",
|
||||||
|
city: "Testville",
|
||||||
|
region: "",
|
||||||
|
postalCode: "T1 1TT",
|
||||||
|
country: "GB",
|
||||||
|
timezone: "Europe/London",
|
||||||
|
currency: "GBP",
|
||||||
|
locale: "en-GB",
|
||||||
|
},
|
||||||
|
hours: (["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const).map((dayOfWeek) => ({
|
||||||
|
dayOfWeek,
|
||||||
|
isClosed: dayOfWeek === "SUNDAY",
|
||||||
|
opensAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 540,
|
||||||
|
closesAtMinutes: dayOfWeek === "SUNDAY" ? undefined : 1080,
|
||||||
|
})),
|
||||||
|
bookingRules: {
|
||||||
|
allowGuestBooking: false,
|
||||||
|
minNoticeMinutes: 120,
|
||||||
|
maxAdvanceDays: 60,
|
||||||
|
cancellationWindowHours: 24,
|
||||||
|
rescheduleWindowHours: 24,
|
||||||
|
slotIntervalMinutes: 30,
|
||||||
|
bufferBeforeMinutes: 0,
|
||||||
|
bufferAfterMinutes: 5,
|
||||||
|
maxAppointmentsPerDay: null,
|
||||||
|
maxAppointmentsPerStaffPerDay: null,
|
||||||
|
customerCanChooseStaff: true,
|
||||||
|
requireApproval: false,
|
||||||
|
},
|
||||||
|
branding: { logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" },
|
||||||
|
starterServices: [
|
||||||
|
{ categoryName: "Haircuts", name: "Classic Cut", description: "", price: "20.00", durationMinutes: 30, active: true, allowOnlineBooking: true },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetInstallationSingleton(): Promise<void> {
|
||||||
|
await prisma.applicationInstallation.deleteMany({ where: { id: "singleton" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupInstalledBusiness(businessId: string | undefined): Promise<void> {
|
||||||
|
if (!businessId) return;
|
||||||
|
const staff = await prisma.staffProfile.findMany({ where: { businessId }, select: { id: true, userId: true } });
|
||||||
|
const userIds = staff.map((s) => s.userId);
|
||||||
|
await prisma.staffService.deleteMany({ where: { staffId: { in: staff.map((s) => s.id) } } });
|
||||||
|
await prisma.staffProfile.deleteMany({ where: { businessId } });
|
||||||
|
await prisma.service.deleteMany({ where: { businessId } });
|
||||||
|
await prisma.serviceCategory.deleteMany({ where: { businessId } });
|
||||||
|
await prisma.rolePermission.deleteMany({ where: { role: { businessId } } });
|
||||||
|
await prisma.role.deleteMany({ where: { businessId } });
|
||||||
|
await prisma.auditLog.deleteMany({ where: { entityId: businessId } });
|
||||||
|
await prisma.loyaltyProgramme.deleteMany({ where: { businessId } });
|
||||||
|
await prisma.bookingRule.deleteMany({ where: { businessId } });
|
||||||
|
await prisma.businessSettings.deleteMany({ where: { businessId } });
|
||||||
|
await prisma.businessHours.deleteMany({ where: { businessId } });
|
||||||
|
await prisma.businessLocation.deleteMany({ where: { businessId } });
|
||||||
|
await prisma.business.deleteMany({ where: { id: businessId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
await resetInstallationSingleton();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("installApplication (integration)", () => {
|
||||||
|
let createdBusinessId: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetInstallationSingleton();
|
||||||
|
createdBusinessId = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanupInstalledBusiness(createdBusinessId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an incorrect installation token without creating anything", async () => {
|
||||||
|
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||||
|
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||||
|
try {
|
||||||
|
const result = await installApplication(prisma, {
|
||||||
|
input: buildInput({ installationToken: "wrong-token" }),
|
||||||
|
installedVersion: "test",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) expect(result.code).toBe("INVALID_TOKEN");
|
||||||
|
|
||||||
|
const business = await prisma.business.findFirst({ where: { name: "Integration Test Barbershop" } });
|
||||||
|
expect(business).toBeNull();
|
||||||
|
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
|
||||||
|
expect(installation).toBeNull();
|
||||||
|
} finally {
|
||||||
|
process.env.INSTALLATION_TOKEN = originalToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the business, administrator, permissions, booking rules, and starter services transactionally", async () => {
|
||||||
|
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||||
|
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||||
|
try {
|
||||||
|
const result = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (!result.success) return;
|
||||||
|
createdBusinessId = result.businessId;
|
||||||
|
|
||||||
|
const installation = await prisma.applicationInstallation.findUnique({ where: { id: "singleton" } });
|
||||||
|
expect(installation?.status).toBe(InstallationStatus.INSTALLED);
|
||||||
|
expect(installation?.primaryBusinessId).toBe(result.businessId);
|
||||||
|
expect(installation?.initialAdminUserId).toBe(result.userId);
|
||||||
|
|
||||||
|
const business = await prisma.business.findUnique({
|
||||||
|
where: { id: result.businessId },
|
||||||
|
include: { bookingRule: true, locations: true, hours: true, loyaltyProgramme: true },
|
||||||
|
});
|
||||||
|
expect(business?.name).toBe("Integration Test Barbershop");
|
||||||
|
// Guest booking off by default, per the brief — confirmed end to end here.
|
||||||
|
expect(business?.bookingRule?.allowGuestBooking).toBe(false);
|
||||||
|
expect(business?.locations).toHaveLength(1);
|
||||||
|
expect(business?.hours).toHaveLength(7);
|
||||||
|
expect(business?.loyaltyProgramme?.isEnabled).toBe(false);
|
||||||
|
|
||||||
|
const roles = await prisma.role.findMany({ where: { businessId: result.businessId }, include: { permissions: true } });
|
||||||
|
const ownerRole = roles.find((r) => r.name === "Owner");
|
||||||
|
const barberRole = roles.find((r) => r.name === "Barber");
|
||||||
|
expect(ownerRole).toBeDefined();
|
||||||
|
expect(barberRole).toBeDefined();
|
||||||
|
expect(ownerRole!.permissions.length).toBeGreaterThan(barberRole!.permissions.length);
|
||||||
|
|
||||||
|
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: result.userId }, include: { services: true } });
|
||||||
|
expect(staffProfile?.roleId).toBe(ownerRole!.id);
|
||||||
|
// The initial owner is eligible for every starter service — see the brief's "initial owner is service-eligible."
|
||||||
|
expect(staffProfile?.services).toHaveLength(1);
|
||||||
|
|
||||||
|
const services = await prisma.service.findMany({ where: { businessId: result.businessId } });
|
||||||
|
expect(services).toHaveLength(1);
|
||||||
|
expect(services[0]?.name).toBe("Classic Cut");
|
||||||
|
|
||||||
|
const auditLog = await prisma.auditLog.findFirst({ where: { entityId: result.businessId, action: "APPLICATION_INSTALLED" } });
|
||||||
|
expect(auditLog).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
process.env.INSTALLATION_TOKEN = originalToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to install a second time once already installed", async () => {
|
||||||
|
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||||
|
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||||
|
try {
|
||||||
|
const first = await installApplication(prisma, { input: buildInput(), installedVersion: "test" });
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
if (first.success) createdBusinessId = first.businessId;
|
||||||
|
|
||||||
|
const second = await installApplication(prisma, {
|
||||||
|
input: buildInput({
|
||||||
|
administrator: { ...buildInput().administrator, email: `second-${Date.now()}@example.com` },
|
||||||
|
}),
|
||||||
|
installedVersion: "test",
|
||||||
|
});
|
||||||
|
expect(second.success).toBe(false);
|
||||||
|
if (!second.success) expect(second.code).toBe("ALREADY_INSTALLED");
|
||||||
|
} finally {
|
||||||
|
process.env.INSTALLATION_TOKEN = originalToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents two concurrent installation attempts from both succeeding", async () => {
|
||||||
|
const originalToken = process.env.INSTALLATION_TOKEN;
|
||||||
|
process.env.INSTALLATION_TOKEN = INSTALLATION_TOKEN;
|
||||||
|
try {
|
||||||
|
const inputA = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-a-${Date.now()}@example.com` } });
|
||||||
|
const inputB = buildInput({ administrator: { ...buildInput().administrator, email: `concurrent-b-${Date.now()}@example.com` } });
|
||||||
|
|
||||||
|
const [resultA, resultB] = await Promise.all([
|
||||||
|
installApplication(prisma, { input: inputA, installedVersion: "test" }),
|
||||||
|
installApplication(prisma, { input: inputB, installedVersion: "test" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const successes = [resultA, resultB].filter((r) => r.success);
|
||||||
|
expect(successes).toHaveLength(1);
|
||||||
|
const winner = successes[0];
|
||||||
|
if (winner?.success) createdBusinessId = winner.businessId;
|
||||||
|
|
||||||
|
const businessCount = await prisma.business.count({
|
||||||
|
where: { name: "Integration Test Barbershop" },
|
||||||
|
});
|
||||||
|
expect(businessCount).toBe(1);
|
||||||
|
} finally {
|
||||||
|
process.env.INSTALLATION_TOKEN = originalToken;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { signIn } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getRequestIp } from "@/lib/security/request-ip";
|
||||||
|
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||||
|
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||||
|
import { installApplication } from "../installer.service";
|
||||||
|
import { installSchema } from "../schema";
|
||||||
|
import type { InstallInput } from "../schema";
|
||||||
|
|
||||||
|
const packageVersion = process.env.npm_package_version ?? "1.0.0";
|
||||||
|
|
||||||
|
export interface VerifyTokenResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cheap, read-only check the wizard's first step calls for immediate
|
||||||
|
* feedback — never reveals *why* a token is wrong, and never on its own
|
||||||
|
* unlocks anything server-side: the real gate is `submitInstallationAction`
|
||||||
|
* re-checking the same token before it writes anything (see
|
||||||
|
* installer.service.ts's own header for why it's checked twice).
|
||||||
|
*/
|
||||||
|
export async function verifyInstallationTokenAction(token: string): Promise<VerifyTokenResult> {
|
||||||
|
const ip = await getRequestIp();
|
||||||
|
const { allowed } = checkRateLimit(`install-token:${ip}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 });
|
||||||
|
if (!allowed) {
|
||||||
|
return { valid: false, error: "Too many attempts. Please try again later." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = process.env.INSTALLATION_TOKEN;
|
||||||
|
if (!expected) {
|
||||||
|
return { valid: false, error: "Installation is not available on this deployment." };
|
||||||
|
}
|
||||||
|
if (!token || !secretsMatch(token, expected)) {
|
||||||
|
return { valid: false, error: "Incorrect installation token." };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitInstallationResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
fieldErrors?: Record<string, string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitInstallationAction(input: InstallInput): Promise<SubmitInstallationResult> {
|
||||||
|
const ip = await getRequestIp();
|
||||||
|
const { allowed } = checkRateLimit(`install-submit:${ip}`, { maxAttempts: 10, windowMs: 15 * 60 * 1000 });
|
||||||
|
if (!allowed) {
|
||||||
|
return { success: false, error: "Too many attempts. Please try again later." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = installSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, error: "Please fix the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await installApplication(prisma, { input: parsed.data, installedVersion: packageVersion });
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, error: result.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
await signIn("credentials", {
|
||||||
|
email: parsed.data.administrator.email,
|
||||||
|
password: parsed.data.administrator.password,
|
||||||
|
redirectTo: "/owner?installed=1",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { submitInstallationAction, verifyInstallationTokenAction } from "../actions/install";
|
||||||
|
import type { InstallInput } from "../schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds every wizard step's data as plain React state, not a signed
|
||||||
|
* server-side draft — nothing is validated or written to the database
|
||||||
|
* until the single final `submitInstallationAction` call on Step 9's
|
||||||
|
* confirmation (see installer.service.ts's own header). That means a
|
||||||
|
* page refresh loses progress, a deliberate simplification: this page is
|
||||||
|
* only ever visited once, by the person physically standing up the
|
||||||
|
* deployment, so "refresh loses your place" is a fair trade for not
|
||||||
|
* needing a signed-cookie draft model and its own cleanup story.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WEEKDAYS = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const;
|
||||||
|
type WeekdayCode = (typeof WEEKDAYS)[number];
|
||||||
|
const DAY_LABEL: Record<WeekdayCode, string> = {
|
||||||
|
MONDAY: "Monday",
|
||||||
|
TUESDAY: "Tuesday",
|
||||||
|
WEDNESDAY: "Wednesday",
|
||||||
|
THURSDAY: "Thursday",
|
||||||
|
FRIDAY: "Friday",
|
||||||
|
SATURDAY: "Saturday",
|
||||||
|
SUNDAY: "Sunday",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HoursDayState {
|
||||||
|
dayOfWeek: WeekdayCode;
|
||||||
|
isClosed: boolean;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StarterServiceState {
|
||||||
|
key: string;
|
||||||
|
categoryName: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
active: boolean;
|
||||||
|
allowOnlineBooking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMinutes(time: string): number {
|
||||||
|
const [h, m] = time.split(":").map(Number);
|
||||||
|
return (h ?? 0) * 60 + (m ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
"Welcome",
|
||||||
|
"Administrator",
|
||||||
|
"Business Details",
|
||||||
|
"Location & Contact",
|
||||||
|
"Opening Hours",
|
||||||
|
"Booking Settings",
|
||||||
|
"Branding",
|
||||||
|
"Starter Services",
|
||||||
|
"Review",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
let starterServiceKeySeq = 0;
|
||||||
|
function newStarterServiceKey(): string {
|
||||||
|
starterServiceKeySeq += 1;
|
||||||
|
return `starter-${starterServiceKeySeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STARTER_SERVICES: StarterServiceState[] = [
|
||||||
|
{
|
||||||
|
key: newStarterServiceKey(),
|
||||||
|
categoryName: "Haircuts",
|
||||||
|
name: "Classic Haircut",
|
||||||
|
description: "",
|
||||||
|
price: "25.00",
|
||||||
|
durationMinutes: 30,
|
||||||
|
active: true,
|
||||||
|
allowOnlineBooking: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: newStarterServiceKey(),
|
||||||
|
categoryName: "Beard Services",
|
||||||
|
name: "Beard Trim",
|
||||||
|
description: "",
|
||||||
|
price: "15.00",
|
||||||
|
durationMinutes: 20,
|
||||||
|
active: true,
|
||||||
|
allowOnlineBooking: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: newStarterServiceKey(),
|
||||||
|
categoryName: "Packages",
|
||||||
|
name: "Haircut & Beard Combo",
|
||||||
|
description: "",
|
||||||
|
price: "35.00",
|
||||||
|
durationMinutes: 45,
|
||||||
|
active: true,
|
||||||
|
allowOnlineBooking: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function InstallWizard() {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 1
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" });
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
const [business, setBusiness] = useState({ name: "", shortName: "", description: "", email: "", phone: "", website: "" });
|
||||||
|
|
||||||
|
// Step 4
|
||||||
|
const [location, setLocation] = useState({
|
||||||
|
addressLine1: "",
|
||||||
|
addressLine2: "",
|
||||||
|
city: "",
|
||||||
|
region: "",
|
||||||
|
postalCode: "",
|
||||||
|
country: "GB",
|
||||||
|
timezone: "Europe/London",
|
||||||
|
currency: "GBP",
|
||||||
|
locale: "en-GB",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5
|
||||||
|
const [hours, setHours] = useState<HoursDayState[]>(
|
||||||
|
WEEKDAYS.map((dayOfWeek) => ({
|
||||||
|
dayOfWeek,
|
||||||
|
isClosed: dayOfWeek === "SUNDAY",
|
||||||
|
startTime: "09:00",
|
||||||
|
endTime: "18:00",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 6 — guest booking defaults OFF, per the brief.
|
||||||
|
const [bookingRules, setBookingRules] = useState({
|
||||||
|
allowGuestBooking: false,
|
||||||
|
minNoticeMinutes: 120,
|
||||||
|
maxAdvanceDays: 60,
|
||||||
|
cancellationWindowHours: 24,
|
||||||
|
rescheduleWindowHours: 24,
|
||||||
|
slotIntervalMinutes: 30,
|
||||||
|
bufferBeforeMinutes: 0,
|
||||||
|
bufferAfterMinutes: 5,
|
||||||
|
maxAppointmentsPerDay: null as number | null,
|
||||||
|
maxAppointmentsPerStaffPerDay: null as number | null,
|
||||||
|
customerCanChooseStaff: true,
|
||||||
|
requireApproval: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 7
|
||||||
|
const [branding, setBranding] = useState({ logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" });
|
||||||
|
|
||||||
|
// Step 8
|
||||||
|
const [starterServices, setStarterServices] = useState<StarterServiceState[]>(DEFAULT_STARTER_SERVICES);
|
||||||
|
|
||||||
|
const verifyToken = () => {
|
||||||
|
setTokenError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await verifyInstallationTokenAction(token);
|
||||||
|
if (!result.valid) {
|
||||||
|
setTokenError(result.error ?? "Incorrect installation token.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep(2);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDay = (dayOfWeek: WeekdayCode, patch: Partial<HoursDayState>) => {
|
||||||
|
setHours((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
|
||||||
|
};
|
||||||
|
const copyMondayToWeekdays = () => {
|
||||||
|
const monday = hours.find((d) => d.dayOfWeek === "MONDAY");
|
||||||
|
if (!monday) return;
|
||||||
|
setHours((prev) =>
|
||||||
|
prev.map((d) =>
|
||||||
|
d.dayOfWeek === "SATURDAY" || d.dayOfWeek === "SUNDAY" || d.dayOfWeek === "MONDAY"
|
||||||
|
? d
|
||||||
|
: { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const copyMondayToAll = () => {
|
||||||
|
const monday = hours.find((d) => d.dayOfWeek === "MONDAY");
|
||||||
|
if (!monday) return;
|
||||||
|
setHours((prev) =>
|
||||||
|
prev.map((d) => (d.dayOfWeek === "MONDAY" ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStarter = (key: string, patch: Partial<StarterServiceState>) => {
|
||||||
|
setStarterServices((prev) => prev.map((s) => (s.key === key ? { ...s, ...patch } : s)));
|
||||||
|
};
|
||||||
|
const removeStarter = (key: string) => {
|
||||||
|
setStarterServices((prev) => prev.filter((s) => s.key !== key));
|
||||||
|
};
|
||||||
|
const addStarter = () => {
|
||||||
|
setStarterServices((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
key: newStarterServiceKey(),
|
||||||
|
categoryName: "Haircuts",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
price: "0.00",
|
||||||
|
durationMinutes: 30,
|
||||||
|
active: true,
|
||||||
|
allowOnlineBooking: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
setSubmitError(null);
|
||||||
|
const input: InstallInput = {
|
||||||
|
installationToken: token,
|
||||||
|
administrator: admin,
|
||||||
|
business,
|
||||||
|
location,
|
||||||
|
hours: hours.map((d) => ({
|
||||||
|
dayOfWeek: d.dayOfWeek,
|
||||||
|
isClosed: d.isClosed,
|
||||||
|
...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }),
|
||||||
|
})),
|
||||||
|
bookingRules,
|
||||||
|
branding,
|
||||||
|
starterServices: starterServices
|
||||||
|
.filter((s) => s.name.trim())
|
||||||
|
.map((s) => ({
|
||||||
|
categoryName: s.categoryName,
|
||||||
|
name: s.name,
|
||||||
|
description: s.description,
|
||||||
|
price: s.price,
|
||||||
|
durationMinutes: s.durationMinutes,
|
||||||
|
active: s.active,
|
||||||
|
allowOnlineBooking: s.allowOnlineBooking,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await submitInstallationAction(input);
|
||||||
|
if (!result.success) {
|
||||||
|
setSubmitError(result.error ?? "Installation failed. Please try again.");
|
||||||
|
}
|
||||||
|
// On success, submitInstallationAction signs the administrator in
|
||||||
|
// and redirects server-side — nothing else to do here.
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const canProceedFromStep = (s: number): boolean => {
|
||||||
|
switch (s) {
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
!!admin.firstName.trim() &&
|
||||||
|
!!admin.lastName.trim() &&
|
||||||
|
!!admin.email.trim() &&
|
||||||
|
admin.password.length >= 8 &&
|
||||||
|
admin.password === admin.confirmPassword
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return !!business.name.trim();
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
!!location.addressLine1.trim() &&
|
||||||
|
!!location.city.trim() &&
|
||||||
|
!!location.postalCode.trim() &&
|
||||||
|
!!location.timezone.trim() &&
|
||||||
|
location.currency.trim().length === 3
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||||
|
Step {step} of {STEPS.length} — {STEPS[step - 1]}
|
||||||
|
</p>
|
||||||
|
<div className="bg-line mt-2 h-1.5 w-full overflow-hidden rounded-full">
|
||||||
|
<div className="bg-ink h-full rounded-full transition-all" style={{ width: `${(step / STEPS.length) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">Set up your business</h1>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
This wizard creates the first administrator account and configures your business — opening hours,
|
||||||
|
booking rules, and branding. It only needs to run once.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Installation Token"
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
error={tokenError ?? undefined}
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Button className="w-full" isLoading={isPending} disabled={!token} onClick={verifyToken}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">Administrator account</h2>
|
||||||
|
<p className="text-muted text-sm">This becomes the first owner — full permissions, protected from removal.</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
|
||||||
|
<Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<Input label="Email Address" type="email" value={admin.email} onChange={(e) => setAdmin({ ...admin, email: e.target.value })} required />
|
||||||
|
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={admin.password}
|
||||||
|
onChange={(e) => setAdmin({ ...admin, password: e.target.value })}
|
||||||
|
hint="At least 8 characters."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
value={admin.confirmPassword}
|
||||||
|
onChange={(e) => setAdmin({ ...admin, confirmPassword: e.target.value })}
|
||||||
|
error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">Business details</h2>
|
||||||
|
<Input label="Business Name" value={business.name} onChange={(e) => setBusiness({ ...business, name: e.target.value })} required />
|
||||||
|
<Input
|
||||||
|
label="Short Name (optional)"
|
||||||
|
hint="Used where space is tight, e.g. the home-screen icon label."
|
||||||
|
value={business.shortName}
|
||||||
|
onChange={(e) => setBusiness({ ...business, shortName: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Description (optional)"
|
||||||
|
value={business.description}
|
||||||
|
onChange={(e) => setBusiness({ ...business, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input label="Business Email (optional)" type="email" value={business.email} onChange={(e) => setBusiness({ ...business, email: e.target.value })} />
|
||||||
|
<Input label="Phone Number (optional)" type="tel" value={business.phone} onChange={(e) => setBusiness({ ...business, phone: e.target.value })} />
|
||||||
|
<Input label="Website (optional)" value={business.website} onChange={(e) => setBusiness({ ...business, website: e.target.value })} />
|
||||||
|
<StepNav onBack={() => setStep(2)} onNext={() => setStep(4)} nextDisabled={!canProceedFromStep(3)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">Location & contact</h2>
|
||||||
|
<Input label="Address Line 1" value={location.addressLine1} onChange={(e) => setLocation({ ...location, addressLine1: e.target.value })} required />
|
||||||
|
<Input label="Address Line 2 (optional)" value={location.addressLine2} onChange={(e) => setLocation({ ...location, addressLine2: e.target.value })} />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Town / City" value={location.city} onChange={(e) => setLocation({ ...location, city: e.target.value })} required />
|
||||||
|
<Input label="County (optional)" value={location.region} onChange={(e) => setLocation({ ...location, region: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Postcode" value={location.postalCode} onChange={(e) => setLocation({ ...location, postalCode: e.target.value })} required />
|
||||||
|
<Input label="Country" value={location.country} onChange={(e) => setLocation({ ...location, country: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="Timezone" value={location.timezone} onChange={(e) => setLocation({ ...location, timezone: e.target.value })} required />
|
||||||
|
<Input label="Currency" value={location.currency} onChange={(e) => setLocation({ ...location, currency: e.target.value.toUpperCase() })} maxLength={3} required />
|
||||||
|
<Input label="Locale" value={location.locale} onChange={(e) => setLocation({ ...location, locale: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<StepNav onBack={() => setStep(3)} onNext={() => setStep(5)} nextDisabled={!canProceedFromStep(4)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">Opening hours</h2>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button type="button" onClick={copyMondayToWeekdays} className="text-accent text-xs font-bold tracking-widest uppercase underline">
|
||||||
|
Copy Monday to weekdays
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
|
||||||
|
Copy Monday to all days
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
||||||
|
{hours.map((day) => (
|
||||||
|
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch checked={!day.isClosed} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isClosed: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} open`} />
|
||||||
|
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
|
||||||
|
</div>
|
||||||
|
{day.isClosed ? (
|
||||||
|
<span className="text-muted text-xs">Closed</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="time" value={day.startTime} onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })} className="border-line rounded-lg border px-2 py-1.5 text-xs" aria-label={`${DAY_LABEL[day.dayOfWeek]} opening time`} />
|
||||||
|
<span className="text-muted text-xs">to</span>
|
||||||
|
<input type="time" value={day.endTime} onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })} className="border-line rounded-lg border px-2 py-1.5 text-xs" aria-label={`${DAY_LABEL[day.dayOfWeek]} closing time`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<StepNav onBack={() => setStep(4)} onNext={() => setStep(6)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 6 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">Booking settings</h2>
|
||||||
|
<div className="rounded-jumbo border-line border bg-white p-5">
|
||||||
|
<Switch
|
||||||
|
checked={bookingRules.allowGuestBooking}
|
||||||
|
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, allowGuestBooking: checked })}
|
||||||
|
label="Allow customers to book as guests"
|
||||||
|
description="When enabled, customers can book without creating an account. When disabled, customers must sign in or create an account before confirming their booking. Off is recommended — you can turn this on later in Booking Settings."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Minimum Notice (minutes)"
|
||||||
|
type="number"
|
||||||
|
value={bookingRules.minNoticeMinutes}
|
||||||
|
onChange={(e) => setBookingRules({ ...bookingRules, minNoticeMinutes: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Max Advance Booking (days)"
|
||||||
|
type="number"
|
||||||
|
value={bookingRules.maxAdvanceDays}
|
||||||
|
onChange={(e) => setBookingRules({ ...bookingRules, maxAdvanceDays: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Cancellation Window (hours)"
|
||||||
|
type="number"
|
||||||
|
value={bookingRules.cancellationWindowHours}
|
||||||
|
onChange={(e) => setBookingRules({ ...bookingRules, cancellationWindowHours: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Reschedule Window (hours)"
|
||||||
|
type="number"
|
||||||
|
value={bookingRules.rescheduleWindowHours}
|
||||||
|
onChange={(e) => setBookingRules({ ...bookingRules, rescheduleWindowHours: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Slot Interval (minutes)"
|
||||||
|
type="number"
|
||||||
|
value={bookingRules.slotIntervalMinutes}
|
||||||
|
onChange={(e) => setBookingRules({ ...bookingRules, slotIntervalMinutes: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-jumbo border-line space-y-3 border bg-white p-5">
|
||||||
|
<Switch
|
||||||
|
checked={bookingRules.customerCanChooseStaff}
|
||||||
|
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, customerCanChooseStaff: checked })}
|
||||||
|
label="Customers can choose their barber"
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={bookingRules.requireApproval}
|
||||||
|
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, requireApproval: checked })}
|
||||||
|
label="New bookings require staff approval"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<StepNav onBack={() => setStep(5)} onNext={() => setStep(7)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 7 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">Branding</h2>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Optional — every screen in the app uses your business name and these images/colours instead of the
|
||||||
|
product name. You can add or change all of this later in Business Settings.
|
||||||
|
</p>
|
||||||
|
<Input label="Logo URL (optional)" placeholder="https://…" value={branding.logoUrl} onChange={(e) => setBranding({ ...branding, logoUrl: e.target.value })} />
|
||||||
|
<Input label="Cover Image URL (optional)" placeholder="https://…" value={branding.coverImageUrl} onChange={(e) => setBranding({ ...branding, coverImageUrl: e.target.value })} />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Theme Colour (optional)" placeholder="#111b15" value={branding.themeColor} onChange={(e) => setBranding({ ...branding, themeColor: e.target.value })} />
|
||||||
|
<Input label="Background Colour (optional)" placeholder="#f4f6f4" value={branding.backgroundColor} onChange={(e) => setBranding({ ...branding, backgroundColor: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<StepNav onBack={() => setStep(6)} onNext={() => setStep(8)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 8 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">Starter services</h2>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
A small editable starting point so your booking page isn't empty on day one. Edit, remove, or add
|
||||||
|
your own before finishing setup.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{starterServices.map((s) => (
|
||||||
|
<div key={s.key} className="rounded-jumbo border-line space-y-3 border bg-white p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Service</p>
|
||||||
|
<button type="button" onClick={() => removeStarter(s.key)} className="text-danger text-xs font-bold uppercase">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Category" value={s.categoryName} onChange={(e) => updateStarter(s.key, { categoryName: e.target.value })} />
|
||||||
|
<Input label="Service Name" value={s.name} onChange={(e) => updateStarter(s.key, { name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Price" value={s.price} onChange={(e) => updateStarter(s.key, { price: e.target.value })} />
|
||||||
|
<Input label="Duration (minutes)" type="number" value={s.durationMinutes} onChange={(e) => updateStarter(s.key, { durationMinutes: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" className="w-full" onClick={addStarter}>
|
||||||
|
Add Another Service
|
||||||
|
</Button>
|
||||||
|
<StepNav onBack={() => setStep(7)} onNext={() => setStep(9)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 9 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">Review</h2>
|
||||||
|
<ReviewSection title="Administrator">
|
||||||
|
{admin.firstName} {admin.lastName} · {admin.email}
|
||||||
|
</ReviewSection>
|
||||||
|
<ReviewSection title="Business">{business.name}</ReviewSection>
|
||||||
|
<ReviewSection title="Location">
|
||||||
|
{location.addressLine1}, {location.city}, {location.postalCode} · {location.timezone} · {location.currency}
|
||||||
|
</ReviewSection>
|
||||||
|
<ReviewSection title="Opening Hours">
|
||||||
|
{hours.filter((d) => !d.isClosed).length} day{hours.filter((d) => !d.isClosed).length === 1 ? "" : "s"} open
|
||||||
|
</ReviewSection>
|
||||||
|
<ReviewSection title="Booking Settings">
|
||||||
|
Guest booking {bookingRules.allowGuestBooking ? "enabled" : "disabled"} · {bookingRules.requireApproval ? "requires approval" : "auto-confirms"}
|
||||||
|
</ReviewSection>
|
||||||
|
<ReviewSection title="Branding">{branding.logoUrl ? "Logo set" : "No logo yet — add one later in Business Settings"}</ReviewSection>
|
||||||
|
<ReviewSection title="Starter Services">
|
||||||
|
{starterServices.filter((s) => s.name.trim()).length} service{starterServices.filter((s) => s.name.trim()).length === 1 ? "" : "s"}
|
||||||
|
</ReviewSection>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
|
{submitError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" className="flex-1" onClick={() => setStep(8)} disabled={isPending}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1" isLoading={isPending} onClick={submit}>
|
||||||
|
Confirm & Install
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepNav({ onBack, onNext, nextDisabled }: { onBack: () => void; onNext: () => void; nextDisabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" className="flex-1" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1" onClick={onNext} disabled={nextDisabled}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-jumbo border-line border bg-white p-4">
|
||||||
|
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">{title}</p>
|
||||||
|
<p className="mt-1 text-sm">{children}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import type { PrismaClient, Prisma } from "@/generated/prisma/client";
|
||||||
|
import { InstallationStatus, RewardType } from "@/generated/prisma/enums";
|
||||||
|
import { INSTALLATION_SINGLETON_ID } from "@/lib/installation/installation-state";
|
||||||
|
import { OWNER_PERMISSIONS, BARBER_PERMISSIONS } from "@/lib/roles/default-permissions";
|
||||||
|
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||||
|
import { validateBusinessProfileInput, validateOpeningHoursInput } from "@/services/business/business.service";
|
||||||
|
import { validateBookingRuleInput } from "@/services/scheduling/booking-rule.service";
|
||||||
|
import { validateCategoryInput, validateServiceInput } from "@/services/catalogue/service-catalogue.service";
|
||||||
|
import { fail, ok } from "@/lib/validation-result";
|
||||||
|
import type { ValidationResult } from "@/lib/validation-result";
|
||||||
|
import type { InstallInput } from "./schema";
|
||||||
|
|
||||||
|
export type InstallErrorCode =
|
||||||
|
| "INVALID_TOKEN"
|
||||||
|
| "ALREADY_INSTALLED"
|
||||||
|
| "VALIDATION_FAILED"
|
||||||
|
| "EMAIL_ALREADY_IN_USE"
|
||||||
|
| "INSTALL_FAILED";
|
||||||
|
|
||||||
|
export interface InstallSuccess {
|
||||||
|
success: true;
|
||||||
|
userId: string;
|
||||||
|
businessId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallFailure {
|
||||||
|
success: false;
|
||||||
|
code: InstallErrorCode;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InstallResult = InstallSuccess | InstallFailure;
|
||||||
|
|
||||||
|
function slugify(name: string): string {
|
||||||
|
const base = name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
// Appended so a second install attempt with the same business name (a
|
||||||
|
// retried/failed install, or genuinely two identically-named
|
||||||
|
// businesses in dev/test) never collides on Business.slug's unique
|
||||||
|
// constraint.
|
||||||
|
return `${base || "business"}-${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAllInput(input: InstallInput): ValidationResult<InstallErrorCode> {
|
||||||
|
const businessValidation = validateBusinessProfileInput({
|
||||||
|
name: input.business.name,
|
||||||
|
description: input.business.description || undefined,
|
||||||
|
phone: input.business.phone || undefined,
|
||||||
|
email: input.business.email || undefined,
|
||||||
|
website: input.business.website || undefined,
|
||||||
|
timezone: input.location.timezone,
|
||||||
|
currency: input.location.currency,
|
||||||
|
locale: input.location.locale,
|
||||||
|
});
|
||||||
|
if (!businessValidation.valid) {
|
||||||
|
return fail({ code: "VALIDATION_FAILED", message: businessValidation.issues[0]!.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.location.addressLine1.trim() || !input.location.city.trim() || !input.location.postalCode.trim()) {
|
||||||
|
return fail({ code: "VALIDATION_FAILED", message: "Address line 1, city, and postcode are required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursValidation = validateOpeningHoursInput(input.hours);
|
||||||
|
if (!hoursValidation.valid) {
|
||||||
|
return fail({ code: "VALIDATION_FAILED", message: hoursValidation.issues[0]!.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingRuleValidation = validateBookingRuleInput(input.bookingRules);
|
||||||
|
if (!bookingRuleValidation.valid) {
|
||||||
|
return fail({ code: "VALIDATION_FAILED", message: bookingRuleValidation.issues[0]!.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const service of input.starterServices) {
|
||||||
|
const categoryValidation = validateCategoryInput({ name: service.categoryName });
|
||||||
|
if (!categoryValidation.valid) {
|
||||||
|
return fail({ code: "VALIDATION_FAILED", message: categoryValidation.issues[0]!.message });
|
||||||
|
}
|
||||||
|
const serviceValidation = validateServiceInput({
|
||||||
|
categoryId: "placeholder", // category is created inside the transaction, before we know its id
|
||||||
|
name: service.name,
|
||||||
|
description: service.description || undefined,
|
||||||
|
durationMinutes: service.durationMinutes,
|
||||||
|
price: service.price,
|
||||||
|
bufferBeforeMinutes: 0,
|
||||||
|
bufferAfterMinutes: 0,
|
||||||
|
allowOnlineBooking: service.allowOnlineBooking,
|
||||||
|
});
|
||||||
|
if (!serviceValidation.valid) {
|
||||||
|
return fail({ code: "VALIDATION_FAILED", message: serviceValidation.issues[0]!.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a fresh deployment goes from "empty database" to "a real,
|
||||||
|
* usable business" — see docs/phase-8-summary.md for the full narrative.
|
||||||
|
* Deliberately separate from prisma/seed.ts (dev/demo fixtures only, never
|
||||||
|
* run against a real deployment) and from every existing owner-facing
|
||||||
|
* service (BusinessService, LoyaltyService, etc., which all assume a
|
||||||
|
* business already exists) — this is the one codepath that creates one.
|
||||||
|
*
|
||||||
|
* Token verification happens twice by design: once cheaply in the
|
||||||
|
* `verifyInstallationTokenAction` Server Action (so the wizard's first
|
||||||
|
* step can give immediate feedback), and again here, as the very first
|
||||||
|
* thing this function does, before any database write — a client that
|
||||||
|
* skips straight to final submission without ever calling the first
|
||||||
|
* check must still be rejected. Neither check ever reveals *why* a token
|
||||||
|
* was wrong.
|
||||||
|
*/
|
||||||
|
export async function installApplication(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: { input: InstallInput; installedVersion: string },
|
||||||
|
): Promise<InstallResult> {
|
||||||
|
const expectedToken = process.env.INSTALLATION_TOKEN;
|
||||||
|
if (!expectedToken) {
|
||||||
|
return { success: false, code: "INVALID_TOKEN", message: "Installation is not available." };
|
||||||
|
}
|
||||||
|
if (!secretsMatch(params.input.installationToken, expectedToken)) {
|
||||||
|
return { success: false, code: "INVALID_TOKEN", message: "Installation is not available." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateAllInput(params.input);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return { success: false, code: "VALIDATION_FAILED", message: validation.issues[0]!.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({ where: { email: params.input.administrator.email.toLowerCase() } });
|
||||||
|
if (existingUser) {
|
||||||
|
return { success: false, code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// First statement, and the concurrency lock — see
|
||||||
|
// ApplicationInstallation's own header in prisma/schema.prisma. A
|
||||||
|
// concurrent second install attempt blocks on this same
|
||||||
|
// primary-key insert and then fails with a unique-constraint
|
||||||
|
// violation once this transaction commits (or proceeds cleanly if
|
||||||
|
// this transaction instead rolls back on a later failure).
|
||||||
|
await tx.applicationInstallation.create({
|
||||||
|
data: { id: INSTALLATION_SINGLETON_ID, status: InstallationStatus.INSTALLING },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { administrator, business: businessInput, location, hours, bookingRules, branding, starterServices } =
|
||||||
|
params.input;
|
||||||
|
|
||||||
|
const business = await tx.business.create({
|
||||||
|
data: {
|
||||||
|
slug: slugify(businessInput.name),
|
||||||
|
name: businessInput.name.trim(),
|
||||||
|
shortName: businessInput.shortName?.trim() || null,
|
||||||
|
description: businessInput.description?.trim() || null,
|
||||||
|
logoUrl: branding.logoUrl?.trim() || null,
|
||||||
|
coverImageUrl: branding.coverImageUrl?.trim() || null,
|
||||||
|
themeColor: branding.themeColor?.trim() || null,
|
||||||
|
backgroundColor: branding.backgroundColor?.trim() || null,
|
||||||
|
phone: businessInput.phone?.trim() || null,
|
||||||
|
email: businessInput.email?.trim() || null,
|
||||||
|
website: businessInput.website?.trim() || null,
|
||||||
|
timezone: location.timezone,
|
||||||
|
currency: location.currency.toUpperCase(),
|
||||||
|
locale: location.locale,
|
||||||
|
locations: {
|
||||||
|
create: {
|
||||||
|
addressLine1: location.addressLine1.trim(),
|
||||||
|
addressLine2: location.addressLine2?.trim() || null,
|
||||||
|
city: location.city.trim(),
|
||||||
|
region: location.region?.trim() || null,
|
||||||
|
postalCode: location.postalCode.trim(),
|
||||||
|
country: location.country.trim(),
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: { create: {} },
|
||||||
|
hours: {
|
||||||
|
create: hours.map((day) => ({
|
||||||
|
dayOfWeek: day.dayOfWeek,
|
||||||
|
isClosed: day.isClosed,
|
||||||
|
opensAtMinutes: day.isClosed ? null : (day.opensAtMinutes ?? null),
|
||||||
|
closesAtMinutes: day.isClosed ? null : (day.closesAtMinutes ?? null),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
bookingRule: { create: { ...bookingRules } },
|
||||||
|
// Disabled by default — the brief is explicit that installation
|
||||||
|
// must not silently turn loyalty on; the owner opts in later via
|
||||||
|
// /owner/settings/loyalty, same as every other post-install
|
||||||
|
// configuration choice.
|
||||||
|
loyaltyProgramme: {
|
||||||
|
create: { isEnabled: false, stampsRequired: 10, rewardType: RewardType.PERCENTAGE },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [ownerRole] = await Promise.all([
|
||||||
|
tx.role.create({
|
||||||
|
data: {
|
||||||
|
businessId: business.id,
|
||||||
|
name: "Owner",
|
||||||
|
isSystem: true,
|
||||||
|
permissions: { create: OWNER_PERMISSIONS.map((permission) => ({ permission })) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tx.role.create({
|
||||||
|
data: {
|
||||||
|
businessId: business.id,
|
||||||
|
name: "Barber",
|
||||||
|
isSystem: true,
|
||||||
|
permissions: { create: BARBER_PERMISSIONS.map((permission) => ({ permission })) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(administrator.password, 12);
|
||||||
|
const user = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
email: administrator.email.toLowerCase(),
|
||||||
|
name: `${administrator.firstName.trim()} ${administrator.lastName.trim()}`,
|
||||||
|
phone: administrator.phone?.trim() || null,
|
||||||
|
passwordHash,
|
||||||
|
emailVerified: new Date(),
|
||||||
|
staffProfile: {
|
||||||
|
create: {
|
||||||
|
businessId: business.id,
|
||||||
|
roleId: ownerRole.id,
|
||||||
|
isBookableOnline: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { staffProfile: true },
|
||||||
|
});
|
||||||
|
const staffId = user.staffProfile!.id;
|
||||||
|
|
||||||
|
// Starter services: one ServiceCategory per distinct categoryName,
|
||||||
|
// created in submission order, then a Service row per entry —
|
||||||
|
// never silently invented data, only what the installer explicitly
|
||||||
|
// reviewed and approved in Step 8 (see install-wizard.tsx).
|
||||||
|
const categoryIdByName = new Map<string, string>();
|
||||||
|
for (const starter of starterServices) {
|
||||||
|
if (!categoryIdByName.has(starter.categoryName)) {
|
||||||
|
const category = await tx.serviceCategory.create({
|
||||||
|
data: { businessId: business.id, name: starter.categoryName },
|
||||||
|
});
|
||||||
|
categoryIdByName.set(starter.categoryName, category.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const createdServiceIds: string[] = [];
|
||||||
|
for (const starter of starterServices) {
|
||||||
|
const categoryId = categoryIdByName.get(starter.categoryName)!;
|
||||||
|
const service = await tx.service.create({
|
||||||
|
data: {
|
||||||
|
businessId: business.id,
|
||||||
|
categoryId,
|
||||||
|
name: starter.name.trim(),
|
||||||
|
description: starter.description?.trim() || null,
|
||||||
|
durationMinutes: starter.durationMinutes,
|
||||||
|
price: starter.price,
|
||||||
|
isActive: starter.active,
|
||||||
|
allowOnlineBooking: starter.allowOnlineBooking,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdServiceIds.push(service.id);
|
||||||
|
}
|
||||||
|
if (createdServiceIds.length > 0) {
|
||||||
|
await tx.staffService.createMany({
|
||||||
|
data: createdServiceIds.map((serviceId) => ({ staffId, serviceId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.applicationInstallation.update({
|
||||||
|
where: { id: INSTALLATION_SINGLETON_ID },
|
||||||
|
data: {
|
||||||
|
status: InstallationStatus.INSTALLED,
|
||||||
|
installedAt: new Date(),
|
||||||
|
installedVersion: params.installedVersion,
|
||||||
|
initialAdminUserId: user.id,
|
||||||
|
primaryBusinessId: business.id,
|
||||||
|
metadata: { starterServiceCount: createdServiceIds.length } satisfies Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: "APPLICATION_INSTALLED",
|
||||||
|
entityType: "Business",
|
||||||
|
entityId: business.id,
|
||||||
|
metadata: { businessName: business.name } satisfies Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { userId: user.id, businessId: business.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, userId: result.userId, businessId: result.businessId };
|
||||||
|
} catch (error) {
|
||||||
|
// A concurrent installer won the create() race — see the header
|
||||||
|
// comment above. Anything else is an unexpected failure; either way,
|
||||||
|
// the transaction rolled back and left no partial data (see
|
||||||
|
// ApplicationInstallation's own header for why that's true even for
|
||||||
|
// the lock row itself).
|
||||||
|
const isDuplicateSingleton =
|
||||||
|
typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "P2002";
|
||||||
|
if (isDuplicateSingleton) {
|
||||||
|
return { success: false, code: "ALREADY_INSTALLED", message: "This application has already been installed." };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "INSTALL_FAILED",
|
||||||
|
message: "Installation failed. No changes were saved — you can safely try again.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the installation wizard's shape — shared
|
||||||
|
* between the client wizard component and the final Server Action (see
|
||||||
|
* actions/install.ts). Deliberately one flat schema, not one per wizard
|
||||||
|
* step: the wizard itself is client-side-only state (see
|
||||||
|
* install-wizard.tsx's own header for why) and nothing is validated or
|
||||||
|
* written server-side until the single final submission, so there is only
|
||||||
|
* ever one real validation boundary to define.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const installAdministratorSchema = z
|
||||||
|
.object({
|
||||||
|
firstName: z.string().trim().min(1, "First name is required"),
|
||||||
|
lastName: z.string().trim().min(1, "Last name is required"),
|
||||||
|
email: z.email("Enter a valid email address"),
|
||||||
|
phone: z.string().trim().optional().or(z.literal("")),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Passwords don't match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const installBusinessSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Business name is required"),
|
||||||
|
shortName: z.string().trim().max(40).optional().or(z.literal("")),
|
||||||
|
description: z.string().trim().optional().or(z.literal("")),
|
||||||
|
email: z.email("Enter a valid email address").optional().or(z.literal("")),
|
||||||
|
phone: z.string().trim().optional().or(z.literal("")),
|
||||||
|
website: z.string().trim().optional().or(z.literal("")),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const installLocationSchema = z.object({
|
||||||
|
addressLine1: z.string().trim().min(1, "Address line 1 is required"),
|
||||||
|
addressLine2: z.string().trim().optional().or(z.literal("")),
|
||||||
|
city: z.string().trim().min(1, "Town or city is required"),
|
||||||
|
region: z.string().trim().optional().or(z.literal("")),
|
||||||
|
postalCode: z.string().trim().min(1, "Postcode is required"),
|
||||||
|
country: z.string().trim().min(1, "Country is required"),
|
||||||
|
timezone: z.string().trim().min(1, "Timezone is required"),
|
||||||
|
currency: z.string().trim().length(3, "Currency should be a 3-letter code, e.g. GBP"),
|
||||||
|
locale: z.string().trim().min(1, "Locale is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const weekdaySchema = z.enum(["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]);
|
||||||
|
|
||||||
|
export const installHoursDaySchema = z.object({
|
||||||
|
dayOfWeek: weekdaySchema,
|
||||||
|
isClosed: z.boolean(),
|
||||||
|
opensAtMinutes: z.number().int().min(0).max(1439).optional(),
|
||||||
|
closesAtMinutes: z.number().int().min(0).max(1439).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const installBookingRulesSchema = z.object({
|
||||||
|
allowGuestBooking: z.boolean(),
|
||||||
|
minNoticeMinutes: z.number().int().min(0),
|
||||||
|
maxAdvanceDays: z.number().int().min(1),
|
||||||
|
cancellationWindowHours: z.number().int().min(0),
|
||||||
|
rescheduleWindowHours: z.number().int().min(0),
|
||||||
|
slotIntervalMinutes: z.number().int().min(5).max(120),
|
||||||
|
bufferBeforeMinutes: z.number().int().min(0),
|
||||||
|
bufferAfterMinutes: z.number().int().min(0),
|
||||||
|
maxAppointmentsPerDay: z.number().int().min(1).nullable(),
|
||||||
|
maxAppointmentsPerStaffPerDay: z.number().int().min(1).nullable(),
|
||||||
|
customerCanChooseStaff: z.boolean(),
|
||||||
|
requireApproval: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const installBrandingSchema = z.object({
|
||||||
|
logoUrl: z.string().trim().optional().or(z.literal("")),
|
||||||
|
coverImageUrl: z.string().trim().optional().or(z.literal("")),
|
||||||
|
themeColor: z.string().trim().optional().or(z.literal("")),
|
||||||
|
backgroundColor: z.string().trim().optional().or(z.literal("")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const decimalPrice = z.string().regex(/^\d+(\.\d{1,2})?$/, "Enter a price like 22.50");
|
||||||
|
|
||||||
|
export const installStarterServiceSchema = z.object({
|
||||||
|
categoryName: z.string().trim().min(1),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
description: z.string().trim().optional().or(z.literal("")),
|
||||||
|
price: decimalPrice,
|
||||||
|
durationMinutes: z.number().int().min(5).max(480),
|
||||||
|
active: z.boolean(),
|
||||||
|
allowOnlineBooking: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const installSchema = z.object({
|
||||||
|
installationToken: z.string().min(1, "Installation token is required"),
|
||||||
|
administrator: installAdministratorSchema,
|
||||||
|
business: installBusinessSchema,
|
||||||
|
location: installLocationSchema,
|
||||||
|
hours: z.array(installHoursDaySchema).length(7),
|
||||||
|
bookingRules: installBookingRulesSchema,
|
||||||
|
branding: installBrandingSchema,
|
||||||
|
starterServices: z.array(installStarterServiceSchema).max(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InstallAdministratorInput = z.infer<typeof installAdministratorSchema>;
|
||||||
|
export type InstallBusinessInput = z.infer<typeof installBusinessSchema>;
|
||||||
|
export type InstallLocationInput = z.infer<typeof installLocationSchema>;
|
||||||
|
export type InstallHoursDayInput = z.infer<typeof installHoursDaySchema>;
|
||||||
|
export type InstallBookingRulesInput = z.infer<typeof installBookingRulesSchema>;
|
||||||
|
export type InstallBrandingInput = z.infer<typeof installBrandingSchema>;
|
||||||
|
export type InstallStarterServiceInput = z.infer<typeof installStarterServiceSchema>;
|
||||||
|
export type InstallInput = z.infer<typeof installSchema>;
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/services/scheduling/appointment.service";
|
} from "@/services/scheduling/appointment.service";
|
||||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||||
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingBarberChanged, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingBarberChanged, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
||||||
|
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||||
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
|
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export async function ownerConfirmAppointmentAction(appointmentId: string): Prom
|
|||||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||||
}
|
}
|
||||||
await notifyBookingApproved(prisma, result.appointment);
|
await notifyBookingApproved(prisma, result.appointment);
|
||||||
|
await syncAppointmentSafely(prisma, appointmentId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +127,7 @@ export async function ownerMarkNoShowAction(appointmentId: string): Promise<Staf
|
|||||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||||
}
|
}
|
||||||
await notifyAppointmentNoShow(prisma, result.appointment);
|
await notifyAppointmentNoShow(prisma, result.appointment);
|
||||||
|
await syncAppointmentSafely(prisma, appointmentId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +143,11 @@ export async function ownerCancelAppointmentAction(params: {
|
|||||||
cancelledByUserId: owner.userId,
|
cancelledByUserId: owner.userId,
|
||||||
reason: params.reason,
|
reason: params.reason,
|
||||||
});
|
});
|
||||||
return result.success
|
if (!result.success) {
|
||||||
? { success: true }
|
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
|
||||||
: { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
|
}
|
||||||
|
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OwnerRescheduleResult {
|
export interface OwnerRescheduleResult {
|
||||||
@@ -181,5 +186,6 @@ export async function ownerRescheduleAppointmentAction(params: {
|
|||||||
await notifyBookingBarberChanged(prisma, result.appointment);
|
await notifyBookingBarberChanged(prisma, result.appointment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -136,8 +136,8 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
|||||||
onCheckedChange={(v) => set("customerCanChooseStaff", v)}
|
onCheckedChange={(v) => set("customerCanChooseStaff", v)}
|
||||||
/>
|
/>
|
||||||
<Switch
|
<Switch
|
||||||
label="Allow guest bookings"
|
label="Allow customers to book as guests"
|
||||||
description="Let customers book without an account."
|
description="When enabled, customers can book without creating an account. When disabled, customers must sign in or create an account before confirming their booking."
|
||||||
checked={form.allowGuestBooking}
|
checked={form.allowGuestBooking}
|
||||||
onCheckedChange={(v) => set("allowGuestBooking", v)}
|
onCheckedChange={(v) => set("allowGuestBooking", v)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,79 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useInstallPrompt } from "../hooks/use-install-prompt";
|
||||||
interface BeforeInstallPromptEvent extends Event {
|
|
||||||
prompt: () => Promise<void>;
|
|
||||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStandalone(): boolean {
|
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
return (
|
|
||||||
window.matchMedia("(display-mode: standalone)").matches ||
|
|
||||||
// iOS Safari's own (non-standard, but the only signal it exposes).
|
|
||||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIos(): boolean {
|
|
||||||
if (typeof navigator === "undefined") return false;
|
|
||||||
return /iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chrome/Edge/Android fire `beforeinstallprompt` when the manifest +
|
* Chrome/Edge/Android fire `beforeinstallprompt` when the manifest +
|
||||||
* service worker criteria are met — we capture and defer it (the spec
|
* service worker criteria are met — captured and triggered from this
|
||||||
* requires preventDefault() to control exactly when the native prompt
|
* button instead of an unsolicited popup (see use-install-prompt.ts,
|
||||||
* shows) and trigger it from this button instead of an unsolicited popup.
|
* shared with WelcomeInstallCard so there's exactly one PWA install
|
||||||
* iOS Safari never fires that event at all, so it gets manual
|
* implementation, not two). iOS Safari never fires that event at all, so
|
||||||
* "Add to Home Screen" instructions instead — there's no programmatic
|
* it gets manual "Add to Home Screen" instructions instead. `businessName`
|
||||||
* install API on that platform.
|
* comes from the caller — every Settings page already fetches branding
|
||||||
|
* server-side, so this client component doesn't need to.
|
||||||
*/
|
*/
|
||||||
export function InstallAppButton() {
|
export function InstallAppButton({ businessName }: { businessName: string }) {
|
||||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
|
||||||
const [installed, setInstalled] = useState(() => isStandalone());
|
|
||||||
const [showIosInstructions, setShowIosInstructions] = useState(false);
|
const [showIosInstructions, setShowIosInstructions] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onBeforeInstallPrompt = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
|
||||||
};
|
|
||||||
const onInstalled = () => {
|
|
||||||
setInstalled(true);
|
|
||||||
setDeferredPrompt(null);
|
|
||||||
};
|
|
||||||
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
|
||||||
window.addEventListener("appinstalled", onInstalled);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
|
||||||
window.removeEventListener("appinstalled", onInstalled);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (installed) {
|
if (installed) {
|
||||||
return <p className="text-muted text-sm">NextChair is installed on this device.</p>;
|
return <p className="text-muted text-sm">{businessName} is installed on this device.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deferredPrompt) {
|
if (canPromptNatively) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button size="sm" onClick={promptInstall}>
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
await deferredPrompt.prompt();
|
|
||||||
await deferredPrompt.userChoice;
|
|
||||||
setDeferredPrompt(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" aria-hidden="true" /> Install App
|
<Download className="h-4 w-4" aria-hidden="true" /> Install App
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isIos()) {
|
if (ios) {
|
||||||
return showIosInstructions ? (
|
return showIosInstructions ? (
|
||||||
<p className="text-muted text-sm">
|
<p className="text-muted text-sm">
|
||||||
Tap the Share icon in Safari, then choose “Add to Home Screen”.
|
Tap the Share icon in Safari, then choose “Add to Home Screen”.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useInstallPrompt } from "../hooks/use-install-prompt";
|
||||||
|
|
||||||
|
const DISMISSED_UNTIL_KEY = "nextchair:install-card-dismissed-until";
|
||||||
|
/** Re-prompt interval after "Not Now" — see the brief's "reasonable re-prompt interval such as 14 or 30 days." 30 days chosen: this card is one of several places to install (Settings always has it too), so a longer, less-naggy interval is appropriate here specifically. */
|
||||||
|
const DISMISS_DAYS = 30;
|
||||||
|
|
||||||
|
function isDismissed(): boolean {
|
||||||
|
if (typeof window === "undefined") return true;
|
||||||
|
const raw = window.localStorage.getItem(DISMISSED_UNTIL_KEY);
|
||||||
|
if (!raw) return false;
|
||||||
|
const until = Number(raw);
|
||||||
|
return Number.isFinite(until) && Date.now() < until;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prominent, inline Welcome-page counterpart to the Settings-page
|
||||||
|
* `InstallAppButton` — same underlying detection (see
|
||||||
|
* use-install-prompt.ts), never a second installer. Renders nothing when
|
||||||
|
* already installed, on a non-iOS/non-installable browser, or recently
|
||||||
|
* dismissed — it never blocks Sign In / Create Account / Continue as
|
||||||
|
* Guest / Browse Services above it, and never opens as an unsolicited
|
||||||
|
* modal (see the brief's explicit "do not automatically open a modal").
|
||||||
|
*/
|
||||||
|
export function WelcomeInstallCard({ businessName }: { businessName: string }) {
|
||||||
|
const { installed, ios, canPromptNatively, promptInstall } = useInstallPrompt();
|
||||||
|
const [dismissed, setDismissed] = useState(true);
|
||||||
|
const [showIosSteps, setShowIosSteps] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void Promise.resolve().then(() => setDismissed(isDismissed()));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
window.localStorage.setItem(DISMISSED_UNTIL_KEY, String(Date.now() + DISMISS_DAYS * 24 * 60 * 60 * 1000));
|
||||||
|
setDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (installed || dismissed || (!canPromptNatively && !ios)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-jumbo mb-6 border border-white/10 bg-white/5 p-5 text-white">
|
||||||
|
<p className="text-sm font-bold">Add {businessName} to your Home Screen</p>
|
||||||
|
<p className="mt-1 text-sm text-white/70">Book appointments, manage your visits and receive reminders more easily.</p>
|
||||||
|
|
||||||
|
{ios && showIosSteps ? (
|
||||||
|
<div className="mt-4 space-y-2 text-sm text-white/80">
|
||||||
|
<p className="font-bold text-white">Install {businessName}</p>
|
||||||
|
<ol className="list-inside list-decimal space-y-1">
|
||||||
|
<li>Tap the Share button in Safari.</li>
|
||||||
|
<li>Scroll down and choose “Add to Home Screen”.</li>
|
||||||
|
<li>Tap Add.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 flex gap-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-white"
|
||||||
|
onClick={ios ? () => setShowIosSteps(true) : promptInstall}
|
||||||
|
>
|
||||||
|
Add to Home Screen
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="text-white/70 hover:bg-white/10 hover:text-white" onClick={dismiss}>
|
||||||
|
Not Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStandalone(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return (
|
||||||
|
window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
// iOS Safari's own (non-standard, but the only signal it exposes).
|
||||||
|
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIos(): boolean {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared `beforeinstallprompt` capture + standalone/iOS detection — the
|
||||||
|
* one PWA install implementation both `InstallAppButton` (Settings, every
|
||||||
|
* role) and `WelcomeInstallCard` (the unauthenticated Welcome page) use.
|
||||||
|
* See the brief's explicit "do not create a competing second installer".
|
||||||
|
* All detection lives in `useEffect`, never a `useState` lazy
|
||||||
|
* initializer — reading `window`/`navigator` during the initializer would
|
||||||
|
* run during SSR too and mismatch the client's first render (the same
|
||||||
|
* hydration bug this app's push-subscribe-toggle.tsx fixed in Phase 7).
|
||||||
|
*/
|
||||||
|
export function useInstallPrompt() {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
const [installed, setInstalled] = useState(false);
|
||||||
|
const [ios, setIos] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Async chain, not a direct setState call in the effect body — same
|
||||||
|
// pattern push-subscribe-toggle.tsx uses (see that file's own
|
||||||
|
// comment) to satisfy react-hooks/set-state-in-effect while still
|
||||||
|
// reading browser-only APIs that can't run during SSR.
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
setInstalled(isStandalone());
|
||||||
|
setIos(isIos());
|
||||||
|
});
|
||||||
|
|
||||||
|
const onBeforeInstallPrompt = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
||||||
|
};
|
||||||
|
const onInstalled = () => {
|
||||||
|
setInstalled(true);
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||||
|
window.addEventListener("appinstalled", onInstalled);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||||
|
window.removeEventListener("appinstalled", onInstalled);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const promptInstall = async (): Promise<void> => {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
await deferredPrompt.prompt();
|
||||||
|
await deferredPrompt.userChoice;
|
||||||
|
setDeferredPrompt(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { installed, ios, canPromptNatively: !!deferredPrompt, promptInstall };
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@/services/scheduling/appointment.service";
|
} from "@/services/scheduling/appointment.service";
|
||||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||||
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
||||||
|
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.service";
|
||||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +64,9 @@ export async function confirmAppointmentAction(
|
|||||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||||
}
|
}
|
||||||
await notifyBookingApproved(prisma, result.appointment);
|
await notifyBookingApproved(prisma, result.appointment);
|
||||||
|
// Approval is the first point a PENDING appointment becomes syncable —
|
||||||
|
// see calendar-sync.service.ts's own policy comment.
|
||||||
|
await syncAppointmentSafely(prisma, appointmentId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +127,7 @@ export async function markNoShowAction(
|
|||||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||||
}
|
}
|
||||||
await notifyAppointmentNoShow(prisma, result.appointment);
|
await notifyAppointmentNoShow(prisma, result.appointment);
|
||||||
|
await syncAppointmentSafely(prisma, appointmentId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,12 +143,11 @@ export async function cancelStaffAppointmentAction(params: {
|
|||||||
cancelledByUserId: staff.user.id,
|
cancelledByUserId: staff.user.id,
|
||||||
reason: params.reason,
|
reason: params.reason,
|
||||||
});
|
});
|
||||||
return result.success
|
if (!result.success) {
|
||||||
? { success: true }
|
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
|
||||||
: {
|
}
|
||||||
success: false,
|
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||||
error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment.",
|
return { success: true };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rescheduleStaffAppointmentAction(params: {
|
export async function rescheduleStaffAppointmentAction(params: {
|
||||||
@@ -159,12 +163,15 @@ export async function rescheduleStaffAppointmentAction(params: {
|
|||||||
staffId: params.staffId,
|
staffId: params.staffId,
|
||||||
startAt: new Date(params.startAt),
|
startAt: new Date(params.startAt),
|
||||||
});
|
});
|
||||||
return result.success
|
if (!result.success) {
|
||||||
? { success: true }
|
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment." };
|
||||||
: {
|
}
|
||||||
success: false,
|
// Covers both a plain reschedule and a barber reassignment (staffId
|
||||||
error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment.",
|
// change) — syncAppointmentSafely itself detects and cancels a stale
|
||||||
};
|
// event under the previous staff member's connection when the staffId
|
||||||
|
// changed (see that function's own comment).
|
||||||
|
await syncAppointmentSafely(prisma, params.appointmentId);
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function friendlyTransitionMessage(code?: string): string {
|
function friendlyTransitionMessage(code?: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { cache } from "react";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place every customer/staff/owner-visible surface gets the
|
||||||
|
* business's white-label identity from — see docs/phase-8-summary.md.
|
||||||
|
* "NextChair" is the internal product name only (repo, Docker project,
|
||||||
|
* package name, technical docs/logs); it must never be the fallback a
|
||||||
|
* real person sees. Fallback order, per the brief:
|
||||||
|
* 1. Configured business name + logo
|
||||||
|
* 2. Configured business name + the app's own neutral icon (handled
|
||||||
|
* naturally: leaving `logoUrl`/`appIconUrl`/`faviconUrl` null falls
|
||||||
|
* through to the static /icon.png / /apple-icon.png files Next
|
||||||
|
* already serves via its file-convention icons — no separate
|
||||||
|
* "neutral icon" asset needed)
|
||||||
|
* 3. Neutral wording with no visible product name at all — only
|
||||||
|
* reachable before installation completes, since every other route
|
||||||
|
* is gated behind a completed install (see
|
||||||
|
* src/lib/installation/installation-state.ts).
|
||||||
|
*
|
||||||
|
* Deliberately a separate, lighter query from getPrimaryBusiness() (which
|
||||||
|
* `findFirstOrThrow`s and eager-loads locations/hours/settings/
|
||||||
|
* bookingRule) — this is called from `generateMetadata` and the root
|
||||||
|
* layout on *every* request, including pre-install ones where no
|
||||||
|
* Business row exists yet, so it must never throw.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BrandingInfo {
|
||||||
|
configured: boolean;
|
||||||
|
name: string;
|
||||||
|
shortName: string;
|
||||||
|
description: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
coverImageUrl: string | null;
|
||||||
|
faviconUrl: string | null;
|
||||||
|
appIconUrl: string | null;
|
||||||
|
themeColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_THEME_COLOR = "#111b15";
|
||||||
|
const DEFAULT_BACKGROUND_COLOR = "#f4f6f4";
|
||||||
|
|
||||||
|
/** Neutral wording only — never the internal product name. */
|
||||||
|
const UNCONFIGURED_BRANDING: BrandingInfo = {
|
||||||
|
configured: false,
|
||||||
|
name: "Book an Appointment",
|
||||||
|
shortName: "Booking",
|
||||||
|
description: null,
|
||||||
|
logoUrl: null,
|
||||||
|
coverImageUrl: null,
|
||||||
|
faviconUrl: null,
|
||||||
|
appIconUrl: null,
|
||||||
|
themeColor: DEFAULT_THEME_COLOR,
|
||||||
|
backgroundColor: DEFAULT_BACKGROUND_COLOR,
|
||||||
|
email: null,
|
||||||
|
phone: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBranding = cache(async (): Promise<BrandingInfo> => {
|
||||||
|
// Reached from the root layout's generateMetadata, which Next may
|
||||||
|
// invoke while statically prerendering a route at *build* time (e.g.
|
||||||
|
// /_not-found) — no real database is reachable then (see the
|
||||||
|
// Dockerfile's placeholder build-time DATABASE_URL). A transient/
|
||||||
|
// unreachable database is treated exactly like "not installed yet":
|
||||||
|
// neutral fallback wording, never a crashed build or a 500 at runtime.
|
||||||
|
let business;
|
||||||
|
try {
|
||||||
|
business = await prisma.business.findFirst({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
shortName: true,
|
||||||
|
description: true,
|
||||||
|
logoUrl: true,
|
||||||
|
coverImageUrl: true,
|
||||||
|
faviconUrl: true,
|
||||||
|
appIconUrl: true,
|
||||||
|
themeColor: true,
|
||||||
|
backgroundColor: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return UNCONFIGURED_BRANDING;
|
||||||
|
}
|
||||||
|
if (!business) return UNCONFIGURED_BRANDING;
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
name: business.name,
|
||||||
|
shortName: business.shortName?.trim() || business.name,
|
||||||
|
description: business.description,
|
||||||
|
logoUrl: business.logoUrl,
|
||||||
|
coverImageUrl: business.coverImageUrl,
|
||||||
|
faviconUrl: business.faviconUrl,
|
||||||
|
appIconUrl: business.appIconUrl,
|
||||||
|
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
|
||||||
|
backgroundColor: business.backgroundColor || DEFAULT_BACKGROUND_COLOR,
|
||||||
|
email: business.email,
|
||||||
|
phone: business.phone,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** "Book an Appointment | Business Name" — the shared page-title format used in generateMetadata across the app. */
|
||||||
|
export function brandedTitle(pageTitle: string, branding: BrandingInfo): string {
|
||||||
|
return `${pageTitle} | ${branding.name}`;
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ export function generateAppointmentIcs(input: AppointmentIcsInput): string {
|
|||||||
const lines = [
|
const lines = [
|
||||||
"BEGIN:VCALENDAR",
|
"BEGIN:VCALENDAR",
|
||||||
"VERSION:2.0",
|
"VERSION:2.0",
|
||||||
"PRODID:-//NextChair//Booking//EN",
|
"PRODID:-//Booking Calendar//EN",
|
||||||
"CALSCALE:GREGORIAN",
|
"CALSCALE:GREGORIAN",
|
||||||
"METHOD:PUBLISH",
|
"METHOD:PUBLISH",
|
||||||
"BEGIN:VEVENT",
|
"BEGIN:VEVENT",
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { cache } from "react";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { InstallationStatus } from "@/generated/prisma/enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApplicationInstallation always uses this fixed id — see the model's own
|
||||||
|
* header in prisma/schema.prisma for why a fixed id (not a lookup by "any
|
||||||
|
* row") is the concurrency-safety mechanism, not just a convention.
|
||||||
|
*/
|
||||||
|
export const INSTALLATION_SINGLETON_ID = "singleton";
|
||||||
|
|
||||||
|
export interface InstallationState {
|
||||||
|
installed: boolean;
|
||||||
|
status: InstallationStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Component-scoped read (React `cache()`-deduped per request) — use
|
||||||
|
* this from pages/layouts. `proxy.ts` has its own process-lifetime-cached
|
||||||
|
* check instead (see isInstalledForRequest below): a Server Component
|
||||||
|
* dedupes per-request, but the proxy runs on every single request and a
|
||||||
|
* fresh DB round trip each time would be real, avoidable cost once the app
|
||||||
|
* is (permanently, for the life of the process) installed.
|
||||||
|
*/
|
||||||
|
export const getInstallationState = cache(async (): Promise<InstallationState> => {
|
||||||
|
const row = await prisma.applicationInstallation.findUnique({
|
||||||
|
where: { id: INSTALLATION_SINGLETON_ID },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
const status = row?.status ?? InstallationStatus.NOT_INSTALLED;
|
||||||
|
return { installed: status === InstallationStatus.INSTALLED, status };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level (not request-level) cache for `proxy.ts`. Installation is a
|
||||||
|
* one-way, one-time transition for the life of a deployment — once this
|
||||||
|
* process has observed `INSTALLED`, it can never legitimately become
|
||||||
|
* un-installed again (there is no "reset" action, per the brief's own
|
||||||
|
* "no unauthenticated reset button" requirement), so re-querying the
|
||||||
|
* database on every single request forever after is pure waste. Before
|
||||||
|
* install, every request still does the real check, which is exactly the
|
||||||
|
* window where correctness (redirecting to /install) actually matters.
|
||||||
|
*/
|
||||||
|
let processObservedInstalled = false;
|
||||||
|
|
||||||
|
export async function isInstalledForRequest(): Promise<boolean> {
|
||||||
|
if (processObservedInstalled) return true;
|
||||||
|
const row = await prisma.applicationInstallation.findUnique({
|
||||||
|
where: { id: INSTALLATION_SINGLETON_ID },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
const installed = row?.status === InstallationStatus.INSTALLED;
|
||||||
|
if (installed) processObservedInstalled = true;
|
||||||
|
return installed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two system roles' permission sets — single source of truth, used by
|
||||||
|
* both `prisma/seed.ts` (dev/demo fixtures) and the installation wizard
|
||||||
|
* (`src/features/installation/installer.service.ts`, the real "first
|
||||||
|
* owner" creation path). Previously duplicated only in seed.ts; extracted
|
||||||
|
* here in Phase 8 so a permission added to one path can't silently miss
|
||||||
|
* the other (see docs/phase-8-summary.md).
|
||||||
|
*/
|
||||||
|
export const OWNER_PERMISSIONS: Permission[] = [
|
||||||
|
Permission.MANAGE_BUSINESS_PROFILE,
|
||||||
|
Permission.MANAGE_BOOKING_RULES,
|
||||||
|
Permission.MANAGE_SERVICES,
|
||||||
|
Permission.MANAGE_STAFF,
|
||||||
|
Permission.MANAGE_LOYALTY_PROGRAMME,
|
||||||
|
Permission.MANAGE_NOTIFICATION_SETTINGS,
|
||||||
|
Permission.VIEW_ALL_STAFF_APPOINTMENTS,
|
||||||
|
Permission.MANAGE_ALL_APPOINTMENTS,
|
||||||
|
Permission.MANAGE_OWN_APPOINTMENTS,
|
||||||
|
Permission.CHECK_IN_CUSTOMERS,
|
||||||
|
Permission.MANAGE_LOYALTY_STAMPS,
|
||||||
|
Permission.MANAGE_OWN_AVAILABILITY,
|
||||||
|
Permission.VIEW_CUSTOMER_DIRECTORY,
|
||||||
|
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
|
||||||
|
Permission.VIEW_REPORTING,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BARBER_PERMISSIONS: Permission[] = [
|
||||||
|
Permission.MANAGE_OWN_APPOINTMENTS,
|
||||||
|
Permission.CHECK_IN_CUSTOMERS,
|
||||||
|
Permission.MANAGE_LOYALTY_STAMPS,
|
||||||
|
Permission.MANAGE_OWN_AVAILABILITY,
|
||||||
|
Permission.VIEW_CUSTOMER_DIRECTORY,
|
||||||
|
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `role.upsert`'s `update: {}` deliberately does nothing on an existing
|
||||||
|
* role, so a plain nested `permissions: { create: [...] }` only fires on
|
||||||
|
* the `create` branch — this syncs RolePermission rows to exactly match
|
||||||
|
* the target list every time, safe to call repeatedly. See prisma/seed.ts
|
||||||
|
* for the original bug this fixed.
|
||||||
|
*/
|
||||||
|
export async function syncRolePermissions(
|
||||||
|
prisma: Pick<PrismaClient, "rolePermission">,
|
||||||
|
roleId: string,
|
||||||
|
permissions: Permission[],
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.rolePermission.deleteMany({ where: { roleId, permission: { notIn: permissions } } });
|
||||||
|
await prisma.rolePermission.createMany({
|
||||||
|
data: permissions.map((permission) => ({ roleId, permission })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createOAuthState, verifyOAuthState } from "../oauth-state";
|
||||||
|
|
||||||
|
describe("oauth-state", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.AUTH_SECRET = "test-auth-secret-not-for-production";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies a state token for the staff member it was issued to", () => {
|
||||||
|
const state = createOAuthState("staff-123");
|
||||||
|
expect(verifyOAuthState(state, "staff-123")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a state token issued to a different staff member", () => {
|
||||||
|
const state = createOAuthState("staff-123");
|
||||||
|
expect(verifyOAuthState(state, "staff-999")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a tampered token", () => {
|
||||||
|
const state = createOAuthState("staff-123");
|
||||||
|
const tampered = `${state.slice(0, -2)}xx`;
|
||||||
|
expect(verifyOAuthState(tampered, "staff-123")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed token", () => {
|
||||||
|
expect(verifyOAuthState("not-a-real-token", "staff-123")).toBe(false);
|
||||||
|
expect(verifyOAuthState("", "staff-123")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an expired token", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const state = createOAuthState("staff-123");
|
||||||
|
vi.advanceTimersByTime(11 * 60 * 1000); // past the 10-minute max age
|
||||||
|
expect(verifyOAuthState(state, "staff-123")).toBe(false);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { decryptToken, encryptToken } from "../token-encryption";
|
||||||
|
|
||||||
|
describe("token-encryption", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a plaintext value", () => {
|
||||||
|
const plaintext = "1//0gExampleGoogleRefreshToken";
|
||||||
|
const encrypted = encryptToken(plaintext);
|
||||||
|
expect(encrypted).not.toBe(plaintext);
|
||||||
|
expect(decryptToken(encrypted)).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a different ciphertext for the same plaintext each time (random IV)", () => {
|
||||||
|
const a = encryptToken("same-value");
|
||||||
|
const b = encryptToken("same-value");
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(decryptToken(a)).toBe("same-value");
|
||||||
|
expect(decryptToken(b)).toBe("same-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if CALENDAR_TOKEN_ENCRYPTION_KEY is not set", () => {
|
||||||
|
delete process.env.CALENDAR_TOKEN_ENCRYPTION_KEY;
|
||||||
|
expect(() => encryptToken("x")).toThrow(/CALENDAR_TOKEN_ENCRYPTION_KEY/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails to decrypt with a tampered ciphertext (authentication tag mismatch)", () => {
|
||||||
|
const encrypted = encryptToken("secret-value");
|
||||||
|
const tampered = encrypted.slice(0, -4) + "AAAA";
|
||||||
|
expect(() => decryptToken(tampered)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed, short-lived OAuth `state` for the Google Calendar connect flow
|
||||||
|
* (see src/app/api/staff/calendar/google/{authorize,callback}/route.ts).
|
||||||
|
* Same HMAC-over-JSON approach as src/lib/booking-draft/sign.ts, with its
|
||||||
|
* own context string — kept as a separate module rather than reusing that
|
||||||
|
* one directly, so the two signing keys stay cryptographically
|
||||||
|
* independent (a leaked booking-draft signature should tell an attacker
|
||||||
|
* nothing about this one, and vice versa).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CONTEXT = "nextchair-calendar-oauth-state-v1";
|
||||||
|
const MAX_AGE_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
interface StatePayload {
|
||||||
|
nonce: string;
|
||||||
|
staffId: string;
|
||||||
|
issuedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSigningKey(): Buffer {
|
||||||
|
const secret = process.env.AUTH_SECRET;
|
||||||
|
if (!secret) throw new Error("AUTH_SECRET must be set to sign the calendar OAuth state.");
|
||||||
|
return createHmac("sha256", secret).update(CONTEXT).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOAuthState(staffId: string): string {
|
||||||
|
const payload: StatePayload = { nonce: randomBytes(16).toString("hex"), staffId, issuedAt: Date.now() };
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
const mac = createHmac("sha256", getSigningKey()).update(json).digest("base64url");
|
||||||
|
return `${Buffer.from(json, "utf8").toString("base64url")}.${mac}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the staffId if `token` is a validly-signed, non-expired state for this exact staff member — null otherwise. Never throws. */
|
||||||
|
export function verifyOAuthState(token: string, expectedStaffId: string): boolean {
|
||||||
|
const separatorIndex = token.indexOf(".");
|
||||||
|
if (separatorIndex === -1) return false;
|
||||||
|
const encodedPayload = token.slice(0, separatorIndex);
|
||||||
|
const mac = token.slice(separatorIndex + 1);
|
||||||
|
if (!encodedPayload || !mac) return false;
|
||||||
|
|
||||||
|
let json: string;
|
||||||
|
try {
|
||||||
|
json = Buffer.from(encodedPayload, "base64url").toString("utf8");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedMac = createHmac("sha256", getSigningKey()).update(json).digest("base64url");
|
||||||
|
const macBuffer = Buffer.from(mac);
|
||||||
|
const expectedBuffer = Buffer.from(expectedMac);
|
||||||
|
if (macBuffer.length !== expectedBuffer.length || !timingSafeEqual(macBuffer, expectedBuffer)) return false;
|
||||||
|
|
||||||
|
let payload: StatePayload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.staffId !== expectedStaffId) return false;
|
||||||
|
if (Date.now() - payload.issuedAt > MAX_AGE_MS) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time string comparison for secrets (cron bearer tokens, the
|
||||||
|
* installation token) — a plain `!==` leaks a secret's length and correct
|
||||||
|
* prefix via response timing. Shared by
|
||||||
|
* src/app/api/cron/process-notifications/route.ts and
|
||||||
|
* src/features/installation/actions/install.ts; see also
|
||||||
|
* src/lib/booking-draft/sign.ts, which uses the same primitive directly
|
||||||
|
* for HMAC comparison rather than through this helper.
|
||||||
|
*/
|
||||||
|
export function secretsMatch(provided: string, expected: string): boolean {
|
||||||
|
const providedBuffer = Buffer.from(provided);
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
if (providedBuffer.length !== expectedBuffer.length) return false;
|
||||||
|
return timingSafeEqual(providedBuffer, expectedBuffer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256-GCM at-rest encryption for Google Calendar OAuth tokens (see
|
||||||
|
* StaffCalendarConnection in prisma/schema.prisma) — refresh tokens are
|
||||||
|
* long-lived credentials for a third-party account and must never be
|
||||||
|
* stored in plain text. Output format: base64(iv[12] + authTag[16] +
|
||||||
|
* ciphertext) — everything needed to decrypt in one column, no separate
|
||||||
|
* iv/tag columns to keep in sync.
|
||||||
|
*
|
||||||
|
* CALENDAR_TOKEN_ENCRYPTION_KEY is expected to be a 32-byte value,
|
||||||
|
* hex-encoded (64 hex chars) — generate with `openssl rand -hex 32`. A
|
||||||
|
* SHA-256 of the raw env value is used as the actual AES key (not the
|
||||||
|
* raw bytes directly) so a key of any length/format still yields a
|
||||||
|
* valid 32-byte AES-256 key, rather than throwing at startup for a
|
||||||
|
* slightly-wrong-length env var.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALGORITHM = "aes-256-gcm";
|
||||||
|
const IV_LENGTH = 12;
|
||||||
|
const AUTH_TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
const secret = process.env.CALENDAR_TOKEN_ENCRYPTION_KEY;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error("CALENDAR_TOKEN_ENCRYPTION_KEY must be set to store Google Calendar tokens.");
|
||||||
|
}
|
||||||
|
return createHash("sha256").update(secret).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptToken(plaintext: string): string {
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptToken(encoded: string): string {
|
||||||
|
const raw = Buffer.from(encoded, "base64");
|
||||||
|
const iv = raw.subarray(0, IV_LENGTH);
|
||||||
|
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
|
const ciphertext = raw.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { safeRedirectPath } from "../safe-redirect";
|
||||||
|
|
||||||
|
describe("safeRedirectPath", () => {
|
||||||
|
it("accepts a plain internal path", () => {
|
||||||
|
expect(safeRedirectPath("/book/details", "/home")).toBe("/book/details");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for null/undefined/empty input", () => {
|
||||||
|
expect(safeRedirectPath(null, "/home")).toBe("/home");
|
||||||
|
expect(safeRedirectPath(undefined, "/home")).toBe("/home");
|
||||||
|
expect(safeRedirectPath("", "/home")).toBe("/home");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects protocol-relative URLs", () => {
|
||||||
|
expect(safeRedirectPath("//evil.com", "/home")).toBe("/home");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects absolute URLs to another host", () => {
|
||||||
|
expect(safeRedirectPath("https://evil.com/phish", "/home")).toBe("/home");
|
||||||
|
expect(safeRedirectPath("http://evil.com", "/home")).toBe("/home");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a path not starting with a single slash", () => {
|
||||||
|
expect(safeRedirectPath("book/details", "/home")).toBe("/home");
|
||||||
|
expect(safeRedirectPath("javascript:alert(1)", "/home")).toBe("/home");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Validates a caller-supplied return path (a `callbackUrl` query param,
|
||||||
|
* a booking-draft continuation target) is a safe, internal, relative
|
||||||
|
* path before it's ever used in a redirect — see the brief's "Accept
|
||||||
|
* only safe internal return paths. Prevent open redirects." Auth.js's
|
||||||
|
* own `signIn()`/`redirectTo` already restricts to same-origin
|
||||||
|
* internally, but this is checked explicitly rather than relied on
|
||||||
|
* implicitly, since it guards a value that reached this code from a URL
|
||||||
|
* query string, not from a trusted internal source.
|
||||||
|
*/
|
||||||
|
export function safeRedirectPath(path: string | null | undefined, fallback: string): string {
|
||||||
|
if (!path) return fallback;
|
||||||
|
// Must start with exactly one "/" — rejects "//evil.com" (protocol-relative),
|
||||||
|
// "https://evil.com", and "javascript:..." in one check.
|
||||||
|
if (!path.startsWith("/") || path.startsWith("//")) return fallback;
|
||||||
|
if (path.includes("://")) return fallback;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
+43
-2
@@ -1,6 +1,7 @@
|
|||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { authConfig } from "@/auth.config";
|
import { authConfig } from "@/auth.config";
|
||||||
|
import { isInstalledForRequest } from "@/lib/installation/installation-state";
|
||||||
|
|
||||||
// /home, /appointments and /settings are intentionally NOT hard-gated here
|
// /home, /appointments and /settings are intentionally NOT hard-gated here
|
||||||
// as of Phase 4 — Home must work for guests (public business browsing +
|
// as of Phase 4 — Home must work for guests (public business browsing +
|
||||||
@@ -10,6 +11,25 @@ import { authConfig } from "@/auth.config";
|
|||||||
// permission-based tooling with no meaningful "guest" experience at all.
|
// permission-based tooling with no meaningful "guest" experience at all.
|
||||||
const PROTECTED_PREFIXES = ["/staff", "/owner"];
|
const PROTECTED_PREFIXES = ["/staff", "/owner"];
|
||||||
|
|
||||||
|
// Reachable before installation completes: the installer itself, and the
|
||||||
|
// small set of static/manifest assets a browser requests independently of
|
||||||
|
// the page it's rendering (favicon, PWA manifest/icons, the offline
|
||||||
|
// fallback, the service worker script). Everything else redirects to
|
||||||
|
// /install until ApplicationInstallation flips to INSTALLED — see
|
||||||
|
// src/lib/installation/installation-state.ts. `/api/*` never reaches this
|
||||||
|
// file at all (excluded by `config.matcher` below); API routes that need
|
||||||
|
// business data guard themselves (see /api/health and
|
||||||
|
// /api/cron/process-notifications).
|
||||||
|
const INSTALL_EXEMPT_PREFIXES = [
|
||||||
|
"/install",
|
||||||
|
"/offline",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/icon.png",
|
||||||
|
"/apple-icon.png",
|
||||||
|
"/icons/",
|
||||||
|
"/sw.js",
|
||||||
|
];
|
||||||
|
|
||||||
// Lightweight instance built from the DB-free config — see auth.config.ts.
|
// Lightweight instance built from the DB-free config — see auth.config.ts.
|
||||||
// Next.js 16 renamed this file convention from `middleware` to `proxy`
|
// Next.js 16 renamed this file convention from `middleware` to `proxy`
|
||||||
// (https://nextjs.org/docs/messages/middleware-to-proxy); it now always
|
// (https://nextjs.org/docs/messages/middleware-to-proxy); it now always
|
||||||
@@ -57,12 +77,33 @@ function buildCsp(): string {
|
|||||||
].join("; ");
|
].join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default auth((req) => {
|
export default auth(async (req) => {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
const csp = buildCsp();
|
||||||
|
|
||||||
|
const isInstallExempt = INSTALL_EXEMPT_PREFIXES.some(
|
||||||
|
(prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
||||||
|
);
|
||||||
|
const installed = await isInstalledForRequest();
|
||||||
|
|
||||||
|
if (!installed && !isInstallExempt) {
|
||||||
|
const response = NextResponse.redirect(new URL("/install", req.nextUrl.origin));
|
||||||
|
response.headers.set("Content-Security-Policy", csp);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
if (installed && pathname === "/install") {
|
||||||
|
// The wizard itself also refuses to render/submit once installed (see
|
||||||
|
// src/app/install/page.tsx and the installer Server Action) — this
|
||||||
|
// redirect is a convenience for direct navigation, not the actual
|
||||||
|
// enforcement boundary.
|
||||||
|
const response = NextResponse.redirect(new URL("/", req.nextUrl.origin));
|
||||||
|
response.headers.set("Content-Security-Policy", csp);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const isProtected = PROTECTED_PREFIXES.some(
|
const isProtected = PROTECTED_PREFIXES.some(
|
||||||
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
|
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||||
);
|
);
|
||||||
const csp = buildCsp();
|
|
||||||
|
|
||||||
if (isProtected && !req.auth) {
|
if (isProtected && !req.auth) {
|
||||||
const signInUrl = new URL("/sign-in", req.nextUrl.origin);
|
const signInUrl = new URL("/sign-in", req.nextUrl.origin);
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
cleanupTestBusiness,
|
||||||
|
createTestBusiness,
|
||||||
|
createTestCustomer,
|
||||||
|
createTestStaff,
|
||||||
|
prisma,
|
||||||
|
} from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
|
||||||
|
import { encryptToken } from "@/lib/security/token-encryption";
|
||||||
|
import { MockCalendarProvider } from "../mock-calendar.provider";
|
||||||
|
import { runInitialSync, syncAppointmentSafely } from "../calendar-sync.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercises the real sync policy (create/update/cancel/duplicate
|
||||||
|
* prevention/reassignment) against a real database, with a
|
||||||
|
* `MockCalendarProvider` standing in for Google — see that file's own
|
||||||
|
* header for why (the brief: "Do not call real Google Calendar APIs in
|
||||||
|
* automated tests").
|
||||||
|
*/
|
||||||
|
describe("calendar-sync.service (integration)", () => {
|
||||||
|
let business: TestBusiness;
|
||||||
|
let staffId: string;
|
||||||
|
let customerId: string;
|
||||||
|
let provider: MockCalendarProvider;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
business = await createTestBusiness();
|
||||||
|
const staff = await createTestStaff(business);
|
||||||
|
staffId = staff.staffId;
|
||||||
|
const customer = await createTestCustomer(business);
|
||||||
|
customerId = customer.customerId;
|
||||||
|
provider = new MockCalendarProvider();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanupTestBusiness(business);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function connectCalendar(): Promise<string> {
|
||||||
|
const connection = await prisma.staffCalendarConnection.create({
|
||||||
|
data: {
|
||||||
|
staffId,
|
||||||
|
externalAccountId: "mock-account",
|
||||||
|
accountEmail: "barber@example.com",
|
||||||
|
encryptedAccessToken: encryptToken("mock-access-token"),
|
||||||
|
encryptedRefreshToken: encryptToken("mock-refresh-token"),
|
||||||
|
tokenExpiresAt: new Date(Date.now() + 3600_000),
|
||||||
|
selectedCalendarId: "primary",
|
||||||
|
selectedCalendarName: "Primary Calendar",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return connection.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAppointment(status: AppointmentStatus, overrides: { staffId?: string } = {}) {
|
||||||
|
return prisma.appointment.create({
|
||||||
|
data: {
|
||||||
|
businessId: business.businessId,
|
||||||
|
staffId: overrides.staffId ?? staffId,
|
||||||
|
customerId,
|
||||||
|
status,
|
||||||
|
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
|
||||||
|
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates a remote event for a CONFIRMED appointment", async () => {
|
||||||
|
await connectCalendar();
|
||||||
|
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||||
|
|
||||||
|
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||||
|
|
||||||
|
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||||
|
expect(event?.status).toBe(CalendarSyncStatus.SYNCED);
|
||||||
|
expect(provider.events.size).toBe(1);
|
||||||
|
const [providerEvent] = [...provider.events.values()];
|
||||||
|
expect(providerEvent?.summary).toContain("Test Customer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not sync a PENDING appointment", async () => {
|
||||||
|
await connectCalendar();
|
||||||
|
const appointment = await createAppointment(AppointmentStatus.PENDING);
|
||||||
|
|
||||||
|
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||||
|
|
||||||
|
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||||
|
expect(event).toBeNull();
|
||||||
|
expect(provider.events.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — syncing an already-synced appointment updates the same remote event rather than creating a second one", async () => {
|
||||||
|
await connectCalendar();
|
||||||
|
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||||
|
|
||||||
|
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||||
|
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||||
|
|
||||||
|
const events = await prisma.appointmentCalendarEvent.findMany({ where: { appointmentId: appointment.id } });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(provider.events.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes the remote event when the appointment is cancelled", async () => {
|
||||||
|
await connectCalendar();
|
||||||
|
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||||
|
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||||
|
expect(provider.events.size).toBe(1);
|
||||||
|
|
||||||
|
await prisma.appointment.update({ where: { id: appointment.id }, data: { status: AppointmentStatus.CANCELLED } });
|
||||||
|
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||||
|
|
||||||
|
expect(provider.events.size).toBe(0);
|
||||||
|
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||||
|
expect(event?.status).toBe(CalendarSyncStatus.CANCELLED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels the previous staff member's event on reassignment and creates one under the new staff member's connection", async () => {
|
||||||
|
await connectCalendar();
|
||||||
|
const otherStaff = await createTestStaff(business);
|
||||||
|
const otherConnection = await prisma.staffCalendarConnection.create({
|
||||||
|
data: {
|
||||||
|
staffId: otherStaff.staffId,
|
||||||
|
externalAccountId: "mock-account-2",
|
||||||
|
accountEmail: "other-barber@example.com",
|
||||||
|
encryptedAccessToken: encryptToken("mock-access-token-2"),
|
||||||
|
encryptedRefreshToken: encryptToken("mock-refresh-token-2"),
|
||||||
|
tokenExpiresAt: new Date(Date.now() + 3600_000),
|
||||||
|
selectedCalendarId: "primary",
|
||||||
|
selectedCalendarName: "Primary Calendar",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||||
|
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||||
|
expect(provider.events.size).toBe(1);
|
||||||
|
|
||||||
|
await prisma.appointment.update({ where: { id: appointment.id }, data: { staffId: otherStaff.staffId } });
|
||||||
|
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||||
|
|
||||||
|
const originalConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
|
||||||
|
where: { appointmentId: appointment.id, connection: { staffId } },
|
||||||
|
});
|
||||||
|
expect(originalConnectionEvent?.status).toBe(CalendarSyncStatus.CANCELLED);
|
||||||
|
|
||||||
|
const newConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
|
||||||
|
where: { appointmentId: appointment.id, connectionId: otherConnection.id },
|
||||||
|
});
|
||||||
|
expect(newConnectionEvent?.status).toBe(CalendarSyncStatus.SYNCED);
|
||||||
|
|
||||||
|
await prisma.staffCalendarConnection.delete({ where: { id: otherConnection.id } }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runInitialSync syncs every future non-pending, non-cancelled appointment for a newly connected staff member", async () => {
|
||||||
|
await connectCalendar();
|
||||||
|
const confirmed = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||||
|
const pending = await createAppointment(AppointmentStatus.PENDING);
|
||||||
|
const cancelled = await createAppointment(AppointmentStatus.CANCELLED);
|
||||||
|
|
||||||
|
const result = await runInitialSync(prisma, staffId, provider);
|
||||||
|
|
||||||
|
expect(result.synced).toBe(1);
|
||||||
|
expect(provider.events.size).toBe(1);
|
||||||
|
const syncedEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: confirmed.id } });
|
||||||
|
expect(syncedEvent).toBeTruthy();
|
||||||
|
const pendingEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: pending.id } });
|
||||||
|
expect(pendingEvent).toBeNull();
|
||||||
|
const cancelledEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: cancelled.id } });
|
||||||
|
expect(cancelledEvent).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when no calendar connection exists for the staff member", async () => {
|
||||||
|
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
|
||||||
|
await syncAppointmentSafely(prisma, appointment.id, provider);
|
||||||
|
|
||||||
|
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
|
||||||
|
expect(event).toBeNull();
|
||||||
|
expect(provider.events.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-neutral operations `CalendarSyncService` calls — Google is the
|
||||||
|
* only implementation today (`google-calendar.provider.ts`), but nothing
|
||||||
|
* in the sync service or appointment Server Actions calls Google's API
|
||||||
|
* directly (see the brief's explicit "Do not call Google APIs directly
|
||||||
|
* from appointment Server Actions"), so a second provider is additive.
|
||||||
|
*/
|
||||||
|
export interface CalendarProvider {
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** OAuth authorization URL the browser is redirected to — `state` is an opaque, server-generated CSRF/replay token the callback route re-verifies. */
|
||||||
|
buildAuthorizationUrl(params: { redirectUri: string; state: string }): string;
|
||||||
|
|
||||||
|
/** Exchanges the callback's `code` for tokens, and resolves which external account this actually is. */
|
||||||
|
exchangeCodeForTokens(params: { code: string; redirectUri: string }): Promise<ExchangedTokens>;
|
||||||
|
|
||||||
|
refreshAccessToken(refreshToken: string): Promise<RefreshedAccessToken>;
|
||||||
|
|
||||||
|
/** Only ever returns calendars the account can write to — see RemoteCalendarInfo.writable. */
|
||||||
|
listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]>;
|
||||||
|
|
||||||
|
createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }>;
|
||||||
|
|
||||||
|
updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void>;
|
||||||
|
|
||||||
|
deleteEvent(params: { accessToken: string; calendarId: string; providerEventId: string }): Promise<void>;
|
||||||
|
|
||||||
|
/** Best-effort server-side revocation at disconnect — see the brief's "Revoke credentials where possible." */
|
||||||
|
revoke(refreshToken: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import type { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
|
||||||
|
import { decryptToken, encryptToken } from "@/lib/security/token-encryption";
|
||||||
|
import type { CalendarProvider } from "./calendar-provider";
|
||||||
|
import { GoogleCalendarProvider } from "./google-calendar.provider";
|
||||||
|
import { CalendarProviderError } from "./types";
|
||||||
|
import type { CalendarEventInput } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appointment → Google Calendar sync policy — the one place that decides
|
||||||
|
* *whether* and *how* an appointment's status maps to a calendar event.
|
||||||
|
* Provider-neutral: everything here goes through `CalendarProvider`, never
|
||||||
|
* a Google-specific call directly (see calendar-provider.ts). Every
|
||||||
|
* public function here is safe to call from a Server Action after an
|
||||||
|
* appointment mutation commits — sync failures are caught and recorded,
|
||||||
|
* never thrown, so a Google outage can never roll back or block a real
|
||||||
|
* booking change (see the brief's explicit requirement).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getProvider(): CalendarProvider {
|
||||||
|
// Google is the only provider today — see CalendarProvider's own header
|
||||||
|
// for why a second one is additive, not a rewrite, when needed.
|
||||||
|
return new GoogleCalendarProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getValidAccessToken(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
connection: { id: string; encryptedAccessToken: string | null; encryptedRefreshToken: string; tokenExpiresAt: Date | null },
|
||||||
|
provider: CalendarProvider,
|
||||||
|
): Promise<string> {
|
||||||
|
const stillValid =
|
||||||
|
connection.encryptedAccessToken && connection.tokenExpiresAt && connection.tokenExpiresAt.getTime() - Date.now() > 60_000;
|
||||||
|
if (stillValid) return decryptToken(connection.encryptedAccessToken!);
|
||||||
|
|
||||||
|
const refreshToken = decryptToken(connection.encryptedRefreshToken);
|
||||||
|
const refreshed = await provider.refreshAccessToken(refreshToken);
|
||||||
|
await prisma.staffCalendarConnection.update({
|
||||||
|
where: { id: connection.id },
|
||||||
|
data: { encryptedAccessToken: encryptToken(refreshed.accessToken), tokenExpiresAt: refreshed.expiresAt },
|
||||||
|
});
|
||||||
|
return refreshed.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEventContent(appointment: {
|
||||||
|
id: string;
|
||||||
|
startAt: Date;
|
||||||
|
endAt: Date;
|
||||||
|
status: AppointmentStatus;
|
||||||
|
customer: { firstName: string; lastName: string };
|
||||||
|
services: { service: { name: string } }[];
|
||||||
|
business: { name: string; locations: { addressLine1: string; city: string; postalCode: string }[] };
|
||||||
|
}): CalendarEventInput {
|
||||||
|
const serviceNames = appointment.services.map((s) => s.service.name).join(", ");
|
||||||
|
const location = appointment.business.locations[0];
|
||||||
|
const locationLine = location ? `${location.addressLine1}, ${location.city} ${location.postalCode}` : undefined;
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "";
|
||||||
|
|
||||||
|
const descriptionLines = [
|
||||||
|
`Customer: ${appointment.customer.firstName} ${appointment.customer.lastName}`,
|
||||||
|
`Service: ${serviceNames}`,
|
||||||
|
`Business: ${appointment.business.name}`,
|
||||||
|
locationLine ? `Address: ${locationLine}` : undefined,
|
||||||
|
`Reference: ${appointment.id}`,
|
||||||
|
appUrl ? `View: ${appUrl}/staff/appointments/${appointment.id}` : undefined,
|
||||||
|
appointment.status === AppointmentStatus.NO_SHOW ? "Status: customer did not show up." : undefined,
|
||||||
|
].filter((line): line is string => !!line);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: `${serviceNames} — ${appointment.customer.firstName} ${appointment.customer.lastName}`,
|
||||||
|
description: descriptionLines.join("\n"),
|
||||||
|
location: locationLine,
|
||||||
|
startAt: appointment.startAt,
|
||||||
|
endAt: appointment.endAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Policy: PENDING appointments are never synced (an unapproved booking
|
||||||
|
* shouldn't occupy the barber's personal calendar); CANCELLED deletes the
|
||||||
|
* remote event outright (a cancelled slot has no reason to still show
|
||||||
|
* busy); everything else (CONFIRMED/CHECKED_IN/IN_PROGRESS/COMPLETED/
|
||||||
|
* NO_SHOW) creates or updates the event, with NO_SHOW additionally noting
|
||||||
|
* the outcome in the description rather than removing the event (the
|
||||||
|
* barber's calendar should still show they were scheduled to work).
|
||||||
|
*/
|
||||||
|
async function syncOneConnection(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: {
|
||||||
|
appointmentId: string;
|
||||||
|
connection: {
|
||||||
|
id: string;
|
||||||
|
encryptedAccessToken: string | null;
|
||||||
|
encryptedRefreshToken: string;
|
||||||
|
tokenExpiresAt: Date | null;
|
||||||
|
selectedCalendarId: string;
|
||||||
|
};
|
||||||
|
provider: CalendarProvider;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const appointment = await prisma.appointment.findUnique({
|
||||||
|
where: { id: params.appointmentId },
|
||||||
|
include: {
|
||||||
|
customer: { select: { firstName: true, lastName: true } },
|
||||||
|
services: { include: { service: { select: { name: true } } } },
|
||||||
|
business: { select: { name: true, locations: { where: { isPrimary: true }, take: 1 } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!appointment) return;
|
||||||
|
|
||||||
|
const provider = params.provider;
|
||||||
|
const existing = await prisma.appointmentCalendarEvent.findUnique({
|
||||||
|
where: { appointmentId_connectionId: { appointmentId: params.appointmentId, connectionId: params.connection.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (appointment.status === AppointmentStatus.PENDING) return;
|
||||||
|
|
||||||
|
const accessToken = await getValidAccessToken(prisma, params.connection, provider);
|
||||||
|
|
||||||
|
if (appointment.status === AppointmentStatus.CANCELLED) {
|
||||||
|
if (existing) {
|
||||||
|
await provider.deleteEvent({ accessToken, calendarId: params.connection.selectedCalendarId, providerEventId: existing.providerEventId });
|
||||||
|
await prisma.appointmentCalendarEvent.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { status: CalendarSyncStatus.CANCELLED, lastSyncedAt: new Date(), lastSyncError: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = buildEventContent(appointment);
|
||||||
|
|
||||||
|
if (existing && existing.status !== CalendarSyncStatus.CANCELLED) {
|
||||||
|
await provider.updateEvent({
|
||||||
|
accessToken,
|
||||||
|
calendarId: params.connection.selectedCalendarId,
|
||||||
|
providerEventId: existing.providerEventId,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
await prisma.appointmentCalendarEvent.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { status: CalendarSyncStatus.SYNCED, lastSyncedAt: new Date(), lastSyncError: null, sourceVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const created = await provider.createEvent({ accessToken, calendarId: params.connection.selectedCalendarId, event });
|
||||||
|
await prisma.appointmentCalendarEvent.upsert({
|
||||||
|
where: { appointmentId_connectionId: { appointmentId: params.appointmentId, connectionId: params.connection.id } },
|
||||||
|
create: {
|
||||||
|
appointmentId: params.appointmentId,
|
||||||
|
connectionId: params.connection.id,
|
||||||
|
providerEventId: created.providerEventId,
|
||||||
|
providerCalendarId: params.connection.selectedCalendarId,
|
||||||
|
status: CalendarSyncStatus.SYNCED,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
providerEventId: created.providerEventId,
|
||||||
|
providerCalendarId: params.connection.selectedCalendarId,
|
||||||
|
status: CalendarSyncStatus.SYNCED,
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
lastSyncError: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncAt: new Date(), lastSyncError: null } });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof CalendarProviderError ? error.message : "Calendar sync failed unexpectedly.";
|
||||||
|
await prisma.staffCalendarConnection.update({ where: { id: params.connection.id }, data: { lastSyncError: message } }).catch(() => {});
|
||||||
|
if (existing) {
|
||||||
|
await prisma.appointmentCalendarEvent
|
||||||
|
.update({ where: { id: existing.id }, data: { status: CalendarSyncStatus.FAILED, lastSyncError: message } })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call after any appointment mutation that should be reflected in
|
||||||
|
* calendars — confirmation, approval, reschedule, service change,
|
||||||
|
* cancellation, no-show. Never throws; a Google failure is recorded on
|
||||||
|
* the connection/event rows for the staff settings page to surface, not
|
||||||
|
* propagated back to the caller (see this file's own header). If the
|
||||||
|
* appointment was reassigned to a different staff member, the previous
|
||||||
|
* staff member's event (if any) is cancelled first so responsibility
|
||||||
|
* moves cleanly rather than leaving a stale duplicate.
|
||||||
|
*/
|
||||||
|
export async function syncAppointmentSafely(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
appointmentId: string,
|
||||||
|
provider: CalendarProvider = getProvider(),
|
||||||
|
): Promise<void> {
|
||||||
|
const appointment = await prisma.appointment.findUnique({ where: { id: appointmentId }, select: { staffId: true } });
|
||||||
|
if (!appointment) return;
|
||||||
|
|
||||||
|
const staleEvents = await prisma.appointmentCalendarEvent.findMany({
|
||||||
|
where: { appointmentId, status: { not: CalendarSyncStatus.CANCELLED }, connection: { staffId: { not: appointment.staffId } } },
|
||||||
|
include: { connection: true },
|
||||||
|
});
|
||||||
|
for (const stale of staleEvents) {
|
||||||
|
await syncOneConnection(prisma, {
|
||||||
|
appointmentId,
|
||||||
|
provider,
|
||||||
|
connection: {
|
||||||
|
id: stale.connection.id,
|
||||||
|
encryptedAccessToken: stale.connection.encryptedAccessToken,
|
||||||
|
encryptedRefreshToken: stale.connection.encryptedRefreshToken,
|
||||||
|
tokenExpiresAt: stale.connection.tokenExpiresAt,
|
||||||
|
selectedCalendarId: stale.connection.selectedCalendarId,
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
// A reassigned-away staff member's event is deleted via the CANCELLED
|
||||||
|
// path above regardless of the appointment's own current status — it's
|
||||||
|
// no longer this connection's appointment to reflect.
|
||||||
|
if (staleEvents.length > 0) {
|
||||||
|
await prisma.appointmentCalendarEvent.updateMany({
|
||||||
|
where: { id: { in: staleEvents.map((e) => e.id) } },
|
||||||
|
data: { status: CalendarSyncStatus.CANCELLED },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId: appointment.staffId } });
|
||||||
|
if (!connection || connection.revokedAt) return;
|
||||||
|
|
||||||
|
await syncOneConnection(prisma, { appointmentId, connection, provider });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync every future appointment for a newly connected staff member — see
|
||||||
|
* the brief's "Initial sync... default to the next 90 days." `provider`
|
||||||
|
* is injectable (defaults to the real Google provider) purely so tests
|
||||||
|
* can pass a `MockCalendarProvider` — see
|
||||||
|
* src/services/calendar/mock-calendar.provider.ts and the brief's "Do
|
||||||
|
* not call real Google Calendar APIs in automated tests."
|
||||||
|
*/
|
||||||
|
export async function runInitialSync(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
staffId: string,
|
||||||
|
provider: CalendarProvider = getProvider(),
|
||||||
|
): Promise<{ synced: number }> {
|
||||||
|
const connection = await prisma.staffCalendarConnection.findUnique({ where: { staffId } });
|
||||||
|
if (!connection) return { synced: 0 };
|
||||||
|
|
||||||
|
const appointments = await prisma.appointment.findMany({
|
||||||
|
where: {
|
||||||
|
staffId,
|
||||||
|
startAt: { gte: new Date(), lte: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) },
|
||||||
|
status: { notIn: [AppointmentStatus.PENDING, AppointmentStatus.CANCELLED] },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const appointment of appointments) {
|
||||||
|
await syncOneConnection(prisma, { appointmentId: appointment.id, connection, provider });
|
||||||
|
}
|
||||||
|
return { synced: appointments.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import type { CalendarProvider } from "./calendar-provider";
|
||||||
|
import { CalendarProviderError } from "./types";
|
||||||
|
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Calendar over its plain REST API (OAuth 2.0 + Calendar API v3)
|
||||||
|
* via `fetch` — no `googleapis` SDK dependency, matching this app's
|
||||||
|
* existing pattern of building directly against a documented REST
|
||||||
|
* contract rather than adding a heavy client library (see
|
||||||
|
* brevo-email-provider.ts's own header for the same reasoning applied to
|
||||||
|
* email). Minimum scopes: `calendar.events` (create/update/delete events)
|
||||||
|
* and `calendar.readonly` (list calendars to choose a destination) —
|
||||||
|
* deliberately not the full `calendar` scope, which also grants calendar
|
||||||
|
* *settings* management this app never needs.
|
||||||
|
*
|
||||||
|
* Unverified against a live Google account in this environment — no
|
||||||
|
* OAuth credentials were available to test against. See
|
||||||
|
* docs/phase-8-summary.md, "Unverified external-provider behaviour."
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCOPES = ["https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly", "email"].join(" ");
|
||||||
|
|
||||||
|
interface GoogleErrorBody {
|
||||||
|
error?: string | { message?: string; errors?: { reason?: string }[] };
|
||||||
|
error_description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGoogleError(status: number, body: GoogleErrorBody | undefined): CalendarProviderError {
|
||||||
|
const reason =
|
||||||
|
typeof body?.error === "object" ? body.error.errors?.[0]?.reason : typeof body?.error === "string" ? body.error : undefined;
|
||||||
|
|
||||||
|
if (status === 401 || reason === "authError") {
|
||||||
|
return new CalendarProviderError("TOKEN_EXPIRED", "The connection to Google Calendar has expired.", true);
|
||||||
|
}
|
||||||
|
if (status === 403 && reason === "insufficientPermissions") {
|
||||||
|
return new CalendarProviderError("CALENDAR_NOT_WRITABLE", "This calendar can't be written to.", false);
|
||||||
|
}
|
||||||
|
if (status === 403 || status === 429) {
|
||||||
|
return new CalendarProviderError("RATE_LIMITED", "Google Calendar is temporarily rate-limiting requests.", true);
|
||||||
|
}
|
||||||
|
if (status === 404) {
|
||||||
|
return new CalendarProviderError("CALENDAR_NOT_FOUND", "That calendar no longer exists.", false);
|
||||||
|
}
|
||||||
|
if (status === 400) {
|
||||||
|
return new CalendarProviderError("INVALID_EVENT", "Google Calendar rejected this event.", false);
|
||||||
|
}
|
||||||
|
if (status >= 500) {
|
||||||
|
return new CalendarProviderError("PROVIDER_OUTAGE", "Google Calendar is temporarily unavailable.", true);
|
||||||
|
}
|
||||||
|
return new CalendarProviderError("UNKNOWN", "Google Calendar returned an unexpected error.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function googleFetch(url: string, init: RequestInit): Promise<Response> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, init);
|
||||||
|
} catch {
|
||||||
|
throw new CalendarProviderError("TIMEOUT", "Could not reach Google Calendar.", true);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
let body: GoogleErrorBody | undefined;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
// Non-JSON error body — mapGoogleError below still has the status code.
|
||||||
|
}
|
||||||
|
throw mapGoogleError(response.status, body);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoogleConfig {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig(): GoogleConfig {
|
||||||
|
const clientId = process.env.GOOGLE_CALENDAR_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.GOOGLE_CALENDAR_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
throw new Error("GOOGLE_CALENDAR_CLIENT_ID and GOOGLE_CALENDAR_CLIENT_SECRET must be set to use Google Calendar sync.");
|
||||||
|
}
|
||||||
|
return { clientId, clientSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGoogleEventBody(event: CalendarEventInput) {
|
||||||
|
return {
|
||||||
|
summary: event.summary,
|
||||||
|
description: event.description,
|
||||||
|
location: event.location,
|
||||||
|
start: { dateTime: event.startAt.toISOString() },
|
||||||
|
end: { dateTime: event.endAt.toISOString() },
|
||||||
|
// Never public — see the brief's "Events must not be public."
|
||||||
|
visibility: "private",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GoogleCalendarProvider implements CalendarProvider {
|
||||||
|
name = "google";
|
||||||
|
|
||||||
|
buildAuthorizationUrl(params: { redirectUri: string; state: string }): string {
|
||||||
|
const { clientId } = loadConfig();
|
||||||
|
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
||||||
|
url.searchParams.set("client_id", clientId);
|
||||||
|
url.searchParams.set("redirect_uri", params.redirectUri);
|
||||||
|
url.searchParams.set("response_type", "code");
|
||||||
|
url.searchParams.set("scope", SCOPES);
|
||||||
|
url.searchParams.set("access_type", "offline");
|
||||||
|
// Forces Google to reissue a refresh token even for a user who
|
||||||
|
// previously connected — otherwise a reconnect after disconnect can
|
||||||
|
// silently get no refresh token at all.
|
||||||
|
url.searchParams.set("prompt", "consent");
|
||||||
|
url.searchParams.set("state", params.state);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async exchangeCodeForTokens(params: { code: string; redirectUri: string }): Promise<ExchangedTokens> {
|
||||||
|
const { clientId, clientSecret } = loadConfig();
|
||||||
|
const response = await googleFetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code: params.code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: params.redirectUri,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const tokens = await response.json();
|
||||||
|
if (!tokens.refresh_token) {
|
||||||
|
throw new CalendarProviderError(
|
||||||
|
"MISSING_REFRESH_TOKEN",
|
||||||
|
"Google didn't return a refresh token — please try connecting again.",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfoResponse = await googleFetch("https://www.googleapis.com/oauth2/v2/userinfo", {
|
||||||
|
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||||
|
});
|
||||||
|
const userInfo = await userInfoResponse.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||||
|
externalAccountId: userInfo.id,
|
||||||
|
accountEmail: userInfo.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccessToken(refreshToken: string): Promise<RefreshedAccessToken> {
|
||||||
|
const { clientId, clientSecret } = loadConfig();
|
||||||
|
const response = await googleFetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const tokens = await response.json();
|
||||||
|
return { accessToken: tokens.access_token, expiresAt: new Date(Date.now() + tokens.expires_in * 1000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWritableCalendars(accessToken: string): Promise<RemoteCalendarInfo[]> {
|
||||||
|
const response = await googleFetch("https://www.googleapis.com/calendar/v3/users/me/calendarList", {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
const items: { id: string; summary: string; primary?: boolean; accessRole: string }[] = data.items ?? [];
|
||||||
|
return items
|
||||||
|
.filter((item) => item.accessRole === "owner" || item.accessRole === "writer")
|
||||||
|
.map((item) => ({ id: item.id, name: item.summary, writable: true, primary: !!item.primary }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEvent(params: { accessToken: string; calendarId: string; event: CalendarEventInput }): Promise<{ providerEventId: string }> {
|
||||||
|
const response = await googleFetch(
|
||||||
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(toGoogleEventBody(params.event)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const created = await response.json();
|
||||||
|
return { providerEventId: created.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEvent(params: { accessToken: string; calendarId: string; providerEventId: string; event: CalendarEventInput }): Promise<void> {
|
||||||
|
await googleFetch(
|
||||||
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.providerEventId)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${params.accessToken}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(toGoogleEventBody(params.event)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEvent(params: { accessToken: string; calendarId: string; providerEventId: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
await googleFetch(
|
||||||
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(params.calendarId)}/events/${encodeURIComponent(params.providerEventId)}`,
|
||||||
|
{ method: "DELETE", headers: { Authorization: `Bearer ${params.accessToken}` } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Already deleted on the Google side (e.g. the owner removed it
|
||||||
|
// manually) — not a failure from this app's point of view.
|
||||||
|
if (error instanceof CalendarProviderError && error.code === "CALENDAR_NOT_FOUND") return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(refreshToken: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(refreshToken)}`, { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
// Best-effort — see the brief's "Revoke credentials where possible." A failure here doesn't block local disconnect.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { CalendarProvider } from "./calendar-provider";
|
||||||
|
import type { CalendarEventInput, ExchangedTokens, RefreshedAccessToken, RemoteCalendarInfo } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory `CalendarProvider` for tests — never makes a real network
|
||||||
|
* call, per the brief's "Do not call real Google Calendar APIs in
|
||||||
|
* automated tests." Records every create/update/delete so a test can
|
||||||
|
* assert on sync behavior (idempotency, retry, error mapping) without a
|
||||||
|
* live Google account.
|
||||||
|
*/
|
||||||
|
export class MockCalendarProvider implements CalendarProvider {
|
||||||
|
name = "mock";
|
||||||
|
|
||||||
|
events = new Map<string, CalendarEventInput>();
|
||||||
|
private nextEventId = 1;
|
||||||
|
public calendars: RemoteCalendarInfo[] = [{ id: "primary", name: "Primary Calendar", writable: true, primary: true }];
|
||||||
|
|
||||||
|
buildAuthorizationUrl(): string {
|
||||||
|
return "https://mock.local/authorize";
|
||||||
|
}
|
||||||
|
|
||||||
|
async exchangeCodeForTokens(): Promise<ExchangedTokens> {
|
||||||
|
return {
|
||||||
|
accessToken: "mock-access-token",
|
||||||
|
refreshToken: "mock-refresh-token",
|
||||||
|
expiresAt: new Date(Date.now() + 3600_000),
|
||||||
|
externalAccountId: "mock-account-id",
|
||||||
|
accountEmail: "mock@example.com",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccessToken(): Promise<RefreshedAccessToken> {
|
||||||
|
return { accessToken: "mock-access-token-refreshed", expiresAt: new Date(Date.now() + 3600_000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWritableCalendars(): Promise<RemoteCalendarInfo[]> {
|
||||||
|
return this.calendars;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEvent(params: { event: CalendarEventInput }): Promise<{ providerEventId: string }> {
|
||||||
|
const id = `mock-event-${this.nextEventId++}`;
|
||||||
|
this.events.set(id, params.event);
|
||||||
|
return { providerEventId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEvent(params: { providerEventId: string; event: CalendarEventInput }): Promise<void> {
|
||||||
|
this.events.set(params.providerEventId, params.event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEvent(params: { providerEventId: string }): Promise<void> {
|
||||||
|
this.events.delete(params.providerEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(): Promise<void> {
|
||||||
|
// No-op — nothing to revoke in-memory.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Provider-neutral shapes for one-way (business → calendar) appointment
|
||||||
|
* sync — see calendar-provider.ts for the interface these compose, and
|
||||||
|
* docs/phase-8-summary.md for the overall architecture.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CalendarEventInput {
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
location?: string;
|
||||||
|
startAt: Date;
|
||||||
|
endAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteCalendarInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Only calendars the connected account can write to are ever offered — see the brief's "Do not allow read-only calendars." */
|
||||||
|
writable: boolean;
|
||||||
|
primary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExchangedTokens {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
/** Absent when the provider didn't return one (e.g. a token refresh call, which doesn't reissue a refresh token). */
|
||||||
|
expiresAt: Date;
|
||||||
|
externalAccountId: string;
|
||||||
|
accountEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshedAccessToken {
|
||||||
|
accessToken: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CalendarProviderErrorCode =
|
||||||
|
| "CONSENT_DENIED"
|
||||||
|
| "TOKEN_EXPIRED"
|
||||||
|
| "TOKEN_REVOKED"
|
||||||
|
| "MISSING_REFRESH_TOKEN"
|
||||||
|
| "CALENDAR_NOT_FOUND"
|
||||||
|
| "CALENDAR_NOT_WRITABLE"
|
||||||
|
| "RATE_LIMITED"
|
||||||
|
| "PROVIDER_OUTAGE"
|
||||||
|
| "INVALID_EVENT"
|
||||||
|
| "TIMEOUT"
|
||||||
|
| "UNKNOWN";
|
||||||
|
|
||||||
|
/** Never exposes a raw provider response — see the brief's "Do not expose raw provider responses." */
|
||||||
|
export class CalendarProviderError extends Error {
|
||||||
|
code: CalendarProviderErrorCode;
|
||||||
|
retryable: boolean;
|
||||||
|
|
||||||
|
constructor(code: CalendarProviderErrorCode, message: string, retryable: boolean) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CalendarProviderError";
|
||||||
|
this.code = code;
|
||||||
|
this.retryable = retryable;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { NotificationType } from "@/generated/prisma/enums";
|
||||||
|
import {
|
||||||
|
cleanupTestBusiness,
|
||||||
|
createTestBusiness,
|
||||||
|
createTestCustomer,
|
||||||
|
prisma,
|
||||||
|
} from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
import { MockEmailProvider } from "../providers/mock-email-provider";
|
||||||
|
import { notify } from "../notification.service";
|
||||||
|
import { processDueNotificationDeliveries } from "../reminder-worker";
|
||||||
|
import { renderEmailForNotification } from "../render-email";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The follow-up-to-Phase-8 behaviour: every outgoing email's visible
|
||||||
|
* sender NAME is the installed business's own name, resolved fresh per
|
||||||
|
* send — never a global lookup, never cached across requests/business
|
||||||
|
* renames. See docs/phase-8-email-sender-summary.md.
|
||||||
|
*/
|
||||||
|
describe("business-name-as-sender (integration)", () => {
|
||||||
|
let business: TestBusiness;
|
||||||
|
let provider: MockEmailProvider;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
business = await createTestBusiness();
|
||||||
|
provider = new MockEmailProvider();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanupTestBusiness(business);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the installed business's name as the sender for a booking-confirmation-style email", async () => {
|
||||||
|
const { customerId } = await createTestCustomer(business);
|
||||||
|
const { notification } = await notify(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||||
|
recipientCustomerId: customerId,
|
||||||
|
bodyText: "You've earned a stamp.",
|
||||||
|
idempotencyKey: "test:sender:booking",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = await renderEmailForNotification(prisma, notification);
|
||||||
|
expect(rendered?.sender.name).toBe("Test Barbershop");
|
||||||
|
expect(rendered?.sender.name).not.toBe("NextChair");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the business name for a staff-invitation notification (falls through to the generic template, same sender resolution)", async () => {
|
||||||
|
const { userId } = await createTestCustomer(business);
|
||||||
|
const { notification } = await notify(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
type: NotificationType.STAFF_INVITATION_RECEIVED,
|
||||||
|
recipientUserId: userId,
|
||||||
|
subject: "You've been invited",
|
||||||
|
bodyText: "You've been invited to join the team.",
|
||||||
|
idempotencyKey: "test:sender:staff-invite",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = await renderEmailForNotification(prisma, notification);
|
||||||
|
expect(rendered?.sender.name).toBe("Test Barbershop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the resolved business name all the way through the background processor to the email provider", async () => {
|
||||||
|
const { customerId } = await createTestCustomer(business);
|
||||||
|
await notify(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||||
|
recipientCustomerId: customerId,
|
||||||
|
bodyText: "You've earned a stamp.",
|
||||||
|
idempotencyKey: "test:sender:worker",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||||
|
expect(result.sent).toBe(1);
|
||||||
|
expect(provider.sentEmails).toHaveLength(1);
|
||||||
|
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the updated business name for the very next email after a rename — no restart, no stale cache", async () => {
|
||||||
|
const { customerId } = await createTestCustomer(business);
|
||||||
|
await notify(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||||
|
recipientCustomerId: customerId,
|
||||||
|
bodyText: "First stamp.",
|
||||||
|
idempotencyKey: "test:sender:rename-before",
|
||||||
|
});
|
||||||
|
await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||||
|
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
|
||||||
|
|
||||||
|
await prisma.business.update({ where: { id: business.businessId }, data: { name: "Renamed Barbershop" } });
|
||||||
|
|
||||||
|
await notify(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||||
|
recipientCustomerId: customerId,
|
||||||
|
bodyText: "Second stamp.",
|
||||||
|
idempotencyKey: "test:sender:rename-after",
|
||||||
|
});
|
||||||
|
await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||||
|
|
||||||
|
expect(provider.sentEmails).toHaveLength(2);
|
||||||
|
expect(provider.sentEmails[1]?.sender.name).toBe("Renamed Barbershop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets one business's notification resolve another business's name (no accidental cross-business branding)", async () => {
|
||||||
|
const otherBusiness = await createTestBusiness();
|
||||||
|
try {
|
||||||
|
await prisma.business.update({ where: { id: otherBusiness.businessId }, data: { name: "Other Business Name" } });
|
||||||
|
const { customerId } = await createTestCustomer(business);
|
||||||
|
|
||||||
|
await notify(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||||
|
recipientCustomerId: customerId,
|
||||||
|
bodyText: "Stamp for business A.",
|
||||||
|
idempotencyKey: "test:sender:isolation",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await processDueNotificationDeliveries(prisma, { emailProvider: provider, businessId: business.businessId });
|
||||||
|
expect(result.sent).toBe(1);
|
||||||
|
expect(provider.sentEmails[0]?.sender.name).toBe("Test Barbershop");
|
||||||
|
expect(provider.sentEmails[0]?.sender.name).not.toBe("Other Business Name");
|
||||||
|
} finally {
|
||||||
|
await cleanupTestBusiness(otherBusiness);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { resolveSenderName } from "../sender-identity";
|
||||||
|
|
||||||
|
describe("resolveSenderName", () => {
|
||||||
|
const originalEmailFromName = process.env.EMAIL_FROM_NAME;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.EMAIL_FROM_NAME;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalEmailFromName === undefined) delete process.env.EMAIL_FROM_NAME;
|
||||||
|
else process.env.EMAIL_FROM_NAME = originalEmailFromName;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the business name when present", () => {
|
||||||
|
expect(resolveSenderName("Blake Heritage Barbershop")).toBe("Blake Heritage Barbershop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the business name even when EMAIL_FROM_NAME is also set", () => {
|
||||||
|
process.env.EMAIL_FROM_NAME = "Some Configured Name";
|
||||||
|
expect(resolveSenderName("Riverside Barbershop")).toBe("Riverside Barbershop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to EMAIL_FROM_NAME only when no business name is available", () => {
|
||||||
|
process.env.EMAIL_FROM_NAME = "Emergency Fallback Name";
|
||||||
|
expect(resolveSenderName(null)).toBe("Emergency Fallback Name");
|
||||||
|
expect(resolveSenderName(undefined)).toBe("Emergency Fallback Name");
|
||||||
|
expect(resolveSenderName("")).toBe("Emergency Fallback Name");
|
||||||
|
expect(resolveSenderName(" ")).toBe("Emergency Fallback Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the neutral name when neither a business name nor EMAIL_FROM_NAME is available", () => {
|
||||||
|
expect(resolveSenderName(null)).toBe("Booking Notifications");
|
||||||
|
expect(resolveSenderName(undefined)).toBe("Booking Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never defaults to the internal product name at any fallback tier", () => {
|
||||||
|
// Tier 1 (business name present), tier 3 (nothing configured at all) —
|
||||||
|
// neither branch has any "NextChair" literal anywhere in this
|
||||||
|
// function's source, so this is really a regression guard: if someone
|
||||||
|
// later "helpfully" reintroduces a NextChair default, this fails.
|
||||||
|
expect(resolveSenderName("Blake Heritage Barbershop")).not.toBe("NextChair");
|
||||||
|
expect(resolveSenderName(null)).not.toBe("NextChair");
|
||||||
|
expect(resolveSenderName(undefined)).not.toBe("NextChair");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,14 +20,19 @@ describe("BrevoEmailProvider", () => {
|
|||||||
username: "test-user",
|
username: "test-user",
|
||||||
password: "test-pass",
|
password: "test-pass",
|
||||||
fromAddress: "noreply@example.com",
|
fromAddress: "noreply@example.com",
|
||||||
fromName: "Test Business",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sendMail.mockReset();
|
sendMail.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
const params = { to: { email: "customer@example.com" }, subject: "Hi", html: "<p>Hi</p>", text: "Hi" };
|
const params = {
|
||||||
|
to: { email: "customer@example.com" },
|
||||||
|
sender: { name: "Blake Heritage Barbershop" },
|
||||||
|
subject: "Hi",
|
||||||
|
html: "<p>Hi</p>",
|
||||||
|
text: "Hi",
|
||||||
|
};
|
||||||
|
|
||||||
it("reports success with the provider message id on a clean send", async () => {
|
it("reports success with the provider message id on a clean send", async () => {
|
||||||
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
|
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
|
||||||
@@ -72,4 +77,12 @@ describe("BrevoEmailProvider", () => {
|
|||||||
expect(JSON.stringify(result)).not.toContain("test-pass");
|
expect(JSON.stringify(result)).not.toContain("test-pass");
|
||||||
expect(JSON.stringify(result)).not.toContain("test-user");
|
expect(JSON.stringify(result)).not.toContain("test-user");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends with the per-message sender name and the provider's own configured address, ignoring any sender.email", async () => {
|
||||||
|
sendMail.mockResolvedValueOnce({ messageId: "<abc123@smtp-relay.brevo.com>" });
|
||||||
|
await provider.sendEmail({ ...params, sender: { name: "Riverside Barbershop", email: "someone-else@other-domain.com" } });
|
||||||
|
expect(sendMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ from: { address: "noreply@example.com", name: "Riverside Barbershop" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -38,6 +38,16 @@ import type { EmailProvider, EmailSendResult, SendEmailParams } from "./email-pr
|
|||||||
* BrevoApiEmailProvider) can be added without touching
|
* BrevoApiEmailProvider) can be added without touching
|
||||||
* NotificationService or the background processor — that's the whole
|
* NotificationService or the background processor — that's the whole
|
||||||
* point of the provider abstraction.
|
* point of the provider abstraction.
|
||||||
|
*
|
||||||
|
* Sender identity (Phase 8 follow-up, see docs/phase-8-email-sender-summary.md):
|
||||||
|
* this config carries only the sender *address* (`fromAddress`) — a
|
||||||
|
* static provider setting, since it must be a domain/address actually
|
||||||
|
* verified with the SMTP relay. The visible sender *name* is no longer
|
||||||
|
* part of this config at all; it arrives per-message via
|
||||||
|
* `SendEmailParams.sender.name`, resolved fresh for every send by
|
||||||
|
* render-email.ts from the notification's own business. This file
|
||||||
|
* deliberately has no Prisma import and no business lookup of any kind —
|
||||||
|
* see that same doc for why the resolution lives one layer up.
|
||||||
*/
|
*/
|
||||||
export interface BrevoEmailProviderConfig {
|
export interface BrevoEmailProviderConfig {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -45,7 +55,6 @@ export interface BrevoEmailProviderConfig {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
fromAddress: string;
|
fromAddress: string;
|
||||||
fromName: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig {
|
export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig {
|
||||||
@@ -64,7 +73,6 @@ export function loadBrevoEmailProviderConfigFromEnv(): BrevoEmailProviderConfig
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
fromAddress,
|
fromAddress,
|
||||||
fromName: process.env.EMAIL_FROM_NAME ?? "NextChair",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,11 +98,9 @@ export class BrevoEmailProvider implements EmailProvider {
|
|||||||
readonly name = "brevo";
|
readonly name = "brevo";
|
||||||
private readonly transporter: nodemailer.Transporter;
|
private readonly transporter: nodemailer.Transporter;
|
||||||
private readonly fromAddress: string;
|
private readonly fromAddress: string;
|
||||||
private readonly fromName: string;
|
|
||||||
|
|
||||||
constructor(config: BrevoEmailProviderConfig) {
|
constructor(config: BrevoEmailProviderConfig) {
|
||||||
this.fromAddress = config.fromAddress;
|
this.fromAddress = config.fromAddress;
|
||||||
this.fromName = config.fromName;
|
|
||||||
this.transporter = nodemailer.createTransport({
|
this.transporter = nodemailer.createTransport({
|
||||||
host: config.host,
|
host: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
@@ -109,7 +115,11 @@ export class BrevoEmailProvider implements EmailProvider {
|
|||||||
async sendEmail(params: SendEmailParams): Promise<EmailSendResult> {
|
async sendEmail(params: SendEmailParams): Promise<EmailSendResult> {
|
||||||
try {
|
try {
|
||||||
const info = await this.transporter.sendMail({
|
const info = await this.transporter.sendMail({
|
||||||
from: { address: this.fromAddress, name: this.fromName },
|
// The address is always this provider's own env-configured,
|
||||||
|
// verified-with-the-relay value — `params.sender.email` is
|
||||||
|
// deliberately ignored (see EmailSender's own header). Only the
|
||||||
|
// display name comes from the caller.
|
||||||
|
from: { address: this.fromAddress, name: params.sender.name },
|
||||||
to: { address: params.to.email, name: params.to.name },
|
to: { address: params.to.email, name: params.to.name },
|
||||||
subject: params.subject,
|
subject: params.subject,
|
||||||
html: params.html,
|
html: params.html,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export class ConsoleEmailProvider implements EmailProvider {
|
|||||||
console.log(
|
console.log(
|
||||||
[
|
[
|
||||||
"──── [ConsoleEmailProvider] would send an email (not actually sent) ────",
|
"──── [ConsoleEmailProvider] would send an email (not actually sent) ────",
|
||||||
|
`From: ${params.sender.name}`,
|
||||||
`To: ${params.to.name ? `${params.to.name} <${params.to.email}>` : params.to.email}`,
|
`To: ${params.to.name ? `${params.to.name} <${params.to.email}>` : params.to.email}`,
|
||||||
`Subject: ${params.subject}`,
|
`Subject: ${params.subject}`,
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -12,8 +12,25 @@ export interface EmailAddress {
|
|||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visible sender identity for one message — resolved per-send by the
|
||||||
|
* notification/rendering layer (see render-email.ts's `resolveSenderName`),
|
||||||
|
* never baked into static provider config. `email` is optional and, for
|
||||||
|
* BrevoEmailProvider specifically, ignored: the sender *address* stays
|
||||||
|
* exclusively environment-configured (`EMAIL_FROM_ADDRESS`) because it
|
||||||
|
* must match a domain/address actually verified with the SMTP relay — see
|
||||||
|
* that provider's own header. `name` is present here so a future provider
|
||||||
|
* that *can* vary its address per-message isn't blocked by this shape.
|
||||||
|
*/
|
||||||
|
export interface EmailSender {
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SendEmailParams {
|
export interface SendEmailParams {
|
||||||
to: EmailAddress;
|
to: EmailAddress;
|
||||||
|
/** The resolved business name (or fallback) to show as the visible sender — see render-email.ts. */
|
||||||
|
sender: EmailSender;
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
/** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */
|
/** Plain-text fallback — required, not derived from `html`, so a broken tag can't silently produce an empty fallback. */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { EmailProvider } from "./providers/email-provider";
|
|||||||
import type { PushProvider } from "./providers/push-provider";
|
import type { PushProvider } from "./providers/push-provider";
|
||||||
import { getOwnerUserIds } from "./recipients";
|
import { getOwnerUserIds } from "./recipients";
|
||||||
import { renderEmailForNotification } from "./render-email";
|
import { renderEmailForNotification } from "./render-email";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The background notification processor — the one place EMAIL and PUSH
|
* The background notification processor — the one place EMAIL and PUSH
|
||||||
@@ -161,6 +162,7 @@ async function processEmailDelivery(prisma: PrismaClient, delivery: DueDelivery,
|
|||||||
|
|
||||||
const sendResult = await emailProvider.sendEmail({
|
const sendResult = await emailProvider.sendEmail({
|
||||||
to: { email: recipientEmail },
|
to: { email: recipientEmail },
|
||||||
|
sender: email.sender,
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
html: email.html,
|
html: email.html,
|
||||||
text: email.text,
|
text: email.text,
|
||||||
@@ -202,10 +204,17 @@ async function processPushDelivery(prisma: PrismaClient, delivery: DueDelivery,
|
|||||||
let lastFailureReason = "";
|
let lastFailureReason = "";
|
||||||
let anyRetryable = false;
|
let anyRetryable = false;
|
||||||
|
|
||||||
|
// Every notify() call sets a subject (see notification.service.ts),
|
||||||
|
// so this fallback is defensive rather than a normal path — but a
|
||||||
|
// push notification's title is genuinely user-visible (shown by the
|
||||||
|
// OS), so it still needs the business name, not the internal product
|
||||||
|
// name, when reached.
|
||||||
|
const fallbackTitle = delivery.notification.subject ?? (await getBranding()).name;
|
||||||
|
|
||||||
for (const subscription of subscriptions) {
|
for (const subscription of subscriptions) {
|
||||||
const sendResult = await pushProvider.sendPush({
|
const sendResult = await pushProvider.sendPush({
|
||||||
subscription: { endpoint: subscription.endpoint, p256dhKey: subscription.p256dhKey, authKey: subscription.authKey },
|
subscription: { endpoint: subscription.endpoint, p256dhKey: subscription.p256dhKey, authKey: subscription.authKey },
|
||||||
title: delivery.notification.subject ?? "NextChair",
|
title: fallbackTitle,
|
||||||
body: delivery.notification.bodyText,
|
body: delivery.notification.bodyText,
|
||||||
deepLinkPath: delivery.notification.deepLinkPath ?? undefined,
|
deepLinkPath: delivery.notification.deepLinkPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||||
import { NotificationType } from "@/generated/prisma/enums";
|
import { NotificationType } from "@/generated/prisma/enums";
|
||||||
import { renderBookingCancelledEmail, renderBookingConfirmedEmail, renderBookingPendingApprovalEmail, renderBookingReminderEmail, renderBookingRescheduledEmail } from "./templates/booking-emails";
|
import {
|
||||||
|
renderBookingCancelledEmail,
|
||||||
|
renderBookingConfirmedEmail,
|
||||||
|
renderBookingPendingApprovalEmail,
|
||||||
|
renderBookingReminderEmail,
|
||||||
|
renderBookingRescheduledEmail,
|
||||||
|
} from "./templates/booking-emails";
|
||||||
import type { AppointmentEmailData } from "./templates/booking-emails";
|
import type { AppointmentEmailData } from "./templates/booking-emails";
|
||||||
import { renderGenericNotificationEmail } from "./templates/generic-email";
|
import { renderGenericNotificationEmail } from "./templates/generic-email";
|
||||||
import { renderLoyaltyRewardEarnedEmail, renderLoyaltyRewardRedeemedEmail } from "./templates/loyalty-emails";
|
import {
|
||||||
|
renderLoyaltyRewardEarnedEmail,
|
||||||
|
renderLoyaltyRewardRedeemedEmail,
|
||||||
|
} from "./templates/loyalty-emails";
|
||||||
import type { LoyaltyRewardEmailData } from "./templates/loyalty-emails";
|
import type { LoyaltyRewardEmailData } from "./templates/loyalty-emails";
|
||||||
import type { RenderedEmail } from "./templates/shared";
|
import type { RenderedEmail } from "./templates/shared";
|
||||||
|
import { resolveSenderName } from "./sender-identity";
|
||||||
|
import type { EmailSender } from "./providers/email-provider";
|
||||||
|
|
||||||
|
export interface RenderedEmailWithSender extends RenderedEmail {
|
||||||
|
/** Resolved fresh on every call from the notification's own business — see resolveSenderName. */
|
||||||
|
sender: EmailSender;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a stored Notification row, builds the actual EMAIL to send —
|
* Given a stored Notification row, builds the actual EMAIL to send —
|
||||||
@@ -17,17 +33,35 @@ import type { RenderedEmail } from "./templates/shared";
|
|||||||
* (e.g. a since-updated business name) rather than a stale snapshot. This
|
* (e.g. a since-updated business name) rather than a stale snapshot. This
|
||||||
* is the file that dispatches by NotificationType; the templates/ folder
|
* is the file that dispatches by NotificationType; the templates/ folder
|
||||||
* itself is pure and knows nothing about Prisma.
|
* itself is pure and knows nothing about Prisma.
|
||||||
|
*
|
||||||
|
* The sender *name* (see RenderedEmailWithSender) is resolved here too,
|
||||||
|
* from this exact same `business` row — scoped by `notification.businessId`
|
||||||
|
* via `findUnique`, never a global `findFirst()`, so one business can
|
||||||
|
* never accidentally supply another's branding to a misdirected send.
|
||||||
*/
|
*/
|
||||||
export async function renderEmailForNotification(
|
export async function renderEmailForNotification(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
notification: { id: string; type: NotificationType; businessId: string; appointmentId: string | null; loyaltyRewardId: string | null; subject: string | null; bodyText: string; deepLinkPath: string | null; recipientUserId: string | null; recipientCustomerId: string | null },
|
notification: {
|
||||||
): Promise<RenderedEmail | null> {
|
id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
businessId: string;
|
||||||
|
appointmentId: string | null;
|
||||||
|
loyaltyRewardId: string | null;
|
||||||
|
subject: string | null;
|
||||||
|
bodyText: string;
|
||||||
|
deepLinkPath: string | null;
|
||||||
|
recipientUserId: string | null;
|
||||||
|
recipientCustomerId: string | null;
|
||||||
|
},
|
||||||
|
): Promise<RenderedEmailWithSender | null> {
|
||||||
const business = await prisma.business.findUnique({
|
const business = await prisma.business.findUnique({
|
||||||
where: { id: notification.businessId },
|
where: { id: notification.businessId },
|
||||||
include: { locations: true, settings: true },
|
include: { locations: true, settings: true },
|
||||||
});
|
});
|
||||||
if (!business) return null;
|
if (!business) return null;
|
||||||
|
|
||||||
|
const sender: EmailSender = { name: resolveSenderName(business.name) };
|
||||||
|
|
||||||
const businessInfo = {
|
const businessInfo = {
|
||||||
name: business.name,
|
name: business.name,
|
||||||
logoUrl: business.logoUrl ?? undefined,
|
logoUrl: business.logoUrl ?? undefined,
|
||||||
@@ -39,7 +73,10 @@ export async function renderEmailForNotification(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const recipientName = await resolveRecipientName(prisma, notification);
|
const recipientName = await resolveRecipientName(prisma, notification);
|
||||||
|
const rendered = await renderBody();
|
||||||
|
return { ...rendered, sender };
|
||||||
|
|
||||||
|
async function renderBody(): Promise<RenderedEmail> {
|
||||||
switch (notification.type) {
|
switch (notification.type) {
|
||||||
case NotificationType.BOOKING_CONFIRMED:
|
case NotificationType.BOOKING_CONFIRMED:
|
||||||
case NotificationType.BOOKING_PENDING_APPROVAL:
|
case NotificationType.BOOKING_PENDING_APPROVAL:
|
||||||
@@ -52,16 +89,23 @@ export async function renderEmailForNotification(
|
|||||||
manageAppointmentPath: notification.deepLinkPath ?? "/appointments",
|
manageAppointmentPath: notification.deepLinkPath ?? "/appointments",
|
||||||
});
|
});
|
||||||
if (!data) return renderFallback();
|
if (!data) return renderFallback();
|
||||||
if (notification.type === NotificationType.BOOKING_CONFIRMED) return renderBookingConfirmedEmail(data);
|
if (notification.type === NotificationType.BOOKING_CONFIRMED)
|
||||||
if (notification.type === NotificationType.BOOKING_PENDING_APPROVAL) return renderBookingPendingApprovalEmail(data);
|
return renderBookingConfirmedEmail(data);
|
||||||
if (notification.type === NotificationType.BOOKING_RESCHEDULED) return renderBookingRescheduledEmail(data);
|
if (notification.type === NotificationType.BOOKING_PENDING_APPROVAL)
|
||||||
if (notification.type === NotificationType.BOOKING_CANCELLED) return renderBookingCancelledEmail(data);
|
return renderBookingPendingApprovalEmail(data);
|
||||||
|
if (notification.type === NotificationType.BOOKING_RESCHEDULED)
|
||||||
|
return renderBookingRescheduledEmail(data);
|
||||||
|
if (notification.type === NotificationType.BOOKING_CANCELLED)
|
||||||
|
return renderBookingCancelledEmail(data);
|
||||||
return renderBookingReminderEmail(data);
|
return renderBookingReminderEmail(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
case NotificationType.LOYALTY_REWARD_EARNED:
|
case NotificationType.LOYALTY_REWARD_EARNED:
|
||||||
case NotificationType.LOYALTY_REWARD_REDEEMED: {
|
case NotificationType.LOYALTY_REWARD_REDEEMED: {
|
||||||
const data = await buildLoyaltyRewardEmailData(prisma, notification, { business: businessInfo, recipientName });
|
const data = await buildLoyaltyRewardEmailData(prisma, notification, {
|
||||||
|
business: businessInfo,
|
||||||
|
recipientName,
|
||||||
|
});
|
||||||
if (!data) return renderFallback();
|
if (!data) return renderFallback();
|
||||||
return notification.type === NotificationType.LOYALTY_REWARD_EARNED
|
return notification.type === NotificationType.LOYALTY_REWARD_EARNED
|
||||||
? renderLoyaltyRewardEarnedEmail(data)
|
? renderLoyaltyRewardEarnedEmail(data)
|
||||||
@@ -82,6 +126,7 @@ export async function renderEmailForNotification(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveRecipientName(
|
async function resolveRecipientName(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
@@ -95,7 +140,10 @@ async function resolveRecipientName(
|
|||||||
if (customer) return `${customer.firstName} ${customer.lastName}`.trim();
|
if (customer) return `${customer.firstName} ${customer.lastName}`.trim();
|
||||||
}
|
}
|
||||||
if (notification.recipientUserId) {
|
if (notification.recipientUserId) {
|
||||||
const user = await prisma.user.findUnique({ where: { id: notification.recipientUserId }, select: { name: true } });
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: notification.recipientUserId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
if (user?.name) return user.name;
|
if (user?.name) return user.name;
|
||||||
}
|
}
|
||||||
return "there";
|
return "there";
|
||||||
@@ -113,15 +161,28 @@ async function buildAppointmentEmailData(
|
|||||||
});
|
});
|
||||||
if (!appointment) return null;
|
if (!appointment) return null;
|
||||||
|
|
||||||
const totalPrice = appointment.services.reduce((sum: number, s: { priceAtBooking: Prisma.Decimal }) => sum + Number(s.priceAtBooking), 0);
|
const totalPrice = appointment.services.reduce(
|
||||||
const totalMinutes = appointment.services.reduce((sum: number, s: { durationAtBookingMinutes: number }) => sum + s.durationAtBookingMinutes, 0);
|
(sum: number, s: { priceAtBooking: Prisma.Decimal }) => sum + Number(s.priceAtBooking),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalMinutes = appointment.services.reduce(
|
||||||
|
(sum: number, s: { durationAtBookingMinutes: number }) => sum + s.durationAtBookingMinutes,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
staffName: appointment.staff.user.name ?? "your barber",
|
staffName: appointment.staff.user.name ?? "your barber",
|
||||||
serviceNames: appointment.services.map((s: { service: { name: string } }) => s.service.name),
|
serviceNames: appointment.services.map((s: { service: { name: string } }) => s.service.name),
|
||||||
dateLabel: appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }),
|
dateLabel: appointment.startAt.toLocaleDateString("en-GB", {
|
||||||
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
}),
|
||||||
|
timeLabel: appointment.startAt.toLocaleTimeString("en-GB", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
durationLabel: `${totalMinutes} min`,
|
durationLabel: `${totalMinutes} min`,
|
||||||
totalLabel: `£${totalPrice.toFixed(2)}`,
|
totalLabel: `£${totalPrice.toFixed(2)}`,
|
||||||
appointmentRef: appointment.id.slice(-8),
|
appointmentRef: appointment.id.slice(-8),
|
||||||
@@ -134,7 +195,9 @@ async function buildLoyaltyRewardEmailData(
|
|||||||
base: Pick<LoyaltyRewardEmailData, "business" | "recipientName">,
|
base: Pick<LoyaltyRewardEmailData, "business" | "recipientName">,
|
||||||
): Promise<LoyaltyRewardEmailData | null> {
|
): Promise<LoyaltyRewardEmailData | null> {
|
||||||
if (!notification.loyaltyRewardId) return null;
|
if (!notification.loyaltyRewardId) return null;
|
||||||
const reward = await prisma.loyaltyReward.findUnique({ where: { id: notification.loyaltyRewardId } });
|
const reward = await prisma.loyaltyReward.findUnique({
|
||||||
|
where: { id: notification.loyaltyRewardId },
|
||||||
|
});
|
||||||
if (!reward) return null;
|
if (!reward) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -142,7 +205,11 @@ async function buildLoyaltyRewardEmailData(
|
|||||||
rewardLabel: reward.label,
|
rewardLabel: reward.label,
|
||||||
rewardDescription: reward.label,
|
rewardDescription: reward.label,
|
||||||
expiresLabel: reward.expiresAt
|
expiresLabel: reward.expiresAt
|
||||||
? reward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
|
? reward.expiresAt.toLocaleDateString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
loyaltyCardPath: "/home",
|
loyaltyCardPath: "/home",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Resolves the visible email sender *name* — pure and DB-free (the caller,
|
||||||
|
* render-email.ts, has already loaded the business); kept as its own tiny
|
||||||
|
* module so the priority order is independently unit-testable. Never
|
||||||
|
* returns the internal product name. See
|
||||||
|
* docs/phase-8-email-sender-summary.md for the full priority-order
|
||||||
|
* rationale.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NEUTRAL_FALLBACK_SENDER_NAME = "Booking Notifications";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority: 1) the installed business's own name, 2) EMAIL_FROM_NAME as an
|
||||||
|
* emergency-only fallback (never required to be kept in sync with the
|
||||||
|
* business name — see .env.example), 3) a neutral, non-product name. A
|
||||||
|
* normally-installed application always resolves tier 1 — every
|
||||||
|
* Notification is business-scoped (see prisma/schema.prisma,
|
||||||
|
* Notification.businessId is required) — so tiers 2/3 exist purely as
|
||||||
|
* defensive fallbacks for a business row somehow having a blank name, or
|
||||||
|
* for a future notification type with no business association at all.
|
||||||
|
*/
|
||||||
|
export function resolveSenderName(businessName: string | null | undefined): string {
|
||||||
|
const trimmedBusinessName = businessName?.trim();
|
||||||
|
if (trimmedBusinessName) return trimmedBusinessName;
|
||||||
|
|
||||||
|
const envFallback = process.env.EMAIL_FROM_NAME?.trim();
|
||||||
|
if (envFallback) return envFallback;
|
||||||
|
|
||||||
|
return NEUTRAL_FALLBACK_SENDER_NAME;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export function renderStaffInvitationEmail(data: StaffInvitationEmailData): Rend
|
|||||||
const bodyHtml = [
|
const bodyHtml = [
|
||||||
paragraph(`Hi ${data.staffName},`),
|
paragraph(`Hi ${data.staffName},`),
|
||||||
paragraph(
|
paragraph(
|
||||||
`${data.invitedByName} has added you as ${data.roleName === "Owner" ? "an" : "a"} ${data.roleName} at ${data.business.name} on NextChair. Set your password to get started.`,
|
`${data.invitedByName} has added you as ${data.roleName === "Owner" ? "an" : "a"} ${data.roleName} at ${data.business.name}. Set your password to get started.`,
|
||||||
),
|
),
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export function renderStaffInvitationEmail(data: StaffInvitationEmailData): Rend
|
|||||||
bodyLines: [
|
bodyLines: [
|
||||||
`Hi ${data.staffName},`,
|
`Hi ${data.staffName},`,
|
||||||
"",
|
"",
|
||||||
`${data.invitedByName} has added you as a ${data.roleName} at ${data.business.name} on NextChair. Set your password to get started.`,
|
`${data.invitedByName} has added you as a ${data.roleName} at ${data.business.name}. Set your password to get started.`,
|
||||||
],
|
],
|
||||||
actionLabel: "Set Your Password",
|
actionLabel: "Set Your Password",
|
||||||
actionUrl,
|
actionUrl,
|
||||||
|
|||||||
Reference in new issue
Block a user