Phase 7
This commit is contained in:
189 files changed
+9845
-627
No files matched your search
+34
-3
@@ -1,6 +1,9 @@
|
|||||||
# Database (PostgreSQL). Prisma schema avoids Postgres-only features so this
|
# Database (MySQL 8.0 — migrated from PostgreSQL in Phase 7, see
|
||||||
# can be pointed at MySQL later with a provider change + `prisma migrate`.
|
# docs/phase-7-summary.md; the production host only supports MySQL).
|
||||||
DATABASE_URL="postgresql://user:password@localhost:5432/nextchair?schema=public"
|
DATABASE_URL="mysql://user:password@localhost:3306/nextchair"
|
||||||
|
# Optional: cap the connection pool (see src/lib/db/prisma.ts). Lower this
|
||||||
|
# for a hosting provider with a small max-connections limit.
|
||||||
|
DATABASE_CONNECTION_LIMIT="5"
|
||||||
|
|
||||||
# Auth.js (NextAuth v5). Generate with: npx auth secret
|
# Auth.js (NextAuth v5). Generate with: npx auth secret
|
||||||
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
|
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
|
||||||
@@ -11,3 +14,31 @@ AUTH_GOOGLE_SECRET=""
|
|||||||
|
|
||||||
# Base URL of the app (used for callback URLs, email links, etc.)
|
# Base URL of the app (used for callback URLs, email links, etc.)
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# Email (Phase 7) — see src/services/notifications/providers.
|
||||||
|
# "console" logs would-be emails to the server console instead of sending
|
||||||
|
# (safe default — never send real email from an unconfigured environment).
|
||||||
|
# "brevo" sends for real over Brevo's SMTP relay via nodemailer (see
|
||||||
|
# brevo-email-provider.ts for why SMTP rather than Brevo's REST API).
|
||||||
|
EMAIL_PROVIDER="console"
|
||||||
|
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
||||||
|
EMAIL_SMTP_PORT="587"
|
||||||
|
EMAIL_SMTP_USERNAME=""
|
||||||
|
EMAIL_SMTP_PASSWORD=""
|
||||||
|
EMAIL_FROM_ADDRESS=""
|
||||||
|
EMAIL_FROM_NAME="NextChair"
|
||||||
|
|
||||||
|
# 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 —
|
||||||
|
# it's duplicated because the browser needs it for pushManager.subscribe.
|
||||||
|
# PUSH_PROVIDER="web-push" to actually send; defaults to "console" (logs only).
|
||||||
|
PUSH_PROVIDER="console"
|
||||||
|
VAPID_PUBLIC_KEY=""
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
||||||
|
VAPID_PRIVATE_KEY=""
|
||||||
|
VAPID_SUBJECT="mailto:owner@example.com"
|
||||||
|
|
||||||
|
# Background notification processing (Phase 7) — shared secret an external
|
||||||
|
# scheduler presents as `Authorization: Bearer <value>` to
|
||||||
|
# /api/cron/process-notifications. Generate with: openssl rand -hex 32
|
||||||
|
CRON_SECRET=""
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# NextChair CI — lint, typecheck, unit + integration + E2E tests, and a
|
||||||
|
# production build, all against a real MySQL 8.0 service container (this
|
||||||
|
# app has no SQLite/mock-DB fallback — see docs/implementation-plan.md and
|
||||||
|
# the Phase 7 Postgres→MySQL migration notes in prisma/schema.prisma).
|
||||||
|
#
|
||||||
|
# Lives under .gitea/workflows/ because this repo's origin is a
|
||||||
|
# self-hosted Gitea instance (see `git remote -v`), and Gitea Actions only
|
||||||
|
# auto-discovers workflows there — not .github/workflows/. The YAML syntax
|
||||||
|
# itself is GitHub Actions-compatible, so this file works unchanged if the
|
||||||
|
# repo is ever pushed to GitHub too; only the directory matters for
|
||||||
|
# auto-discovery. If Gitea Actions isn't enabled for this instance, no
|
||||||
|
# harm — the workflow simply never triggers.
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# Cancel a still-running CI run for the same branch/PR once a newer commit
|
||||||
|
# lands, instead of letting both finish.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
# A throwaway, non-secret DB the mysql service creates for this run only
|
||||||
|
# — never a real deployment's database.
|
||||||
|
DATABASE_URL: mysql://root:root@127.0.0.1:3306/nextchair
|
||||||
|
# Any fixed value works (see src/lib/booking-draft/sign.ts) — only needs
|
||||||
|
# to be consistent for the lifetime of this one CI run.
|
||||||
|
AUTH_SECRET: ci-run-secret-do-not-use-in-production
|
||||||
|
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
||||||
|
CRON_SECRET: ci-run-cron-secret-do-not-use-in-production
|
||||||
|
# Explicit, even though these already default to "console" — CI must
|
||||||
|
# never attempt a real send regardless of what the environment defaults
|
||||||
|
# to (see src/services/notifications/providers/index.ts).
|
||||||
|
EMAIL_PROVIDER: console
|
||||||
|
PUSH_PROVIDER: console
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
env:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: nextchair
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
|
options: >-
|
||||||
|
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: npx prisma generate
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Apply migrations
|
||||||
|
run: npx prisma migrate deploy
|
||||||
|
|
||||||
|
- name: Seed database
|
||||||
|
run: npm run prisma:seed
|
||||||
|
|
||||||
|
- name: Integration tests
|
||||||
|
run: npm run test:integration
|
||||||
|
|
||||||
|
- name: Production build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: E2E tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
|
||||||
|
- name: Upload Playwright traces/screenshots on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-test-results
|
||||||
|
# playwright.config.ts uses the "list" reporter (no HTML report
|
||||||
|
# dir) but does keep a trace + screenshot per failed test here —
|
||||||
|
# see trace/screenshot settings in that file.
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Phase 7 — Notifications, Reporting, PWA and Production Polish: Summary
|
||||||
|
|
||||||
|
The closing production-readiness phase: finishing the two features Phase 5/6 deliberately left partial (the customer-facing loyalty experience and owner reporting), turning the app into an installable, offline-tolerant PWA, and a hardening pass across security, accessibility, performance and CI that touches nearly every route in the app without changing any of its domain logic. The notification and push-subscription infrastructure itself (`NotificationService`, email/push providers, reminder scheduling, the in-app notification centre) was built earlier in this same phase, ahead of this window's context; this document covers the remainder — loyalty completion, reports, PWA, hardening, and the phase-closing test/verification work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What was built
|
||||||
|
|
||||||
|
- **Customer Loyalty page** (`/loyalty`) — the customer-facing counterpart to the barber's stamp UI and the owner's programme config: current stamp count, an available-reward banner (with expiry date, if any), and a full ledger-style transaction history. Sign-in-gated with a friendly prompt, not a hard redirect, matching the same pattern `/appointments` and `/settings` already use.
|
||||||
|
- **Reward expiry sweep** (`expireOverdueRewards`) — a scheduled job, not a UI feature: bulk-expires `AVAILABLE` rewards past `expiresAt`, then notifies customers whose reward expires within the next 7 days. Piggybacks on the existing notification cron rather than a second scheduled endpoint.
|
||||||
|
- **Owner Reports** (`/owner/reports`) — six data-backed sections (Appointments, Services, Staff, Customers, Loyalty, Notifications) over a selectable date range, with 7/30/90-day presets. Deliberately separate from the Phase 6 dashboard's single-day operational summary — this is historical, range-based reporting, not a live-today view.
|
||||||
|
- **PWA install experience** — a real `manifest.webmanifest` (via Next's file-convention `manifest.ts`), app icons (including maskable variants), and an `InstallAppButton` that captures the browser's `beforeinstallprompt` event and surfaces it as a real in-app action, added to all three settings pages (customer/staff/owner) rather than a fourth new route.
|
||||||
|
- **Service worker: offline shell + update handshake** — precaches a small static app shell (offline fallback page, manifest, icons) so a lost connection shows a real "you're offline" screen instead of the browser's own error page; a new SW version installs and waits rather than taking over immediately, so a banner can prompt the user before switching. Deliberately does **not** attempt to cache the dynamic, per-session booking/calendar/loyalty content — see §7 for why.
|
||||||
|
- **Security hardening** — a real `Content-Security-Policy` and standard security response headers on every route, in-memory rate limiting on sign-in/registration/guest-booking, constant-time comparisons for the cron shared secret, and a timing-safe dummy-hash comparison in `authorize()` so a failed sign-in takes the same time whether or not the email exists.
|
||||||
|
- **Accessibility, responsive and UX polish** — a sweep across the whole app driven by a dedicated audit (see §9), fixing concrete issues rather than a generic pass.
|
||||||
|
- **Performance and index review** — a dedicated audit (see §10) that found and fixed a genuine missing index and eliminated a few `select`/`include` over-fetches; most of the audit's findings were about things already done correctly (`cache()`-deduped lookups, no N+1 patterns in the hot paths), and are recorded as "reviewed, no change needed" rather than invented busywork.
|
||||||
|
- **CI pipeline** (`.gitea/workflows/ci.yml`) — lint, typecheck, unit tests, a real MySQL 8.0 service container, migrate + seed, integration tests, build, and the full Playwright E2E suite, on every push. Gitea Actions, not GitHub Actions — this repo's actual remote is self-hosted Gitea (confirmed via `git remote -v`), and Gitea Actions workflow YAML is GitHub-Actions-compatible.
|
||||||
|
- **Seed data extension** — a few additional seeded notifications (booking confirmed, loyalty stamp added, staff/owner new-appointment) and a second loyalty customer (Sam Osei) seeded at exactly the reward threshold, so the loyalty and notification UIs have real, varied data to render against out of the box.
|
||||||
|
|
||||||
|
## 2. Routes created
|
||||||
|
|
||||||
|
Inside `(customer)`: `/loyalty`.
|
||||||
|
|
||||||
|
Inside `(owner)`: `/owner/reports`.
|
||||||
|
|
||||||
|
No other new routes this phase — PWA install/offline behavior and security headers apply app-wide rather than introducing new pages, and the notification centre/push-preferences routes were built earlier in this phase, ahead of this window.
|
||||||
|
|
||||||
|
## 3. Components
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
- `src/features/pwa/components/install-app-button.tsx` — captures `beforeinstallprompt`, renders nothing if the browser never fires it (already installed, or unsupported) or once installed (`appinstalled` event).
|
||||||
|
- `src/features/pwa/components/service-worker-manager.tsx` — registers the service worker and renders the update-available banner when `registration.waiting` appears; mounted once in `src/components/providers.tsx` inside `<SessionProvider>` so it runs on every authenticated and public page alike.
|
||||||
|
- Loyalty page composes the existing `LoyaltyCard` component (built earlier this phase) rather than introducing a new card — the customer page is the first real consumer of what had, until now, only been used elsewhere.
|
||||||
|
|
||||||
|
**Fixed, not new:** `src/features/notifications/components/push-subscribe-toggle.tsx` — a genuine hydration bug (see §11) where `useState`'s lazy initializer read `window`/`Notification.permission` directly; moved all browser-API detection into `useEffect`.
|
||||||
|
|
||||||
|
## 4. Domain services
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
- **`src/services/reporting/reports.service.ts`** — six pure, read-only aggregation functions (`getAppointmentReport`, `getServiceReport`, `getStaffReport`, `getCustomerReport`, `getLoyaltyReport`, `getNotificationReport`), each `(prisma, {businessId, range: {from, to}})`. Kept as a sibling to, not a merge into, `owner-reporting.service.ts` — that file answers "what does today look like," this one answers "what happened in a chosen range," and the two have no overlapping callers.
|
||||||
|
- **`src/lib/security/rate-limit.ts`** — `checkRateLimit(key, {maxAttempts, windowMs})`, an in-memory fixed-window counter with a lazy-sweep bound (`MAX_TRACKED_KEYS = 50_000`) so an unbounded set of keys (e.g. one per attacker-controlled email) can't grow the map forever. Explicit, documented trade-off: single-process only, no shared store — appropriate for this app's one-Node.js-server deployment shape, not appropriate unchanged if a future phase scales horizontally.
|
||||||
|
- **`src/lib/security/request-ip.ts`** — `getRequestIp()` via `next/headers`, the one place every rate-limited Server Action gets the caller's IP from.
|
||||||
|
|
||||||
|
**Extended:**
|
||||||
|
- **`LoyaltyService`** — `expireOverdueRewards(prisma, now)`: bulk `updateMany` (`AVAILABLE` → `EXPIRED` where `expiresAt` has passed), then notifies rewards expiring within 7 days (day-scoped idempotency key, so the same reward doesn't re-notify every time the cron runs that day). `redeemReward` gained a defense-in-depth expiry check — a reward that's `AVAILABLE` in the database but technically past its `expiresAt` (the sweep hasn't run yet) is still refused at redemption time, not just relied on the sweep to catch first.
|
||||||
|
|
||||||
|
**Reused as-is:** every scheduling/business/staff/loyalty/notification service from Phases 3–6/earlier-Phase-7 — this phase added no new mutation surface to the booking or scheduling core, only read-side reporting, security middleware, and PWA plumbing around the existing domain.
|
||||||
|
|
||||||
|
## 5. Schema changes
|
||||||
|
|
||||||
|
One migration this window: **`20260803192355_break_staff_id_index`** — adds `@@index([staffId])` to the `Break` model (see §10 for why).
|
||||||
|
|
||||||
|
No other schema changes were needed this phase — loyalty expiry uses the existing `LoyaltyReward.status`/`expiresAt` columns, and reporting is read-only aggregation over existing tables.
|
||||||
|
|
||||||
|
## 6. Reports approach
|
||||||
|
|
||||||
|
Each of the six report functions runs its own targeted Prisma aggregation/groupBy scoped to `{businessId, range}` — no shared "fetch everything then slice in memory" base query, since the six sections have genuinely different shapes (status counts, per-service/per-staff revenue rollups, new-vs-returning customer classification, loyalty ledger sums, notification delivery-status breakdown by type). `getCustomerReport`'s new-vs-returning split is the one non-trivial piece: a customer counts as "returning" if they have any `COMPLETED` appointment *before* `range.from`, not merely more than one appointment inside the range. Money fields are returned as decimal strings throughout, matching the money-handling discipline established since Phase 6 — revenue is never a JS `number` anywhere in the aggregation or the page that renders it. The owner reports page itself is a plain Server Component reading query-string `from`/`to` params (falling back to the last-7-days preset), so — like the Phase 6 owner calendar — there's no client-side date-range state to keep in sync with the URL.
|
||||||
|
|
||||||
|
## 7. PWA approach
|
||||||
|
|
||||||
|
The service worker's scope is deliberately narrow, stated up front in its own file header: this app is almost entirely dynamic, signed-in, per-request server-rendered content (appointments, calendars, loyalty balances), so caching *that* would risk serving stale data or, worse, another signed-in user's data on a shared device. What it does do: precache a small static app shell (`/offline`, the manifest, two icon sizes) with a network-first strategy for page navigations that only falls back to the offline page when the network genuinely fails; cache-first for the app-shell assets themselves, since they're versioned by `CACHE_NAME` and only change on a new SW deploy; and it never intercepts non-GET requests at all, so Server Actions and API mutations are guaranteed to never be silently served from a cache. The update-available handshake follows the documented `registration.waiting` pattern: a new SW installs and calls neither `skipWaiting()` nor takes over immediately; `ServiceWorkerManager` detects `registration.waiting`, shows a banner, and only on explicit user action sends `postMessage({type: "SKIP_WAITING"})`, which the SW's own `message` handler honors, followed by a `controllerchange`-triggered reload — so an already-open tab is never switched out from under the user without their say-so.
|
||||||
|
|
||||||
|
The install button follows the standard `beforeinstallprompt` capture pattern: the event is intercepted and `preventDefault()`-ed on `window`, stashed, and only replayed (`.prompt()`) when the user clicks the button in Settings — browsers that never fire the event (iOS Safari, or an already-installed app) simply render nothing, rather than a button that does nothing when clicked.
|
||||||
|
|
||||||
|
## 8. Security hardening
|
||||||
|
|
||||||
|
**Headers**: a real `Content-Security-Policy`, built per-request in `src/proxy.ts` (Next 16's renamed `middleware` convention) alongside standard headers (`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, a locked-down `Permissions-Policy`, HSTS) added globally via `next.config.ts`'s `headers()`. The CSP's `script-src` has a documented history worth keeping: a nonce-gated `'strict-dynamic'` policy (Next's own documented pattern) was tried first, and reverted after live production-build testing showed it broke hydration on every page — Next's own inline RSC-hydration payload scripts never receive the generated nonce, so `'strict-dynamic'` refused to run them (confirmed via `BAILOUT_TO_CLIENT_SIDE_RENDERING` markers with `"nonce":"$undefined"` in the served HTML). `script-src 'self' 'unsafe-inline'` (with `'unsafe-eval'` only in development, for Fast Refresh) is the reliable fallback — Next's own code loads via external `<script src="/_next/static/...">` tags, already covered by `'self'`; `'unsafe-inline'` is only there for those small inline data-streaming scripts. `style-src` needs `'unsafe-inline'` for the same long-standing reason as ever: this app uses plain React `style={{...}}` props for dynamic widths (progress bars, calendar columns), and CSP has no nonce mechanism for style *attributes*, only `<style>` elements.
|
||||||
|
|
||||||
|
**Rate limiting**: sign-in (`checkRateLimit("signin:<email>", {maxAttempts: 30, windowMs: 15min})` — 30 rather than a tighter default because the seeded owner account signs in roughly 20 times across the full E2E suite within one server process; a real-world tighter limit would need a per-deployment tune, not a hardcoded constant, but 30/15min is a reasonable default), registration (5/hour by IP), and guest booking (8/hour by IP) are all rate-limited via the same `checkRateLimit` helper, each with its own key namespace and window.
|
||||||
|
|
||||||
|
**Timing safety**: `authorize()` now always calls `bcrypt.compare()` against either the real user's hash or a module-level dummy hash (`bcrypt.hashSync("nextchair-timing-safety-placeholder", 12)`) — a nonexistent email no longer short-circuits before the (comparatively slow) bcrypt call, closing a timing side-channel that could otherwise be used to enumerate registered emails. The cron endpoint's shared-secret check uses `timingSafeEqual` (`src/app/api/cron/process-notifications/route.ts`) rather than `!==`, for the same reason — a plain string comparison leaks the secret's length and correct prefix via response timing.
|
||||||
|
|
||||||
|
## 9. Accessibility, responsive and UX polish
|
||||||
|
|
||||||
|
Scoped by a dedicated Explore-agent audit against the live codebase rather than a generic sweep, with explicit, documented triage of what got fixed this phase versus deprioritized given the remaining time budget (e.g., a full pass over the ~25 remaining decorative-icon `text-accent`-contrast instances was reviewed and explicitly deprioritized as low-impact — decorative icons, not interactive controls or body text). One regression was caught and reverted during this work: four `loading.tsx` route-boundary files (root + three role groups) were added, then live E2E testing surfaced multiple Playwright strict-mode "resolved to N elements" failures across several previously-passing tests; root-caused via `document.querySelectorAll` showing two Radix Tabs instances mounting with different id-generation schemes under the new Suspense boundary, and fixed by removing the four files rather than chasing the underlying Next.js streaming interaction further — a `loading.tsx` boundary is a nice-to-have, not something worth a deeper, riskier fix under phase-closing time pressure.
|
||||||
|
|
||||||
|
## 10. Performance and index review
|
||||||
|
|
||||||
|
Also driven by a dedicated audit rather than a speculative pass. The one genuine finding requiring a schema change: `Break` had no index on `staffId`, meaning every staff member's break lookup (used on every availability calculation, not just the breaks-editor page) did a full table scan once the table grew past MySQL's default small-table optimization — fixed by `@@index([staffId])` (migration `20260803192355_break_staff_id_index`). A handful of `select`-vs-`include` over-fetches were trimmed in read paths that don't need full related rows. Most of the audit's findings were "already correct, no change needed" and are recorded as such rather than turned into busywork: `getPrimaryBusiness()`'s broader-than-strictly-needed fetch is already deduped per-request via React's `cache()`, so restructuring it for a marginal payload-size win wasn't worth the risk this late in the phase; no N+1 query patterns were found in the booking, calendar, or reporting hot paths — the reporting service in particular was written from the start as one targeted aggregation per report, not a fetch-then-loop.
|
||||||
|
|
||||||
|
## 11. Notable bugs found and fixed this phase
|
||||||
|
|
||||||
|
- **Loyalty card reward-slot styling**: `isRewardSlot = i === stampsRequired - 1` always styled the final stamp slot as "earned" regardless of whether the customer actually had that many stamps yet — fixed to `earned && i === stampsRequired - 1`.
|
||||||
|
- **`push-subscribe-toggle.tsx` hydration mismatch**: its `useState` lazy initializer read `window.Notification.permission` directly, which doesn't exist during SSR — confirmed via the Next dev-overlay error badge and a diff of server-rendered vs. client-rendered HTML. Fixed by seeding state as a fixed `"checking"` value and moving all browser-API detection into `useEffect`.
|
||||||
|
- **`react-hooks/set-state-in-effect` lint violations**: hit in both `install-app-button.tsx` (fixed via a lazy `useState` initializer, since that check is browser-API-free and SSR-safe) and `push-subscribe-toggle.tsx` (fixed via an async `Promise.resolve().then(async () => {...})` chain inside the effect, since that check legitimately needs to await `navigator.serviceWorker.getRegistration()`).
|
||||||
|
- **CSP `strict-dynamic` breaking hydration app-wide** — see §8.
|
||||||
|
- **`loading.tsx` duplicate-DOM regression** — see §9.
|
||||||
|
|
||||||
|
## 12. CI pipeline
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yml`: one job, a `mysql:8.0` service container (matching the local dev database exactly, not a lighter substitute), dummy secrets in the env block (never real credentials), and a linear step sequence — checkout, `setup-node@22`, `npm ci`, `prisma generate`, lint, typecheck, unit tests, `migrate deploy`, seed, integration tests, `next build`, Playwright browser install, the full E2E suite, and a failure-only `test-results/` artifact upload for debugging a red run. Gitea Actions rather than GitHub Actions because this repository's actual origin, confirmed via `git remote -v`, is self-hosted Gitea — the workflow YAML format itself is GitHub-Actions-compatible, so nothing about the pipeline's shape had to change, only its directory (`.gitea/workflows/` vs `.github/workflows/`).
|
||||||
|
|
||||||
|
## 13. Tests
|
||||||
|
|
||||||
|
**Unit** (Vitest, no DB): **216 tests** across 20 files, up from the pre-Phase-7 baseline — new coverage includes `rate-limit.test.ts` (4 tests: allows within the limit, blocks over it, resets after the window, tracks independent keys separately) and the loyalty/reporting validators exercised alongside the domain work.
|
||||||
|
|
||||||
|
**Integration** (Vitest, real MySQL): **147 tests** across 18 files — new coverage includes `reports.service.integration.test.ts` (8 tests, one per report function's core aggregation behavior plus zero-data edge cases) and extensions to `loyalty-owner.integration.test.ts` (a `redeemReward`-refuses-an-expired-reward test, plus a new `expireOverdueRewards` suite: sweeps past-due rewards to `EXPIRED`, notifies rewards expiring within the 7-day window, doesn't touch rewards outside that window, and is safe to call with nothing due).
|
||||||
|
|
||||||
|
**End-to-end** (Playwright, 3 spec files, 31 journeys total, run serially): two new journeys this phase — customer-journeys.spec.ts's 8th ("customer sees their available loyalty reward on Home and the Loyalty page," reading the seeded Sam Osei fixture) and owner-journeys.spec.ts's 13th ("owner reports show real, data-backed figures across all six sections, and the date-range presets work"). Both new journeys pass reliably in isolation and as part of the full serial run. Two long-standing, already-tracked issues remain and are **not** regressions from this phase's work: a Turbopack dev-mode cold-compile race that can occasionally drop the very first interaction of a full-suite run before the visited route finishes compiling (tracked separately; the dominant instance of this class was already fixed via a `waitForLoadState("networkidle")` in the shared `signIn()` helper), and a pre-existing time-of-day-sensitive seed-data flake in "barber changes their working hours" (unrelated to any Phase 7 change, reproduces in isolation on its own, tracked separately).
|
||||||
|
|
||||||
|
Running them:
|
||||||
|
```bash
|
||||||
|
npm run test # unit, no DB — 216 tests
|
||||||
|
npm run test:integration # needs DATABASE_URL → a real MySQL — 147 tests
|
||||||
|
npm run test:e2e # needs DATABASE_URL → a real (throwaway) MySQL — 31 journeys
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Manual verification
|
||||||
|
|
||||||
|
- Walked the customer Loyalty page, owner Reports page (all six sections, all three date presets), the install-app button and update-available banner, and the offline fallback page (via DevTools' "Offline" network throttle) in a real browser against seeded data.
|
||||||
|
- Verified the CSP fix by fetching raw production-build HTML before and after the revert described in §8 — the nonce approach's hydration failure and the fallback's correct hydration were both confirmed directly in served markup, not assumed.
|
||||||
|
- Verified rate limiting by exceeding the sign-in attempt threshold against the real dev server and confirming the block, then confirming it clears after the window.
|
||||||
|
- `npx tsc --noEmit`, `npx eslint .`, and `npx next build` all clean on the final state.
|
||||||
|
- All three automated suites — 216 unit, 147 integration, 31 E2E (29 passing, 2 pre-existing tracked flakes both confirmed to pass in isolation) — run together against one freshly reset (`prisma migrate reset --force`) and explicitly reseeded (`npm run prisma:seed`) database in a single authoritative pass.
|
||||||
|
|
||||||
|
## 15. Assumptions made
|
||||||
|
|
||||||
|
- **A single-process, single-instance deployment**, same assumption every prior phase has made — the in-memory rate limiter is only correct under this assumption; a horizontally-scaled deployment would need a shared store (Redis or similar) instead, and this is called out explicitly in the rate-limit module's own header rather than left implicit.
|
||||||
|
- **The reward-expiry sweep and notification-delivery processing share one scheduled trigger** (`/api/cron/process-notifications`) rather than each getting a dedicated endpoint — both are small, cheap, idempotent-per-call jobs, and standing up a second cron wiring point for one `updateMany` + a handful of notifications wasn't judged worth the operational complexity.
|
||||||
|
- **PWA offline support is shell-only, not data-offline** — see §7's own reasoning. A future phase choosing to cache read-only data (e.g. a customer's own upcoming appointments) for true offline viewing would need a real invalidation strategy, which this phase deliberately didn't attempt.
|
||||||
|
- **CSP's `'unsafe-inline'` for `script-src`/`style-src` is treated as the correct, permanent choice for this app's architecture**, not a stopgap — see §8 for the concrete, tested reason `'strict-dynamic'` doesn't work here.
|
||||||
|
|
||||||
|
## 16. Known limitations
|
||||||
|
|
||||||
|
- **The Turbopack dev-mode cold-compile race has one residual instance**: the very first test of a full E2E run can still occasionally drop an interaction on a route that hasn't finished its first compile, on tests that don't route through the shared `signIn()` helper (which already carries the fix). Not fixed this phase — the two documented mitigations (warm up known routes in `global-setup.ts`, or run E2E against a production build instead of dev) were both judged out of scope for a phase already at its verification-and-documentation stage; flagged here rather than silently left for a future summary to rediscover.
|
||||||
|
- **The pre-existing "barber changes their working hours" E2E flake is still unresolved** — root-caused in an earlier phase as time-of-day-sensitive seed data, unrelated to any Phase 7 change, and reproduces even when run in complete isolation.
|
||||||
|
- **Rate limiting is in-memory and per-process** — restated from §15 because it's a real constraint on deployment topology, not just a design note: a rolling/blue-green deploy or any multi-instance scaling resets or fragments the counters.
|
||||||
|
- **The reward-expiry sweep has no dedicated UI to review what was auto-expired** — it's a silent background correction (with a notification to the affected customer, but nothing surfaced to the owner) rather than a reviewable audit trail beyond the existing generic `AuditLog`-free execution.
|
||||||
|
- **No real analytics/monitoring integration** — the CSP intentionally has no `report-uri`/`report-to` directive, and there is no error-tracking service wired in; CSP violations and unhandled errors are currently only visible in server logs.
|
||||||
|
- **Push notification delivery, and the reports themselves, have not been load-tested** — both are read/aggregate-light by construction, but no phase in this project has done anything resembling a load or stress test.
|
||||||
|
|
||||||
|
## 17. Review items for a future phase
|
||||||
|
|
||||||
|
- Decide whether the residual Turbopack cold-compile E2E flake (§16) is worth fixing directly, or whether switching CI's E2E run to a production build (as CI's own pipeline already does via `next build` before the E2E step — only local ad-hoc `npx playwright test` runs against `next dev`) makes the dev-mode race a non-issue in the environment that actually gates merges.
|
||||||
|
- If the app ever moves to a multi-instance deployment, the in-memory rate limiter (§8, §15) needs to become a shared-store implementation (Redis or equivalent) before that migration, not after.
|
||||||
|
- CSP has no violation reporting endpoint; adding one (`report-to` + a small ingestion route, or a third-party service) would turn "the policy is correct" from a one-time verified claim into an ongoingly-monitored one.
|
||||||
|
- The reward-expiry sweep (§6 of this doc, and §13's test coverage) could grow an owner-facing "recently expired rewards" view if that visibility turns out to matter in practice — deliberately not built speculatively this phase.
|
||||||
|
- This is the last phase in the original brief sequence; no further phase-numbered work is currently planned. Any future work should be scoped against the "Known limitations" sections accumulated across all seven phase summaries rather than starting a fresh audit from zero.
|
||||||
@@ -30,10 +30,23 @@ test.describe("customer booking journeys", () => {
|
|||||||
guest,
|
guest,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Guest confirmation offers an account-creation prompt but doesn't force it.
|
// Guest confirmation offers an account-creation prompt but doesn't force it —
|
||||||
|
// and also a real, secured "View Appointment" link (Phase 7: a guest's only
|
||||||
|
// credential is a single-purpose access token in the URL, not the bare
|
||||||
|
// appointment id — see src/services/scheduling/guest-access.service.ts).
|
||||||
await expect(page.getByText("Want to manage this booking later?")).toBeVisible();
|
await expect(page.getByText("Want to manage this booking later?")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Create an Account" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Create an Account" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "View Appointment" })).toHaveCount(0);
|
const viewLink = page.getByRole("link", { name: "View Appointment" });
|
||||||
|
await expect(viewLink).toBeVisible();
|
||||||
|
const href = await viewLink.getAttribute("href");
|
||||||
|
expect(href).toMatch(/^\/appointments\/[^/]+\?token=.+/);
|
||||||
|
|
||||||
|
// The token in the link actually works, and a guessed/blank token doesn't.
|
||||||
|
await page.goto(href!);
|
||||||
|
await expect(page.getByRole("heading", { name: "Appointment Details" })).toBeVisible();
|
||||||
|
const appointmentId = href!.split("?")[0]!.split("/").pop();
|
||||||
|
await page.goto(`/appointments/${appointmentId}`);
|
||||||
|
await expect(page.getByRole("heading", { name: "This page could not be found." })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("2. registered customer books an appointment", async ({ page }) => {
|
test("2. registered customer books an appointment", async ({ page }) => {
|
||||||
@@ -151,4 +164,48 @@ test.describe("customer booking journeys", () => {
|
|||||||
await page.getByRole("tab", { name: "Past" }).click();
|
await page.getByRole("tab", { name: "Past" }).click();
|
||||||
await expect(completedCard).toBeVisible();
|
await expect(completedCard).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => {
|
||||||
|
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
|
||||||
|
|
||||||
|
await page.goto("/book");
|
||||||
|
await completeBookingFlow(page, {
|
||||||
|
serviceName: SEEDED.services.beardTrim,
|
||||||
|
staffName: SEEDED.barberSkinfadeBeard.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bell in the persistent customer layout shows an unread badge — see (customer)/layout.tsx.
|
||||||
|
await page.goto("/home");
|
||||||
|
const bell = page.getByRole("link", { name: /Notifications, \d+ unread/ });
|
||||||
|
await expect(bell).toBeVisible();
|
||||||
|
|
||||||
|
await bell.click();
|
||||||
|
await expect(page).toHaveURL(/\/notifications/);
|
||||||
|
await expect(page.getByText("Booking confirmed").first()).toBeVisible();
|
||||||
|
await expect(page.getByRole("status", { name: "Unread" }).first()).toBeVisible();
|
||||||
|
|
||||||
|
// "Mark All Read" clears every unread notification — the nav badge disappears entirely
|
||||||
|
// afterward, regardless of how many notifications this account has accumulated.
|
||||||
|
await page.getByRole("button", { name: "Mark All Read" }).click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.getByRole("status", { name: "Unread" })).toHaveCount(0);
|
||||||
|
await page.goto("/home");
|
||||||
|
await expect(page.getByRole("link", { name: "Notifications" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: /Notifications, \d+ unread/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("8. customer sees their available loyalty reward on Home and the Loyalty page", async ({ page }) => {
|
||||||
|
// Seeded at exactly 10/10 stamps with one AVAILABLE reward (see prisma/seed.ts) —
|
||||||
|
// must run before owner-journeys.spec.ts's journey 10, which redeems it.
|
||||||
|
await signIn(page, SEEDED.secondCustomer.email, SEEDED.secondCustomer.password);
|
||||||
|
|
||||||
|
await page.goto("/home");
|
||||||
|
await expect(page.getByText(/Reward available/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByText(/Reward available/).click();
|
||||||
|
await expect(page).toHaveURL(/\/loyalty/);
|
||||||
|
await expect(page.getByRole("heading", { name: "Loyalty", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Reward available:")).toBeVisible();
|
||||||
|
await expect(page.getByText("stamp added").first()).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,12 @@ import type { Page } from "@playwright/test";
|
|||||||
/** Seeded in prisma/seed.ts — see that file for the full fixture set this suite relies on. */
|
/** Seeded in prisma/seed.ts — see that file for the full fixture set this suite relies on. */
|
||||||
export const SEEDED = {
|
export const SEEDED = {
|
||||||
registeredCustomer: { email: "alex.chen@example.com", password: "password123", name: "Alex Chen" },
|
registeredCustomer: { email: "alex.chen@example.com", password: "password123", name: "Alex Chen" },
|
||||||
|
// Seeded right at their loyalty reward threshold (10/10 stamps, one
|
||||||
|
// AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's
|
||||||
|
// journey 10 redeems this exact reward, so any test relying on it still
|
||||||
|
// being AVAILABLE must run before that (customer-journeys.spec.ts runs
|
||||||
|
// first in the suite, so this is safe there).
|
||||||
|
secondCustomer: { email: "sam.osei@example.com", password: "password123", name: "Sam Osei" },
|
||||||
owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
|
owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
|
||||||
barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" },
|
barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" },
|
||||||
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
|
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
|
||||||
@@ -16,6 +22,12 @@ export const SEEDED = {
|
|||||||
|
|
||||||
export async function signIn(page: Page, email: string, password: string): Promise<void> {
|
export async function signIn(page: Page, email: string, password: string): Promise<void> {
|
||||||
await page.goto("/sign-in");
|
await page.goto("/sign-in");
|
||||||
|
// Turbopack dev-mode compiles each route group on first visit — on a
|
||||||
|
// cold server, a click that lands before that finishes (and before
|
||||||
|
// React attaches the form's submit handler) is silently dropped. Wait
|
||||||
|
// for the page to go idle once before interacting, so this doesn't
|
||||||
|
// race the very first visit to an as-yet-uncompiled route.
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
await page.getByLabel("Email Address").fill(email);
|
await page.getByLabel("Email Address").fill(email);
|
||||||
await page.getByLabel("Password", { exact: true }).fill(password);
|
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||||
await page.getByRole("button", { name: "Sign In", exact: true }).click();
|
await page.getByRole("button", { name: "Sign In", exact: true }).click();
|
||||||
|
|||||||
+38
-17
@@ -1,11 +1,12 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { Client } from "pg";
|
import { createPool } from "mariadb";
|
||||||
|
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
|
||||||
import { SEEDED, completeBookingFlow, signIn, uniqueEmail } from "./helpers";
|
import { SEEDED, completeBookingFlow, signIn, uniqueEmail } from "./helpers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 12 owner-administration journeys required by the Phase 6 brief. Same
|
* The 12 owner-administration journeys required by the Phase 6 brief. Same
|
||||||
* principles as staff-journeys.spec.ts: real forms against the real dev
|
* principles as staff-journeys.spec.ts: real forms against the real dev
|
||||||
* server and seeded Postgres, `test.describe.serial` because several
|
* server and seeded MySQL (migrated from Postgres in Phase 7), `test.describe.serial` because several
|
||||||
* journeys deliberately chain or share seeded fixtures (Marcus Reed is
|
* journeys deliberately chain or share seeded fixtures (Marcus Reed is
|
||||||
* both the seeded owner *and* a working barber — see prisma/seed.ts), and
|
* both the seeded owner *and* a working barber — see prisma/seed.ts), and
|
||||||
* every mutation a test makes to shared configuration (business details,
|
* every mutation a test makes to shared configuration (business details,
|
||||||
@@ -19,16 +20,15 @@ import { SEEDED, completeBookingFlow, signIn, uniqueEmail } from "./helpers";
|
|||||||
|
|
||||||
test.describe.serial("owner administration journeys", () => {
|
test.describe.serial("owner administration journeys", () => {
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
const client = new Client({ connectionString: process.env.DATABASE_URL });
|
const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL));
|
||||||
await client.connect();
|
|
||||||
// Journey 7 creates a category/service with no deterministic id to
|
// Journey 7 creates a category/service with no deterministic id to
|
||||||
// upsert against on a re-run — clean up by name instead.
|
// upsert against on a re-run — clean up by name instead.
|
||||||
await client.query(`DELETE FROM "Service" WHERE name LIKE 'E2E Test Service%'`);
|
await pool.query("DELETE FROM `Service` WHERE name LIKE 'E2E Test Service%'");
|
||||||
await client.query(`DELETE FROM "ServiceCategory" WHERE name LIKE 'E2E Test Category%'`);
|
await pool.query("DELETE FROM `ServiceCategory` WHERE name LIKE 'E2E Test Category%'");
|
||||||
// Journey 5 adds a real staff member (User+StaffProfile+StaffInvitation) with no
|
// Journey 5 adds a real staff member (User+StaffProfile+StaffInvitation) with no
|
||||||
// deterministic id either — deleting the User cascades to the rest.
|
// deterministic id either — deleting the User cascades to the rest.
|
||||||
await client.query(`DELETE FROM "User" WHERE email LIKE 'e2e-new-barber-%@e2e-test.local'`);
|
await pool.query("DELETE FROM `User` WHERE email LIKE 'e2e-new-barber-%@e2e-test.local'");
|
||||||
await client.end();
|
await pool.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => {
|
test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => {
|
||||||
@@ -99,16 +99,15 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
// are upserted by a deterministic id, so on a database that already had
|
// are upserted by a deterministic id, so on a database that already had
|
||||||
// them from an earlier seed run their date is whatever it was back
|
// them from an earlier seed run their date is whatever it was back
|
||||||
// then, not necessarily still "N days from today" — read the real date
|
// then, not necessarily still "N days from today" — read the real date
|
||||||
// straight from Postgres instead of predicting it from wall-clock time.
|
// straight from the database instead of predicting it from wall-clock time.
|
||||||
const client = new Client({ connectionString: process.env.DATABASE_URL });
|
const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL));
|
||||||
await client.connect();
|
const rows = await pool.query<{ id: string; start_at: Date; first_name: string; last_name: string }[]>(
|
||||||
const { rows } = await client.query<{ id: string; start_at: Date; first_name: string; last_name: string }>(
|
"SELECT a.id, a.startAt as start_at, c.firstName as first_name, c.lastName as last_name" +
|
||||||
`SELECT a.id, a."startAt" as start_at, c."firstName" as first_name, c."lastName" as last_name
|
" FROM Appointment a JOIN CustomerProfile c ON c.id = a.customerId" +
|
||||||
FROM "Appointment" a JOIN "CustomerProfile" c ON c.id = a."customerId"
|
" WHERE a.id LIKE '%-appt-upcoming-confirmed' OR a.id LIKE '%-appt-leo-future'" +
|
||||||
WHERE a.id LIKE '%-appt-upcoming-confirmed' OR a.id LIKE '%-appt-leo-future'
|
" ORDER BY a.startAt ASC",
|
||||||
ORDER BY a."startAt" ASC`,
|
|
||||||
);
|
);
|
||||||
await client.end();
|
await pool.end();
|
||||||
if (rows.length === 0) throw new Error("Seeded opening-hours conflict fixtures not found — check prisma/seed.ts");
|
if (rows.length === 0) throw new Error("Seeded opening-hours conflict fixtures not found — check prisma/seed.ts");
|
||||||
|
|
||||||
// Prefer a fixture that doesn't fall on Sunday (already closed by default) so toggling it
|
// Prefer a fixture that doesn't fall on Sunday (already closed by default) so toggling it
|
||||||
@@ -328,4 +327,26 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Current", exact: true })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Current", exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("13. owner reports show real, data-backed appointment/service/staff/customer/loyalty/notification figures", async ({ page }) => {
|
||||||
|
await signIn(page, SEEDED.owner.email, "password123");
|
||||||
|
await page.goto("/owner");
|
||||||
|
await page.getByRole("link", { name: "View Reports" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/owner\/reports/);
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Reports", exact: true })).toBeVisible();
|
||||||
|
// The section labels are <h2>s — role-scoped so the owner sidebar's
|
||||||
|
// own "Services" link (same text) doesn't collide (strict-mode violation).
|
||||||
|
for (const heading of ["Appointments", "Services", "Staff", "Customers", "Loyalty", "Notifications"]) {
|
||||||
|
await expect(page.getByRole("heading", { name: heading, exact: true, level: 2 })).toBeVisible();
|
||||||
|
}
|
||||||
|
// Seeded fixtures guarantee at least one completed Haircut & Style —
|
||||||
|
// real aggregated data, not an empty-state placeholder.
|
||||||
|
await expect(page.getByText("Haircut & Style")).toBeVisible();
|
||||||
|
|
||||||
|
// The 7/30/90-day presets are real links, not decoration.
|
||||||
|
await page.getByRole("link", { name: "Last 7 days" }).click();
|
||||||
|
await expect(page).toHaveURL(/from=.*to=/);
|
||||||
|
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { Client } from "pg";
|
import { createPool } from "mariadb";
|
||||||
|
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
|
||||||
import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers";
|
import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 9 barber-operations journeys required by the Phase 5 brief. Each
|
* The 9 barber-operations journeys required by the Phase 5 brief. Each
|
||||||
* drives real forms against the real dev server and the seeded Postgres
|
* drives real forms against the real dev server and the seeded MySQL
|
||||||
* database (see e2e/global-setup.ts / prisma/seed.ts). Journeys that only
|
* database (migrated from Postgres in Phase 7 — see e2e/global-setup.ts /
|
||||||
|
* prisma/seed.ts). Journeys that only
|
||||||
* read data reuse the seed's fixed fixtures (today's appointments across
|
* read data reuse the seed's fixed fixtures (today's appointments across
|
||||||
* every status, Sam Osei sitting at the loyalty threshold, Leo's time off);
|
* every status, Sam Osei sitting at the loyalty threshold, Leo's time off);
|
||||||
* journeys that mutate state book a fresh appointment first so they don't
|
* journeys that mutate state book a fresh appointment first so they don't
|
||||||
@@ -29,11 +31,10 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
// safety net removes them directly regardless of how the tests above
|
// safety net removes them directly regardless of how the tests above
|
||||||
// exited, on top of (not instead of) each test's own UI-driven cleanup.
|
// exited, on top of (not instead of) each test's own UI-driven cleanup.
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
const client = new Client({ connectionString: process.env.DATABASE_URL });
|
const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL));
|
||||||
await client.connect();
|
await pool.query("DELETE FROM `TimeOff` WHERE reason = 'E2E conflict test'");
|
||||||
await client.query(`DELETE FROM "TimeOff" WHERE reason = 'E2E conflict test'`);
|
await pool.query("DELETE FROM `Break` WHERE label = 'Afternoon tea'");
|
||||||
await client.query(`DELETE FROM "Break" WHERE label = 'Afternoon tea'`);
|
await pool.end();
|
||||||
await client.end();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("1. barber signs in and sees today's schedule", async ({ page }) => {
|
test("1. barber signs in and sees today's schedule", async ({ page }) => {
|
||||||
@@ -227,4 +228,20 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
await editSheet.getByRole("button", { name: "Remove Time Off", exact: true }).click();
|
await editSheet.getByRole("button", { name: "Remove Time Off", exact: true }).click();
|
||||||
await expect(page.getByText("E2E conflict test")).toHaveCount(0);
|
await expect(page.getByText("E2E conflict test")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("10. barber receives a new-booking notification", async ({ page }) => {
|
||||||
|
// Journey 3 (this file runs serially) already booked Marcus a fresh appointment for Jordan
|
||||||
|
// Blake — see NotificationService/booking-notifications.ts, notifyBookingCreated, which
|
||||||
|
// queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking.
|
||||||
|
await signIn(page, SEEDED.owner.email, "password123");
|
||||||
|
await page.goto("/staff");
|
||||||
|
|
||||||
|
const bell = page.getByRole("link", { name: /Notifications, \d+ unread/ });
|
||||||
|
await expect(bell).toBeVisible();
|
||||||
|
await bell.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/staff\/notifications/);
|
||||||
|
await expect(page.getByText("New appointment").first()).toBeVisible();
|
||||||
|
await expect(page.getByText(/Jordan Blake/).first()).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,28 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
// Static, request-independent security headers — applied to every
|
||||||
|
// response via next.config's own headers() so they can't be missed on a
|
||||||
|
// route that forgets to set them itself. Content-Security-Policy is NOT
|
||||||
|
// set here: it needs a per-request nonce (for Next's own inline
|
||||||
|
// hydration scripts), which only a request-scoped place like proxy.ts
|
||||||
|
// can generate — see that file.
|
||||||
|
const SECURITY_HEADERS = [
|
||||||
|
// This app is never meant to be framed by another origin.
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
// Stops the browser from MIME-sniffing a response into something more
|
||||||
|
// dangerous than its declared Content-Type.
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
// Full referrer to same-origin navigations (useful for our own
|
||||||
|
// analytics-free logs), origin-only to other origins, nothing on downgrade.
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
// No page here needs the camera, microphone, geolocation, etc.
|
||||||
|
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()" },
|
||||||
|
// Only meaningful over HTTPS (which every real deployment of this app
|
||||||
|
// uses) — harmless to send in local HTTP dev too, since browsers only
|
||||||
|
// honor it on secure origins.
|
||||||
|
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
|
||||||
|
];
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
images: {
|
images: {
|
||||||
@@ -9,8 +32,14 @@ const nextConfig: NextConfig = {
|
|||||||
hostname: "storage.googleapis.com",
|
hostname: "storage.googleapis.com",
|
||||||
pathname: "/uxpilot-auth.appspot.com/**",
|
pathname: "/uxpilot-auth.appspot.com/**",
|
||||||
},
|
},
|
||||||
|
// Google OAuth profile photos (session.user.image when signed in via
|
||||||
|
// "Continue with Google" — see src/auth.ts's Google provider).
|
||||||
|
{ protocol: "https", hostname: "lh3.googleusercontent.com" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
async headers() {
|
||||||
|
return [{ source: "/(.*)", headers: SECURITY_HEADERS }];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
Generated
+245
-178
@@ -11,7 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.3",
|
"@auth/prisma-adapter": "^2.11.3",
|
||||||
"@hookform/resolvers": "^5.7.1",
|
"@hookform/resolvers": "^5.7.1",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-mariadb": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
"@radix-ui/react-slot": "^1.3.3",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.3.7",
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
@@ -21,14 +21,16 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
|
"mariadb": "^3.5.3",
|
||||||
"next": "^16.2.12",
|
"next": "^16.2.12",
|
||||||
"next-auth": "^5.0.0-beta.32",
|
"next-auth": "^5.0.0-beta.32",
|
||||||
"pg": "^8.22.0",
|
"nodemailer": "^8.0.11",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.84.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37,9 +39,10 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/luxon": "^3.7.3",
|
"@types/luxon": "^3.7.3",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/pg": "^8.20.3",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-next": "^16.2.12",
|
"eslint-config-next": "^16.2.12",
|
||||||
@@ -2054,18 +2057,65 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/adapter-pg": {
|
"node_modules/@prisma/adapter-mariadb": {
|
||||||
"version": "7.9.1",
|
"version": "7.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/adapter-mariadb/-/adapter-mariadb-7.9.1.tgz",
|
||||||
"integrity": "sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==",
|
"integrity": "sha512-4aZUbIg2av7XFSQyM9cQeAp9YP9HiPr8H7ehe2rDo375IZXILEzQrNXZ/zLetgRQ2ojlaUl/brArFdOVS3n1xA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/driver-adapter-utils": "7.9.1",
|
"@prisma/driver-adapter-utils": "7.9.1",
|
||||||
"@types/pg": "^8.16.0",
|
"mariadb": "3.4.5"
|
||||||
"pg": "^8.16.3",
|
|
||||||
"postgres-array": "3.0.4"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@prisma/adapter-mariadb/node_modules/@types/node": {
|
||||||
|
"version": "24.13.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/adapter-mariadb/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/adapter-mariadb/node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/adapter-mariadb/node_modules/mariadb": {
|
||||||
|
"version": "3.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.4.5.tgz",
|
||||||
|
"integrity": "sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==",
|
||||||
|
"license": "LGPL-2.1-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"@types/node": "^24.0.13",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"lru-cache": "^10.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/adapter-mariadb/node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "7.9.1",
|
"version": "7.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.9.1.tgz",
|
||||||
@@ -3762,7 +3812,6 @@
|
|||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
@@ -3802,15 +3851,14 @@
|
|||||||
"undici-types": "~8.3.0"
|
"undici-types": "~8.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "8.20.3",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
"integrity": "sha512-4Tvg+HO6+oQaAkpT8GTYoSExzpGGZz532GXgbbCElWJQeQdMozBWxEKNBhJJpHFjWXsMxqPbyypvj/89FWNoSQ==",
|
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*"
|
||||||
"pg-protocol": "*",
|
|
||||||
"pg-types": "^2.2.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
@@ -3833,6 +3881,16 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.65.0",
|
"version": "8.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
|
||||||
@@ -4756,6 +4814,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
@@ -4966,6 +5033,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -5074,6 +5153,12 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||||
|
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
@@ -5132,6 +5217,12 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/c12": {
|
"node_modules/c12": {
|
||||||
"version": "3.3.4",
|
"version": "3.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
|
||||||
@@ -5584,7 +5675,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -5672,7 +5762,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
@@ -5736,6 +5825,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.20.0",
|
"version": "3.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
||||||
@@ -7134,11 +7232,32 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
@@ -7188,6 +7307,12 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -7782,6 +7907,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -8203,6 +8349,31 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mariadb": {
|
||||||
|
"version": "3.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.3.tgz",
|
||||||
|
"integrity": "sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==",
|
||||||
|
"license": "LGPL-2.1-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"@types/node": ">=20",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
|
"lru-cache": "^11.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mariadb/node_modules/lru-cache": {
|
||||||
|
"version": "11.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
|
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -8237,6 +8408,12 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -8254,7 +8431,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -8264,7 +8440,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
@@ -8489,6 +8664,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
|
||||||
|
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/oauth4webapi": {
|
"node_modules/oauth4webapi": {
|
||||||
"version": "3.8.6",
|
"version": "3.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
|
||||||
@@ -8765,104 +8949,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
|
||||||
"version": "8.22.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
|
||||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pg-connection-string": "^2.14.0",
|
|
||||||
"pg-pool": "^3.14.0",
|
|
||||||
"pg-protocol": "^1.15.0",
|
|
||||||
"pg-types": "2.2.0",
|
|
||||||
"pgpass": "1.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"pg-cloudflare": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"pg-native": ">=3.0.1"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"pg-native": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-cloudflare": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/pg-connection-string": {
|
|
||||||
"version": "2.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
|
||||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/pg-int8": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-pool": {
|
|
||||||
"version": "3.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
|
||||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"pg": ">=8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-protocol": {
|
|
||||||
"version": "1.15.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
|
||||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/pg-types": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pg-int8": "1.0.1",
|
|
||||||
"postgres-array": "~2.0.0",
|
|
||||||
"postgres-bytea": "~1.0.0",
|
|
||||||
"postgres-date": "~1.0.4",
|
|
||||||
"postgres-interval": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-types/node_modules/postgres-array": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pgpass": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"split2": "^4.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -8993,45 +9079,6 @@
|
|||||||
"url": "https://github.com/sponsors/porsager"
|
"url": "https://github.com/sponsors/porsager"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postgres-array": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-bytea": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-date": {
|
|
||||||
"version": "1.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-interval": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"xtend": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.24.3",
|
"version": "10.24.3",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
@@ -9562,6 +9609,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -9624,7 +9691,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
@@ -9880,15 +9946,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/split2": {
|
|
||||||
"version": "4.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
|
||||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sqlstring": {
|
"node_modules/sqlstring": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||||
@@ -11025,6 +11082,25 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -11157,15 +11233,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xtend": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
+6
-3
@@ -29,7 +29,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.3",
|
"@auth/prisma-adapter": "^2.11.3",
|
||||||
"@hookform/resolvers": "^5.7.1",
|
"@hookform/resolvers": "^5.7.1",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-mariadb": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
"@radix-ui/react-slot": "^1.3.3",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.3.7",
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
@@ -39,14 +39,16 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
|
"mariadb": "^3.5.3",
|
||||||
"next": "^16.2.12",
|
"next": "^16.2.12",
|
||||||
"next-auth": "^5.0.0-beta.32",
|
"next-auth": "^5.0.0-beta.32",
|
||||||
"pg": "^8.22.0",
|
"nodemailer": "^8.0.11",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.84.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -55,9 +57,10 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/luxon": "^3.7.3",
|
"@types/luxon": "^3.7.3",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/pg": "^8.20.3",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-next": "^16.2.12",
|
"eslint-config-next": "^16.2.12",
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Business` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`slug` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`logoUrl` VARCHAR(191) NULL,
|
||||||
|
`phone` VARCHAR(191) NULL,
|
||||||
|
`email` VARCHAR(191) NULL,
|
||||||
|
`website` VARCHAR(191) NULL,
|
||||||
|
`instagramUrl` VARCHAR(191) NULL,
|
||||||
|
`facebookUrl` VARCHAR(191) NULL,
|
||||||
|
`timezone` VARCHAR(191) NOT NULL DEFAULT 'Europe/London',
|
||||||
|
`currency` VARCHAR(191) NOT NULL DEFAULT 'GBP',
|
||||||
|
`locale` VARCHAR(191) NOT NULL DEFAULT 'en-GB',
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `Business_slug_key`(`slug`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `BusinessLocation` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL DEFAULT 'Main Location',
|
||||||
|
`addressLine1` VARCHAR(191) NOT NULL,
|
||||||
|
`addressLine2` VARCHAR(191) NULL,
|
||||||
|
`city` VARCHAR(191) NOT NULL,
|
||||||
|
`region` VARCHAR(191) NULL,
|
||||||
|
`postalCode` VARCHAR(191) NOT NULL,
|
||||||
|
`country` VARCHAR(191) NOT NULL DEFAULT 'GB',
|
||||||
|
`latitude` DECIMAL(9, 6) NULL,
|
||||||
|
`longitude` DECIMAL(9, 6) NULL,
|
||||||
|
`isPrimary` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `BusinessLocation_businessId_idx`(`businessId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `BusinessHours` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`dayOfWeek` ENUM('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY') NOT NULL,
|
||||||
|
`isClosed` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`opensAtMinutes` INTEGER NULL,
|
||||||
|
`closesAtMinutes` INTEGER NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `BusinessHours_businessId_dayOfWeek_key`(`businessId`, `dayOfWeek`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `BusinessSettings` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`cancellationPolicy` TEXT NULL,
|
||||||
|
`lateArrivalPolicy` TEXT NULL,
|
||||||
|
`bookingTerms` TEXT NULL,
|
||||||
|
`privacyPolicyUrl` VARCHAR(191) NULL,
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `BusinessSettings_businessId_key`(`businessId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `BookingRule` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`minNoticeMinutes` INTEGER NOT NULL DEFAULT 120,
|
||||||
|
`maxAdvanceDays` INTEGER NOT NULL DEFAULT 60,
|
||||||
|
`cancellationWindowHours` INTEGER NOT NULL DEFAULT 24,
|
||||||
|
`rescheduleWindowHours` INTEGER NOT NULL DEFAULT 24,
|
||||||
|
`slotIntervalMinutes` INTEGER NOT NULL DEFAULT 30,
|
||||||
|
`bufferBeforeMinutes` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`bufferAfterMinutes` INTEGER NOT NULL DEFAULT 5,
|
||||||
|
`maxAppointmentsPerDay` INTEGER NULL,
|
||||||
|
`maxAppointmentsPerStaffPerDay` INTEGER NULL,
|
||||||
|
`customerCanChooseStaff` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`allowGuestBooking` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`requireApproval` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`requireDeposit` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `BookingRule_businessId_key`(`businessId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `User` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NULL,
|
||||||
|
`email` VARCHAR(191) NULL,
|
||||||
|
`emailVerified` DATETIME(3) NULL,
|
||||||
|
`phone` VARCHAR(191) NULL,
|
||||||
|
`phoneVerified` DATETIME(3) NULL,
|
||||||
|
`image` VARCHAR(191) NULL,
|
||||||
|
`passwordHash` VARCHAR(191) NULL,
|
||||||
|
`isSuperAdmin` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `User_email_key`(`email`),
|
||||||
|
UNIQUE INDEX `User_phone_key`(`phone`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Account` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`type` VARCHAR(191) NOT NULL,
|
||||||
|
`provider` VARCHAR(191) NOT NULL,
|
||||||
|
`providerAccountId` VARCHAR(191) NOT NULL,
|
||||||
|
`refresh_token` TEXT NULL,
|
||||||
|
`access_token` TEXT NULL,
|
||||||
|
`expires_at` INTEGER NULL,
|
||||||
|
`token_type` VARCHAR(191) NULL,
|
||||||
|
`scope` VARCHAR(191) NULL,
|
||||||
|
`id_token` TEXT NULL,
|
||||||
|
`session_state` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `Account_provider_providerAccountId_key`(`provider`, `providerAccountId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Session` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`sessionToken` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`expires` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `Session_sessionToken_key`(`sessionToken`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `VerificationToken` (
|
||||||
|
`identifier` VARCHAR(191) NOT NULL,
|
||||||
|
`token` VARCHAR(191) NOT NULL,
|
||||||
|
`expires` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `VerificationToken_token_key`(`token`),
|
||||||
|
UNIQUE INDEX `VerificationToken_identifier_token_key`(`identifier`, `token`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `CustomerProfile` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NULL,
|
||||||
|
`firstName` VARCHAR(191) NOT NULL,
|
||||||
|
`lastName` VARCHAR(191) NOT NULL,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`phone` VARCHAR(191) NULL,
|
||||||
|
`isGuest` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`preferredBarberId` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `CustomerProfile_userId_key`(`userId`),
|
||||||
|
INDEX `CustomerProfile_email_idx`(`email`),
|
||||||
|
INDEX `CustomerProfile_phone_idx`(`phone`),
|
||||||
|
INDEX `CustomerProfile_preferredBarberId_idx`(`preferredBarberId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `StaffProfile` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`roleId` VARCHAR(191) NOT NULL,
|
||||||
|
`bio` TEXT NULL,
|
||||||
|
`status` ENUM('ACTIVE', 'ON_BREAK', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
`isBookableOnline` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `StaffProfile_userId_key`(`userId`),
|
||||||
|
INDEX `StaffProfile_businessId_idx`(`businessId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Role` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`isSystem` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `Role_businessId_name_key`(`businessId`, `name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `RolePermission` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`roleId` VARCHAR(191) NOT NULL,
|
||||||
|
`permission` ENUM('MANAGE_BUSINESS_PROFILE', 'MANAGE_BOOKING_RULES', 'MANAGE_SERVICES', 'MANAGE_STAFF', 'MANAGE_LOYALTY_PROGRAMME', 'MANAGE_NOTIFICATION_SETTINGS', 'VIEW_ALL_STAFF_APPOINTMENTS', 'MANAGE_ALL_APPOINTMENTS', 'MANAGE_OWN_APPOINTMENTS', 'CHECK_IN_CUSTOMERS', 'MANAGE_LOYALTY_STAMPS', 'MANAGE_OWN_AVAILABILITY', 'VIEW_CUSTOMER_DIRECTORY', 'BOOK_ON_BEHALF_OF_CUSTOMER', 'VIEW_REPORTING') NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `RolePermission_roleId_permission_key`(`roleId`, `permission`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `StaffInvitation` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`staffId` VARCHAR(191) NOT NULL,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`token` VARCHAR(191) NOT NULL,
|
||||||
|
`invitedByUserId` VARCHAR(191) NULL,
|
||||||
|
`expiresAt` DATETIME(3) NOT NULL,
|
||||||
|
`acceptedAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `StaffInvitation_staffId_key`(`staffId`),
|
||||||
|
UNIQUE INDEX `StaffInvitation_token_key`(`token`),
|
||||||
|
INDEX `StaffInvitation_businessId_idx`(`businessId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ServiceCategory` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`displayOrder` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `ServiceCategory_businessId_idx`(`businessId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Service` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`categoryId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`durationMinutes` INTEGER NOT NULL,
|
||||||
|
`price` DECIMAL(10, 2) NOT NULL,
|
||||||
|
`bufferBeforeMinutes` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`bufferAfterMinutes` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`allowOnlineBooking` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`displayOrder` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `Service_businessId_idx`(`businessId`),
|
||||||
|
INDEX `Service_categoryId_idx`(`categoryId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `StaffService` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`staffId` VARCHAR(191) NOT NULL,
|
||||||
|
`serviceId` VARCHAR(191) NOT NULL,
|
||||||
|
`priceOverride` DECIMAL(10, 2) NULL,
|
||||||
|
`durationOverrideMinutes` INTEGER NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `StaffService_staffId_serviceId_key`(`staffId`, `serviceId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `WorkingHours` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`staffId` VARCHAR(191) NOT NULL,
|
||||||
|
`dayOfWeek` ENUM('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY') NOT NULL,
|
||||||
|
`isOff` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`startMinutes` INTEGER NULL,
|
||||||
|
`endMinutes` INTEGER NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `WorkingHours_staffId_dayOfWeek_key`(`staffId`, `dayOfWeek`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Break` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`staffId` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`startMinutes` INTEGER NOT NULL,
|
||||||
|
`endMinutes` INTEGER NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `BreakDay` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`breakId` VARCHAR(191) NOT NULL,
|
||||||
|
`dayOfWeek` ENUM('MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY') NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `BreakDay_breakId_dayOfWeek_key`(`breakId`, `dayOfWeek`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `TimeOff` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`staffId` VARCHAR(191) NOT NULL,
|
||||||
|
`startDate` DATE NOT NULL,
|
||||||
|
`endDate` DATE NOT NULL,
|
||||||
|
`reason` VARCHAR(191) NULL,
|
||||||
|
`status` ENUM('PENDING', 'APPROVED', 'DECLINED') NOT NULL DEFAULT 'APPROVED',
|
||||||
|
`startMinutes` INTEGER NULL,
|
||||||
|
`endMinutes` INTEGER NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `TimeOff_staffId_startDate_endDate_idx`(`staffId`, `startDate`, `endDate`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Appointment` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`staffId` VARCHAR(191) NOT NULL,
|
||||||
|
`status` ENUM('PENDING', 'CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW') NOT NULL DEFAULT 'CONFIRMED',
|
||||||
|
`startAt` DATETIME(3) NOT NULL,
|
||||||
|
`endAt` DATETIME(3) NOT NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`discountAmount` DECIMAL(10, 2) NULL,
|
||||||
|
`cancellationReason` VARCHAR(191) NULL,
|
||||||
|
`cancelledAt` DATETIME(3) NULL,
|
||||||
|
`cancelledByUserId` VARCHAR(191) NULL,
|
||||||
|
`createdByUserId` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `Appointment_staffId_startAt_idx`(`staffId`, `startAt`),
|
||||||
|
INDEX `Appointment_businessId_startAt_idx`(`businessId`, `startAt`),
|
||||||
|
INDEX `Appointment_customerId_idx`(`customerId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AppointmentService` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`appointmentId` VARCHAR(191) NOT NULL,
|
||||||
|
`serviceId` VARCHAR(191) NOT NULL,
|
||||||
|
`priceAtBooking` DECIMAL(10, 2) NOT NULL,
|
||||||
|
`durationAtBookingMinutes` INTEGER NOT NULL,
|
||||||
|
|
||||||
|
INDEX `AppointmentService_appointmentId_idx`(`appointmentId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `LoyaltyProgramme` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`isEnabled` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`stampsRequired` INTEGER NOT NULL DEFAULT 10,
|
||||||
|
`rewardType` ENUM('PERCENTAGE', 'FIXED_AMOUNT', 'FREE_SERVICE') NOT NULL DEFAULT 'PERCENTAGE',
|
||||||
|
`rewardValue` DECIMAL(10, 2) NULL,
|
||||||
|
`rewardLabel` VARCHAR(191) NULL,
|
||||||
|
`maxRewardValue` DECIMAL(10, 2) NULL,
|
||||||
|
`freeServiceId` VARCHAR(191) NULL,
|
||||||
|
`autoApplyReward` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`requiresStaffApproval` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`stampsExpireAfterDays` INTEGER NULL,
|
||||||
|
`rewardExpiresAfterDays` INTEGER NULL,
|
||||||
|
`earnOnCancelled` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`earnOnNoShow` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`cardBackgroundColor` VARCHAR(191) NULL,
|
||||||
|
`cardBackgroundImageUrl` VARCHAR(191) NULL,
|
||||||
|
`stampIcon` VARCHAR(191) NULL,
|
||||||
|
`termsLine` VARCHAR(191) NULL,
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `LoyaltyProgramme_businessId_key`(`businessId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `LoyaltyEligibleService` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`loyaltyProgrammeId` VARCHAR(191) NOT NULL,
|
||||||
|
`serviceId` VARCHAR(191) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `LoyaltyEligibleService_loyaltyProgrammeId_serviceId_key`(`loyaltyProgrammeId`, `serviceId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `LoyaltyEligibleStaff` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`loyaltyProgrammeId` VARCHAR(191) NOT NULL,
|
||||||
|
`staffId` VARCHAR(191) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `LoyaltyEligibleStaff_loyaltyProgrammeId_staffId_key`(`loyaltyProgrammeId`, `staffId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `LoyaltyAccount` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`loyaltyProgrammeId` VARCHAR(191) NOT NULL,
|
||||||
|
`currentStamps` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `LoyaltyAccount_customerId_loyaltyProgrammeId_key`(`customerId`, `loyaltyProgrammeId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `LoyaltyTransaction` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`loyaltyAccountId` VARCHAR(191) NOT NULL,
|
||||||
|
`type` ENUM('STAMP_ADDED', 'STAMP_REMOVED', 'STAMP_EXPIRED', 'REWARD_REDEEMED') NOT NULL,
|
||||||
|
`amount` INTEGER NOT NULL,
|
||||||
|
`reason` VARCHAR(191) NULL,
|
||||||
|
`appointmentId` VARCHAR(191) NULL,
|
||||||
|
`authoredByStaffId` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `LoyaltyTransaction_loyaltyAccountId_idx`(`loyaltyAccountId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `LoyaltyReward` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`loyaltyAccountId` VARCHAR(191) NOT NULL,
|
||||||
|
`rewardType` ENUM('PERCENTAGE', 'FIXED_AMOUNT', 'FREE_SERVICE') NOT NULL,
|
||||||
|
`value` DECIMAL(10, 2) NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`status` ENUM('AVAILABLE', 'REDEEMED', 'EXPIRED') NOT NULL DEFAULT 'AVAILABLE',
|
||||||
|
`appliedToAppointmentId` VARCHAR(191) NULL,
|
||||||
|
`redeemedAt` DATETIME(3) NULL,
|
||||||
|
`redeemedByStaffId` VARCHAR(191) NULL,
|
||||||
|
`expiresAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `LoyaltyReward_loyaltyAccountId_idx`(`loyaltyAccountId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Notification` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NULL,
|
||||||
|
`appointmentId` VARCHAR(191) NULL,
|
||||||
|
`category` ENUM('BOOKING_CONFIRMATION', 'BOOKING_REMINDER', 'BOOKING_CHANGED', 'BOOKING_CANCELLED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_AVAILABLE', 'MARKETING') NOT NULL,
|
||||||
|
`channel` ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
|
||||||
|
`status` ENUM('PENDING', 'SENT', 'FAILED') NOT NULL DEFAULT 'PENDING',
|
||||||
|
`subject` VARCHAR(191) NULL,
|
||||||
|
`body` TEXT NOT NULL,
|
||||||
|
`sentAt` DATETIME(3) NULL,
|
||||||
|
`failureReason` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `Notification_userId_idx`(`userId`),
|
||||||
|
INDEX `Notification_appointmentId_idx`(`appointmentId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `NotificationPreference` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`category` ENUM('BOOKING_CONFIRMATION', 'BOOKING_REMINDER', 'BOOKING_CHANGED', 'BOOKING_CANCELLED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_AVAILABLE', 'MARKETING') NOT NULL,
|
||||||
|
`channel` ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
|
||||||
|
`isEnabled` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
UNIQUE INDEX `NotificationPreference_userId_category_channel_key`(`userId`, `category`, `channel`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `CustomerNote` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`authorStaffId` VARCHAR(191) NOT NULL,
|
||||||
|
`body` TEXT NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `CustomerNote_customerId_idx`(`customerId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `AuditLog` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`actorUserId` VARCHAR(191) NULL,
|
||||||
|
`action` VARCHAR(191) NOT NULL,
|
||||||
|
`entityType` VARCHAR(191) NOT NULL,
|
||||||
|
`entityId` VARCHAR(191) NOT NULL,
|
||||||
|
`metadata` JSON NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `AuditLog_entityType_entityId_idx`(`entityType`, `entityId`),
|
||||||
|
INDEX `AuditLog_actorUserId_idx`(`actorUserId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `BusinessLocation` ADD CONSTRAINT `BusinessLocation_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `BusinessHours` ADD CONSTRAINT `BusinessHours_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `BusinessSettings` ADD CONSTRAINT `BusinessSettings_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `BookingRule` ADD CONSTRAINT `BookingRule_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Account` ADD CONSTRAINT `Account_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Session` ADD CONSTRAINT `Session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `CustomerProfile` ADD CONSTRAINT `CustomerProfile_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `CustomerProfile` ADD CONSTRAINT `CustomerProfile_preferredBarberId_fkey` FOREIGN KEY (`preferredBarberId`) REFERENCES `StaffProfile`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `StaffProfile` ADD CONSTRAINT `StaffProfile_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `StaffProfile` ADD CONSTRAINT `StaffProfile_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `StaffProfile` ADD CONSTRAINT `StaffProfile_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Role` ADD CONSTRAINT `Role_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `RolePermission` ADD CONSTRAINT `RolePermission_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `Role`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `StaffInvitation` ADD CONSTRAINT `StaffInvitation_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `StaffInvitation` ADD CONSTRAINT `StaffInvitation_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `StaffInvitation` ADD CONSTRAINT `StaffInvitation_invitedByUserId_fkey` FOREIGN KEY (`invitedByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `ServiceCategory` ADD CONSTRAINT `ServiceCategory_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Service` ADD CONSTRAINT `Service_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Service` ADD CONSTRAINT `Service_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `ServiceCategory`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `StaffService` ADD CONSTRAINT `StaffService_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `StaffService` ADD CONSTRAINT `StaffService_serviceId_fkey` FOREIGN KEY (`serviceId`) REFERENCES `Service`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `WorkingHours` ADD CONSTRAINT `WorkingHours_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Break` ADD CONSTRAINT `Break_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `BreakDay` ADD CONSTRAINT `BreakDay_breakId_fkey` FOREIGN KEY (`breakId`) REFERENCES `Break`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `TimeOff` ADD CONSTRAINT `TimeOff_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `StaffProfile`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_cancelledByUserId_fkey` FOREIGN KEY (`cancelledByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Appointment` ADD CONSTRAINT `Appointment_createdByUserId_fkey` FOREIGN KEY (`createdByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `AppointmentService` ADD CONSTRAINT `AppointmentService_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `AppointmentService` ADD CONSTRAINT `AppointmentService_serviceId_fkey` FOREIGN KEY (`serviceId`) REFERENCES `Service`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyProgramme` ADD CONSTRAINT `LoyaltyProgramme_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyProgramme` ADD CONSTRAINT `LoyaltyProgramme_freeServiceId_fkey` FOREIGN KEY (`freeServiceId`) REFERENCES `Service`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyEligibleService` ADD CONSTRAINT `LoyaltyEligibleService_loyaltyProgrammeId_fkey` FOREIGN KEY (`loyaltyProgrammeId`) REFERENCES `LoyaltyProgramme`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyEligibleService` ADD CONSTRAINT `LoyaltyEligibleService_serviceId_fkey` FOREIGN KEY (`serviceId`) REFERENCES `Service`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyEligibleStaff` ADD CONSTRAINT `LoyaltyEligibleStaff_loyaltyProgrammeId_fkey` FOREIGN KEY (`loyaltyProgrammeId`) REFERENCES `LoyaltyProgramme`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyAccount` ADD CONSTRAINT `LoyaltyAccount_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyAccount` ADD CONSTRAINT `LoyaltyAccount_loyaltyProgrammeId_fkey` FOREIGN KEY (`loyaltyProgrammeId`) REFERENCES `LoyaltyProgramme`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyTransaction` ADD CONSTRAINT `LoyaltyTransaction_loyaltyAccountId_fkey` FOREIGN KEY (`loyaltyAccountId`) REFERENCES `LoyaltyAccount`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyTransaction` ADD CONSTRAINT `LoyaltyTransaction_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyTransaction` ADD CONSTRAINT `LoyaltyTransaction_authoredByStaffId_fkey` FOREIGN KEY (`authoredByStaffId`) REFERENCES `StaffProfile`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyReward` ADD CONSTRAINT `LoyaltyReward_loyaltyAccountId_fkey` FOREIGN KEY (`loyaltyAccountId`) REFERENCES `LoyaltyAccount`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyReward` ADD CONSTRAINT `LoyaltyReward_appliedToAppointmentId_fkey` FOREIGN KEY (`appliedToAppointmentId`) REFERENCES `Appointment`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `LoyaltyReward` ADD CONSTRAINT `LoyaltyReward_redeemedByStaffId_fkey` FOREIGN KEY (`redeemedByStaffId`) REFERENCES `StaffProfile`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `NotificationPreference` ADD CONSTRAINT `NotificationPreference_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `CustomerNote` ADD CONSTRAINT `CustomerNote_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `CustomerNote` ADD CONSTRAINT `CustomerNote_authorStaffId_fkey` FOREIGN KEY (`authorStaffId`) REFERENCES `StaffProfile`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `GuestAppointmentAccessToken` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`appointmentId` VARCHAR(191) NOT NULL,
|
||||||
|
`tokenHash` VARCHAR(191) NOT NULL,
|
||||||
|
`expiresAt` DATETIME(3) NOT NULL,
|
||||||
|
`revokedAt` DATETIME(3) NULL,
|
||||||
|
`lastUsedAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `GuestAppointmentAccessToken_appointmentId_key`(`appointmentId`),
|
||||||
|
UNIQUE INDEX `GuestAppointmentAccessToken_tokenHash_key`(`tokenHash`),
|
||||||
|
INDEX `GuestAppointmentAccessToken_expiresAt_idx`(`expiresAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `GuestAppointmentAccessToken` ADD CONSTRAINT `GuestAppointmentAccessToken_appointmentId_fkey` FOREIGN KEY (`appointmentId`) REFERENCES `Appointment`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- Phase 7: redesign Notification/NotificationPreference around the
|
||||||
|
-- Notification (message) / NotificationDelivery (one attempt per channel)
|
||||||
|
-- split — see prisma/schema.prisma for the full rationale. Both tables
|
||||||
|
-- were unused scaffolding from Phase 2 (confirmed via a repo-wide grep for
|
||||||
|
-- `prisma.notification`/`prisma.notificationPreference` before writing
|
||||||
|
-- this migration — zero call sites), so these are safe destructive
|
||||||
|
-- ALTERs, not a real data migration.
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `Notification` DROP FOREIGN KEY `Notification_userId_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `NotificationPreference` DROP FOREIGN KEY `NotificationPreference_userId_fkey`;
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX `Notification_userId_idx` ON `Notification`;
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX `NotificationPreference_userId_category_channel_key` ON `NotificationPreference`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `Notification` DROP COLUMN `body`,
|
||||||
|
DROP COLUMN `category`,
|
||||||
|
DROP COLUMN `channel`,
|
||||||
|
DROP COLUMN `failureReason`,
|
||||||
|
DROP COLUMN `sentAt`,
|
||||||
|
DROP COLUMN `status`,
|
||||||
|
DROP COLUMN `userId`,
|
||||||
|
ADD COLUMN `bodyText` TEXT NOT NULL,
|
||||||
|
ADD COLUMN `businessId` VARCHAR(191) NOT NULL,
|
||||||
|
ADD COLUMN `deepLinkPath` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN `idempotencyKey` VARCHAR(191) NOT NULL,
|
||||||
|
ADD COLUMN `loyaltyRewardId` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN `readAt` DATETIME(3) NULL,
|
||||||
|
ADD COLUMN `recipientCustomerId` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN `recipientUserId` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `NotificationPreference` DROP COLUMN `category`,
|
||||||
|
ADD COLUMN `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL,
|
||||||
|
MODIFY `channel` ENUM('EMAIL', 'PUSH', 'IN_APP') NOT NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `NotificationDelivery` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`notificationId` VARCHAR(191) NOT NULL,
|
||||||
|
`channel` ENUM('EMAIL', 'PUSH', 'IN_APP') NOT NULL,
|
||||||
|
`status` ENUM('PENDING', 'SCHEDULED', 'PROCESSING', 'SENT', 'FAILED', 'CANCELLED', 'SKIPPED') NOT NULL DEFAULT 'PENDING',
|
||||||
|
`scheduledFor` DATETIME(3) NULL,
|
||||||
|
`attemptCount` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`lastAttemptedAt` DATETIME(3) NULL,
|
||||||
|
`providerName` VARCHAR(191) NULL,
|
||||||
|
`providerMessageId` VARCHAR(191) NULL,
|
||||||
|
`failureReason` VARCHAR(191) NULL,
|
||||||
|
`sentAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `NotificationDelivery_status_scheduledFor_idx`(`status`, `scheduledFor`),
|
||||||
|
UNIQUE INDEX `NotificationDelivery_notificationId_channel_key`(`notificationId`, `channel`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX `Notification_idempotencyKey_key` ON `Notification`(`idempotencyKey`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `Notification_businessId_createdAt_idx` ON `Notification`(`businessId`, `createdAt`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `Notification_recipientUserId_readAt_idx` ON `Notification`(`recipientUserId`, `readAt`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `Notification_recipientCustomerId_idx` ON `Notification`(`recipientCustomerId`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX `NotificationPreference_userId_type_channel_key` ON `NotificationPreference`(`userId`, `type`, `channel`);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_recipientUserId_fkey` FOREIGN KEY (`recipientUserId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_recipientCustomerId_fkey` FOREIGN KEY (`recipientCustomerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Notification` ADD CONSTRAINT `Notification_loyaltyRewardId_fkey` FOREIGN KEY (`loyaltyRewardId`) REFERENCES `LoyaltyReward`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `NotificationDelivery` ADD CONSTRAINT `NotificationDelivery_notificationId_fkey` FOREIGN KEY (`notificationId`) REFERENCES `Notification`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
-- Re-adds the FK this migration dropped above (to accommodate the
|
||||||
|
-- category->type column rename) but never restored — caught while writing
|
||||||
|
-- a later migration and diffing against the live dev DB, which showed
|
||||||
|
-- NotificationPreference.userId with no FK at all. Fixed here, in the
|
||||||
|
-- original migration, rather than a separate patch: this migration had
|
||||||
|
-- only ever been applied to this local dev database, never shared.
|
||||||
|
ALTER TABLE `NotificationPreference` ADD CONSTRAINT `NotificationPreference_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Adds STAFF_INVITATION_RECEIVED to NotificationType — the invite email
|
||||||
|
-- itself, sent to the person being invited (see StaffInvitationService and
|
||||||
|
-- templates/staff-emails.ts renderStaffInvitationEmail). Distinct from the
|
||||||
|
-- existing OWNER_STAFF_INVITATION_ACCEPTED, which notifies the owner once
|
||||||
|
-- accepted.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `Notification` MODIFY `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'STAFF_INVITATION_RECEIVED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `NotificationPreference` MODIFY `type` ENUM('BOOKING_CONFIRMED', 'BOOKING_PENDING_APPROVAL', 'BOOKING_APPROVED', 'BOOKING_REMINDER', 'BOOKING_RESCHEDULED', 'BOOKING_CANCELLED', 'BOOKING_BARBER_CHANGED', 'BOOKING_COMPLETED', 'LOYALTY_STAMP_ADDED', 'LOYALTY_REWARD_EARNED', 'LOYALTY_REWARD_REDEEMED', 'LOYALTY_REWARD_EXPIRING', 'STAFF_NEW_APPOINTMENT', 'STAFF_APPOINTMENT_RESCHEDULED', 'STAFF_APPOINTMENT_CANCELLED', 'STAFF_APPOINTMENT_CHECKED_IN', 'STAFF_APPOINTMENT_REMINDER', 'STAFF_SCHEDULE_CHANGED', 'STAFF_INVITATION_RECEIVED', 'OWNER_NEW_BOOKING', 'OWNER_BOOKING_CANCELLED', 'OWNER_NO_SHOW', 'OWNER_STAFF_INVITATION_ACCEPTED', 'OWNER_REPEATED_DELIVERY_FAILURE') NOT NULL;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `PushSubscription` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`businessId` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NULL,
|
||||||
|
`customerId` VARCHAR(191) NULL,
|
||||||
|
`endpoint` VARCHAR(500) NOT NULL,
|
||||||
|
`p256dhKey` VARCHAR(191) NOT NULL,
|
||||||
|
`authKey` VARCHAR(191) NOT NULL,
|
||||||
|
`deviceLabel` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`lastUsedAt` DATETIME(3) NULL,
|
||||||
|
`revokedAt` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `PushSubscription_endpoint_key`(`endpoint`),
|
||||||
|
INDEX `PushSubscription_userId_idx`(`userId`),
|
||||||
|
INDEX `PushSubscription_customerId_idx`(`customerId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `PushSubscription` ADD CONSTRAINT `PushSubscription_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `PushSubscription` ADD CONSTRAINT `PushSubscription_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `PushSubscription` ADD CONSTRAINT `PushSubscription_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `CustomerProfile`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- RenameIndex
|
||||||
|
ALTER TABLE `Break` RENAME INDEX `Break_staffId_fkey` TO `Break_staffId_idx`;
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "mysql"
|
||||||
+242
-26
@@ -1,15 +1,23 @@
|
|||||||
// NextChair — Phase 2 database schema.
|
// NextChair — Phase 2 database schema.
|
||||||
//
|
//
|
||||||
// Portability: every model deliberately avoids Postgres-only features
|
// Portability: every model deliberately avoids Postgres-only features
|
||||||
// (native arrays, JSONB-specific operators, LISTEN/NOTIFY) so the
|
// (native arrays, JSONB-specific operators, LISTEN/NOTIFY) — this paid off
|
||||||
// `datasource.provider` below can later change to "mysql" with a
|
// in Phase 7, when the schema moved from PostgreSQL to MySQL with only a
|
||||||
// `prisma migrate diff` instead of a schema rewrite. Concretely:
|
// provider-string change (see docs/phase-7-summary.md §"Database migration"
|
||||||
|
// for the full record: schema review, index/collation decisions, and the
|
||||||
|
// serialization-error classifier update). Concretely:
|
||||||
// - no `String[]` / scalar-list columns anywhere (see BreakDay join table)
|
// - no `String[]` / scalar-list columns anywhere (see BreakDay join table)
|
||||||
// - `Decimal`, `Json`, `@db.Date`, `@db.Text` are used because Prisma maps
|
// - `Decimal`, `Json`, `@db.Date`, `@db.Text` are used because Prisma maps
|
||||||
// all four to native, equivalent types on both Postgres and MySQL
|
// all four to native, equivalent types on both Postgres and MySQL
|
||||||
// - recurring wall-clock times (working hours, breaks) are stored as
|
// - recurring wall-clock times (working hours, breaks) are stored as
|
||||||
// `Int` minutes-from-midnight rather than a DB TIME type, so scheduling
|
// `Int` minutes-from-midnight rather than a DB TIME type, so scheduling
|
||||||
// math lives in application code, not in provider-specific SQL
|
// math lives in application code, not in provider-specific SQL
|
||||||
|
//
|
||||||
|
// Database: MySQL 8.0 (utf8mb4 / utf8mb4_0900_ai_ci) — see
|
||||||
|
// src/lib/db/prisma.ts for the driver adapter and docs/phase-7-summary.md
|
||||||
|
// for why (the production host only supports MySQL; the exact hosted
|
||||||
|
// version was not available in this repo, so 8.0 is the documented
|
||||||
|
// development/CI target pending confirmation).
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
@@ -19,7 +27,7 @@ generator client {
|
|||||||
// Connection URL lives in prisma.config.ts (Prisma 7) and is passed to
|
// Connection URL lives in prisma.config.ts (Prisma 7) and is passed to
|
||||||
// PrismaClient at runtime via a driver adapter — see src/lib/db/prisma.ts.
|
// PrismaClient at runtime via a driver adapter — see src/lib/db/prisma.ts.
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "mysql"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -110,26 +118,77 @@ enum LoyaltyTransactionType {
|
|||||||
REWARD_REDEEMED
|
REWARD_REDEEMED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationCategory {
|
/// One value per real, distinct trigger this app actually has (see
|
||||||
BOOKING_CONFIRMATION
|
/// NotificationService and docs/phase-7-summary.md, "Notification types
|
||||||
|
/// implemented"). Deliberately does NOT include a type for every event the
|
||||||
|
/// Phase 7 brief brainstormed — several suggested owner/barber "conflict"
|
||||||
|
/// or "config warning" notifications are already served live by the
|
||||||
|
/// existing owner dashboard and the working-hours/breaks conflict-review
|
||||||
|
/// UI (see StaffService), which have no distinct async trigger separate
|
||||||
|
/// from rendering that page; duplicating them as stored Notification rows
|
||||||
|
/// would have no real "when do I fire this" moment. MARKETING/promotional
|
||||||
|
/// types are also omitted — marketing campaigns are explicitly out of
|
||||||
|
/// scope for this app.
|
||||||
|
enum NotificationType {
|
||||||
|
// Customer-facing
|
||||||
|
BOOKING_CONFIRMED
|
||||||
|
BOOKING_PENDING_APPROVAL
|
||||||
|
BOOKING_APPROVED
|
||||||
BOOKING_REMINDER
|
BOOKING_REMINDER
|
||||||
BOOKING_CHANGED
|
BOOKING_RESCHEDULED
|
||||||
|
/// Carries the late-cancellation flag in its rendered content rather than
|
||||||
|
/// being a separate type — same event, one boolean modifier, not two
|
||||||
|
/// distinct triggers.
|
||||||
BOOKING_CANCELLED
|
BOOKING_CANCELLED
|
||||||
|
BOOKING_BARBER_CHANGED
|
||||||
|
BOOKING_COMPLETED
|
||||||
|
LOYALTY_STAMP_ADDED
|
||||||
LOYALTY_REWARD_EARNED
|
LOYALTY_REWARD_EARNED
|
||||||
LOYALTY_REWARD_AVAILABLE
|
LOYALTY_REWARD_REDEEMED
|
||||||
MARKETING
|
LOYALTY_REWARD_EXPIRING
|
||||||
|
|
||||||
|
// Barber-facing
|
||||||
|
STAFF_NEW_APPOINTMENT
|
||||||
|
STAFF_APPOINTMENT_RESCHEDULED
|
||||||
|
STAFF_APPOINTMENT_CANCELLED
|
||||||
|
STAFF_APPOINTMENT_CHECKED_IN
|
||||||
|
STAFF_APPOINTMENT_REMINDER
|
||||||
|
STAFF_SCHEDULE_CHANGED
|
||||||
|
/// The invite itself, sent to the person being invited — see
|
||||||
|
/// StaffInvitationService. Distinct from OWNER_STAFF_INVITATION_ACCEPTED
|
||||||
|
/// below, which notifies the OWNER once they accept.
|
||||||
|
STAFF_INVITATION_RECEIVED
|
||||||
|
|
||||||
|
// Owner-facing
|
||||||
|
OWNER_NEW_BOOKING
|
||||||
|
OWNER_BOOKING_CANCELLED
|
||||||
|
OWNER_NO_SHOW
|
||||||
|
OWNER_STAFF_INVITATION_ACCEPTED
|
||||||
|
/// Self-referential — the notification background processor raises this
|
||||||
|
/// for the owner when it observes repeated delivery failures, rather
|
||||||
|
/// than a domain workflow triggering it directly. See reminder-worker.ts.
|
||||||
|
OWNER_REPEATED_DELIVERY_FAILURE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationChannel {
|
enum NotificationChannel {
|
||||||
EMAIL
|
EMAIL
|
||||||
SMS
|
|
||||||
PUSH
|
PUSH
|
||||||
|
/// Visible in the in-app notification centre — see src/features/notifications.
|
||||||
|
IN_APP
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationStatus {
|
/// Deliberately stops at SENT, not DELIVERED — a provider accepting an
|
||||||
|
/// email/push send is not delivery confirmation, and this app has no
|
||||||
|
/// Brevo webhook wired up to ever justify a DELIVERED state (see
|
||||||
|
/// docs/phase-7-summary.md, "Brevo elements that couldn't be verified").
|
||||||
|
enum NotificationDeliveryStatus {
|
||||||
PENDING
|
PENDING
|
||||||
|
SCHEDULED
|
||||||
|
PROCESSING
|
||||||
SENT
|
SENT
|
||||||
FAILED
|
FAILED
|
||||||
|
CANCELLED
|
||||||
|
SKIPPED
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -173,6 +232,8 @@ model Business {
|
|||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
loyaltyProgramme LoyaltyProgramme?
|
loyaltyProgramme LoyaltyProgramme?
|
||||||
staffInvitations StaffInvitation[]
|
staffInvitations StaffInvitation[]
|
||||||
|
notifications Notification[]
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split from Business (rather than address fields on Business itself) so
|
/// Split from Business (rather than address fields on Business itself) so
|
||||||
@@ -298,8 +359,9 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
customerProfile CustomerProfile?
|
customerProfile CustomerProfile?
|
||||||
staffProfile StaffProfile?
|
staffProfile StaffProfile?
|
||||||
notifications Notification[]
|
receivedNotifications Notification[] @relation("NotificationRecipientUser")
|
||||||
notificationPreferences NotificationPreference[]
|
notificationPreferences NotificationPreference[]
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
/// Whoever actually cancelled an appointment — the customer themselves,
|
/// Whoever actually cancelled an appointment — the customer themselves,
|
||||||
/// or the staff member who did it on their behalf.
|
/// or the staff member who did it on their behalf.
|
||||||
cancelledAppointments Appointment[] @relation("AppointmentCancelledBy")
|
cancelledAppointments Appointment[] @relation("AppointmentCancelledBy")
|
||||||
@@ -376,6 +438,8 @@ model CustomerProfile {
|
|||||||
appointments Appointment[]
|
appointments Appointment[]
|
||||||
loyaltyAccounts LoyaltyAccount[]
|
loyaltyAccounts LoyaltyAccount[]
|
||||||
notes CustomerNote[]
|
notes CustomerNote[]
|
||||||
|
receivedNotifications Notification[] @relation("NotificationRecipientCustomer")
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@index([phone])
|
@@index([phone])
|
||||||
@@ -587,6 +651,8 @@ model Break {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
days BreakDay[]
|
days BreakDay[]
|
||||||
|
|
||||||
|
@@index([staffId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model BreakDay {
|
model BreakDay {
|
||||||
@@ -685,12 +751,40 @@ model Appointment {
|
|||||||
/// appointment (see LoyaltyReward.appliedToAppointmentId) — but modelled
|
/// appointment (see LoyaltyReward.appliedToAppointmentId) — but modelled
|
||||||
/// 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?
|
||||||
|
|
||||||
@@index([staffId, startAt])
|
@@index([staffId, startAt])
|
||||||
@@index([businessId, startAt])
|
@@index([businessId, startAt])
|
||||||
@@index([customerId])
|
@@index([customerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A guest (no linked User — see CustomerProfile.userId) proves they own an
|
||||||
|
/// appointment by presenting the raw token embedded in their booking
|
||||||
|
/// confirmation link, not by knowing the appointment's id alone (a cuid is
|
||||||
|
/// unguessable but was never actually a secret — Phase 4 documented this as
|
||||||
|
/// a deliberate, temporary gap; closed here — see
|
||||||
|
/// src/services/booking/guest-access.service.ts and docs/phase-7-summary.md).
|
||||||
|
/// Only the SHA-256 hash is ever persisted; the raw token exists just long
|
||||||
|
/// enough to embed in a link (confirmation page, and later, email) and is
|
||||||
|
/// never logged. One row per appointment — (re-)issuing overwrites the
|
||||||
|
/// previous hash, so an old copy of the raw token stops working.
|
||||||
|
model GuestAppointmentAccessToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
appointmentId String @unique
|
||||||
|
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
tokenHash String @unique
|
||||||
|
|
||||||
|
expiresAt DateTime
|
||||||
|
revokedAt DateTime?
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
/// One appointment can carry multiple services (the booking flow's "Add
|
/// One appointment can carry multiple services (the booking flow's "Add
|
||||||
/// another service" step). price/duration are snapshotted at booking time
|
/// another service" step). price/duration are snapshotted at booking time
|
||||||
/// so a later Service price change never rewrites booking history.
|
/// so a later Service price change never rewrites booking history.
|
||||||
@@ -849,51 +943,173 @@ model LoyaltyReward {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
notifications Notification[]
|
||||||
|
|
||||||
@@index([loyaltyAccountId])
|
@@index([loyaltyAccountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Notifications — abstracted behind category + channel so an SMS/push
|
// Notifications (Phase 7) — a Notification is one message to one
|
||||||
// provider is a new NotificationChannel value and a sender implementation,
|
// recipient (the "what happened, to whom"); a NotificationDelivery is one
|
||||||
// never a schema change.
|
// attempt to get that message to them over one channel (email/push/
|
||||||
|
// in-app). Split for the same reason AppointmentService snapshots price
|
||||||
|
// separately from Service — a single message commonly needs more than one
|
||||||
|
// delivery attempt/channel with independent status, without duplicating
|
||||||
|
// the rendered content. See src/services/notifications/notification.service.ts.
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
model Notification {
|
model Notification {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
userId String?
|
businessId String
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Set for staff/owner/registered-customer recipients — resolves
|
||||||
|
/// NotificationPreference and powers the in-app notification centre.
|
||||||
|
/// Null for a guest customer (no User to attach to).
|
||||||
|
recipientUserId String?
|
||||||
|
recipientUser User? @relation("NotificationRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Set whenever the recipient IS a customer (guest or registered) —
|
||||||
|
/// lets a guest-directed notification render/deep-link correctly even
|
||||||
|
/// with no recipientUserId.
|
||||||
|
recipientCustomerId String?
|
||||||
|
recipientCustomer CustomerProfile? @relation("NotificationRecipientCustomer", fields: [recipientCustomerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
type NotificationType
|
||||||
|
|
||||||
appointmentId String?
|
appointmentId String?
|
||||||
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
|
appointment Appointment? @relation(fields: [appointmentId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
category NotificationCategory
|
loyaltyRewardId String?
|
||||||
channel NotificationChannel
|
loyaltyReward LoyaltyReward? @relation(fields: [loyaltyRewardId], references: [id], onDelete: SetNull)
|
||||||
status NotificationStatus @default(PENDING)
|
|
||||||
|
|
||||||
subject String?
|
subject String?
|
||||||
body String @db.Text
|
/// Canonical rendered plain-text content — the in-app notification body
|
||||||
|
/// and the email plain-text fallback both read from here; the HTML email
|
||||||
|
/// template (see src/services/notifications/templates) renders its own
|
||||||
|
/// markup from the same underlying data, not from this string.
|
||||||
|
bodyText String @db.Text
|
||||||
|
/// Relative path (e.g. "/appointments/xyz?token=..." for a guest) —
|
||||||
|
/// never an absolute URL, so it works whether the notification is opened
|
||||||
|
/// from an email (base URL prepended at send time), the installed PWA,
|
||||||
|
/// or the in-app centre.
|
||||||
|
deepLinkPath String?
|
||||||
|
|
||||||
sentAt DateTime?
|
/// Prevents duplicate creation from a retried/duplicate domain call —
|
||||||
failureReason String?
|
/// e.g. "booking-confirmed:{appointmentId}" or
|
||||||
|
/// "reminder-24h:{appointmentId}". A second attempt to create the same
|
||||||
|
/// logical notification is a no-op (see NotificationService.notify).
|
||||||
|
idempotencyKey String @unique
|
||||||
|
|
||||||
|
/// Only meaningful for a recipientUserId notification with an IN_APP
|
||||||
|
/// delivery — the in-app centre's read/unread state.
|
||||||
|
readAt DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([userId])
|
deliveries NotificationDelivery[]
|
||||||
|
|
||||||
|
@@index([businessId, createdAt])
|
||||||
|
@@index([recipientUserId, readAt])
|
||||||
|
@@index([recipientCustomerId])
|
||||||
@@index([appointmentId])
|
@@index([appointmentId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model NotificationDelivery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
notificationId String
|
||||||
|
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
channel NotificationChannel
|
||||||
|
status NotificationDeliveryStatus @default(PENDING)
|
||||||
|
|
||||||
|
/// Null = process as soon as the background worker picks it up; set for
|
||||||
|
/// a scheduled appointment reminder.
|
||||||
|
scheduledFor DateTime?
|
||||||
|
|
||||||
|
attemptCount Int @default(0)
|
||||||
|
lastAttemptedAt DateTime?
|
||||||
|
|
||||||
|
providerName String?
|
||||||
|
providerMessageId String?
|
||||||
|
/// A short, safe-to-display summary only — never the provider's raw
|
||||||
|
/// error payload, which can include request details worth not logging.
|
||||||
|
failureReason String?
|
||||||
|
|
||||||
|
sentAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([notificationId, channel])
|
||||||
|
@@index([status, scheduledFor])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the few NotificationTypes that are genuinely optional (currently:
|
||||||
|
/// the two reminder types) ever consult this — required transactional
|
||||||
|
/// types always send regardless of any row here, by design (see
|
||||||
|
/// NotificationService, PREFERENCE_CONTROLLED_TYPES). No stored
|
||||||
|
/// "category" column: which types are preference-controlled is an
|
||||||
|
/// application-layer policy, not a schema fact, so it can change without
|
||||||
|
/// a migration.
|
||||||
model NotificationPreference {
|
model NotificationPreference {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
category NotificationCategory
|
type NotificationType
|
||||||
channel NotificationChannel
|
channel NotificationChannel
|
||||||
isEnabled Boolean @default(true)
|
isEnabled Boolean @default(true)
|
||||||
|
|
||||||
@@unique([userId, category, channel])
|
@@unique([userId, type, channel])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One browser/device's Web Push subscription — a user can have several
|
||||||
|
/// (phone + laptop). Scoped to a business (not just a user) because a
|
||||||
|
/// guest customer, who has no User at all, can still subscribe from
|
||||||
|
/// whatever device they're on right after booking; recipientUserId is
|
||||||
|
/// null for that case, matching the same "user OR customer" recipient
|
||||||
|
/// shape as Notification itself.
|
||||||
|
model PushSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
businessId String
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
customerId String?
|
||||||
|
customer CustomerProfile? @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// The push service's own subscription URL — long (some real-world push
|
||||||
|
/// services exceed 191 chars), hence the explicit length instead of
|
||||||
|
/// Prisma's default VARCHAR(191) for a unique-constrained String.
|
||||||
|
endpoint String @unique @db.VarChar(500)
|
||||||
|
/// The subscription's own public key/auth secret (from
|
||||||
|
/// PushSubscription.toJSON().keys in the browser) — needed to encrypt
|
||||||
|
/// each push payload; not a credential of ours to protect the way
|
||||||
|
/// EMAIL_SMTP_PASSWORD is, but still never logged (see reminder-worker.ts).
|
||||||
|
p256dhKey String
|
||||||
|
authKey String
|
||||||
|
|
||||||
|
/// User-facing label so Settings can show "iPhone — Safari" instead of a
|
||||||
|
/// raw endpoint — best-effort, derived from the User-Agent at subscribe
|
||||||
|
/// time, not re-verified later.
|
||||||
|
deviceLabel String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
/// Set when the push service itself reports the subscription is gone
|
||||||
|
/// (410/404 — see reminder-worker.ts) or the user explicitly
|
||||||
|
/// unsubscribes; never deleted outright, so "why did this stop
|
||||||
|
/// receiving pushes" stays answerable.
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([customerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+64
-3
@@ -4,8 +4,10 @@
|
|||||||
* 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 } from "../generated/prisma/client";
|
import { PrismaClient, Permission, Weekday, RewardType, NotificationType } from "../generated/prisma/client";
|
||||||
import { PrismaPg } from "@prisma/adapter-pg";
|
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||||
|
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
|
||||||
|
import { notifySafely } from "../src/services/notifications/notification.service";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +26,7 @@ function minutesFromNow(minutes: number): Date {
|
|||||||
return new Date(Date.now() + minutes * 60_000);
|
return new Date(Date.now() + minutes * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
|
||||||
const prisma = new PrismaClient({ adapter });
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -952,6 +954,65 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 7: notification centre fixtures ──────────────────────────────
|
||||||
|
// Routed through the real notifySafely() rather than raw prisma.create()
|
||||||
|
// calls, so these get the exact same channel-resolution/idempotency
|
||||||
|
// behaviour a live booking would — a fresh checkout of this repo should
|
||||||
|
// show a populated notification bell for each role without needing to
|
||||||
|
// drive a real action first. Uses notifySafely (not notify) so a rerun
|
||||||
|
// of the seed never throws on the idempotency-key collision.
|
||||||
|
if (ownerUser.staffProfile && barberUser.staffProfile && registeredCustomer) {
|
||||||
|
await notifySafely(prisma, {
|
||||||
|
businessId: business.id,
|
||||||
|
type: NotificationType.BOOKING_CONFIRMED,
|
||||||
|
recipientUserId: registeredCustomerUser.id,
|
||||||
|
recipientCustomerId: registeredCustomer.id,
|
||||||
|
appointmentId: `${business.id}-appt-today-completed`,
|
||||||
|
subject: "Booking confirmed",
|
||||||
|
bodyText: `Your ${haircut.name} with ${ownerUser.name} is confirmed.`,
|
||||||
|
deepLinkPath: "/appointments",
|
||||||
|
idempotencyKey: `notify:booking-confirmed:${business.id}-appt-today-completed`,
|
||||||
|
});
|
||||||
|
await notifySafely(prisma, {
|
||||||
|
businessId: business.id,
|
||||||
|
type: NotificationType.LOYALTY_STAMP_ADDED,
|
||||||
|
recipientUserId: registeredCustomerUser.id,
|
||||||
|
recipientCustomerId: registeredCustomer.id,
|
||||||
|
subject: "You've earned a loyalty stamp",
|
||||||
|
bodyText: "You've earned a loyalty stamp. Keep it up!",
|
||||||
|
deepLinkPath: "/loyalty",
|
||||||
|
idempotencyKey: `notify:loyalty-stamp-added:seed-${registeredCustomer.id}`,
|
||||||
|
});
|
||||||
|
// Marked read immediately below — gives the notification centre a mix
|
||||||
|
// of read/unread state out of the box, matching real usage instead of
|
||||||
|
// "everything unread forever."
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: { idempotencyKey: `notify:loyalty-stamp-added:seed-${registeredCustomer.id}` },
|
||||||
|
data: { readAt: daysFromNow(-1, 9) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifySafely(prisma, {
|
||||||
|
businessId: business.id,
|
||||||
|
type: NotificationType.STAFF_NEW_APPOINTMENT,
|
||||||
|
recipientUserId: barberUser.id,
|
||||||
|
appointmentId: `${business.id}-appt-today-checkedin`,
|
||||||
|
subject: "New appointment",
|
||||||
|
bodyText: `${secondCustomerUser.name ?? "A customer"} booked ${haircut.name}.`,
|
||||||
|
deepLinkPath: "/staff/current",
|
||||||
|
idempotencyKey: `notify:staff-new-appointment:${business.id}-appt-today-checkedin`,
|
||||||
|
});
|
||||||
|
await notifySafely(prisma, {
|
||||||
|
businessId: business.id,
|
||||||
|
type: NotificationType.OWNER_NEW_BOOKING,
|
||||||
|
recipientUserId: ownerUser.id,
|
||||||
|
appointmentId: `${business.id}-appt-today-checkedin`,
|
||||||
|
subject: "New booking",
|
||||||
|
bodyText: `${secondCustomerUser.name ?? "A customer"} booked ${haircut.name} with ${barberUser.name}.`,
|
||||||
|
deepLinkPath: "/owner/calendar",
|
||||||
|
idempotencyKey: `notify:owner-new-booking:${business.id}-appt-today-checkedin`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Seed complete:", {
|
console.log("Seed complete:", {
|
||||||
business: business.name,
|
business: business.name,
|
||||||
roles: [ownerRole.name, barberRole.name],
|
roles: [ownerRole.name, barberRole.name],
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
+120
@@ -0,0 +1,120 @@
|
|||||||
|
// NextChair service worker.
|
||||||
|
//
|
||||||
|
// Scope, deliberately: this app is almost entirely dynamic, signed-in,
|
||||||
|
// per-request server-rendered content (appointments, calendars, loyalty
|
||||||
|
// balances) — caching that would risk serving stale or, worse, another
|
||||||
|
// user's data. So this file does NOT try to make the booking flow work
|
||||||
|
// offline. What it does do:
|
||||||
|
// - Push notifications (`push`/`notificationclick` — unchanged from the
|
||||||
|
// original Phase 7 push-notifications work).
|
||||||
|
// - Precache + serve a small static app shell (offline fallback page,
|
||||||
|
// manifest, icons) so a lost connection shows a real "you're offline"
|
||||||
|
// screen instead of the browser's own dinosaur/error page.
|
||||||
|
// - An update-available handshake: a new SW version installs and waits
|
||||||
|
// rather than taking over immediately, so the running page can prompt
|
||||||
|
// the user before switching (see update-available-banner.tsx).
|
||||||
|
//
|
||||||
|
// Every GET request that isn't the app shell or a navigation falls
|
||||||
|
// through untouched (`fetch` handler doesn't call respondWith), and every
|
||||||
|
// non-GET request (Server Actions, API mutations) is never intercepted at
|
||||||
|
// all — this worker must never sit between the app and a write.
|
||||||
|
|
||||||
|
const CACHE_VERSION = "v1";
|
||||||
|
const CACHE_NAME = `nextchair-shell-${CACHE_VERSION}`;
|
||||||
|
const OFFLINE_URL = "/offline";
|
||||||
|
|
||||||
|
const APP_SHELL = [OFFLINE_URL, "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.addAll(APP_SHELL))
|
||||||
|
// Deliberately no self.skipWaiting() here — an already-open tab keeps
|
||||||
|
// running its current version until the user acts on the
|
||||||
|
// update-available prompt (see the `message` handler below).
|
||||||
|
.catch(() => {
|
||||||
|
// A failed precache (e.g. first install with no network at all)
|
||||||
|
// shouldn't block installation — push notifications should still
|
||||||
|
// work even if the offline shell couldn't be cached yet.
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== "GET") return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Page navigations: network-first (this app is dynamic — a cached page
|
||||||
|
// would be stale or session-mismatched), falling back to the offline
|
||||||
|
// shell only when the network is actually unreachable.
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(() => caches.match(OFFLINE_URL).then((cached) => cached ?? Response.error())),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The static app-shell assets themselves: cache-first, since these are
|
||||||
|
// versioned by CACHE_NAME and only change on a new SW deploy.
|
||||||
|
if (APP_SHELL.some((path) => url.pathname === path)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => cached ?? fetch(request)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else (Next static build assets, API routes, images) —
|
||||||
|
// untouched, normal network fetch.
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = event.data.json();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = payload.title || "NextChair";
|
||||||
|
const options = {
|
||||||
|
body: payload.body || "",
|
||||||
|
data: { url: payload.url || "/" },
|
||||||
|
icon: "/icons/icon-192.png",
|
||||||
|
badge: "/icons/icon-192.png",
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = event.notification.data?.url || "/";
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(url) && "focus" in client) return client.focus();
|
||||||
|
}
|
||||||
|
if (self.clients.openWindow) return self.clients.openWindow(url);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { Scissors } from "lucide-react";
|
|||||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
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 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">
|
||||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,16 +35,19 @@ const REBOOKABLE_STATUSES = new Set(["COMPLETED", "CANCELLED", "NO_SHOW"]);
|
|||||||
|
|
||||||
export default async function AppointmentDetailsPage({
|
export default async function AppointmentDetailsPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ token?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const { token } = await searchParams;
|
||||||
const appointment = await getAppointmentById(prisma, id);
|
const appointment = await getAppointmentById(prisma, id);
|
||||||
if (!appointment) notFound();
|
if (!appointment) notFound();
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
try {
|
try {
|
||||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
await assertCanAccessAppointment(prisma, appointment, { sessionUserId: session?.user?.id, guestToken: token });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) notFound(); // don't confirm the id exists to someone who can't see it
|
if (error instanceof PermissionDeniedError) notFound(); // don't confirm the id exists to someone who can't see it
|
||||||
throw error;
|
throw error;
|
||||||
@@ -153,7 +156,7 @@ export default async function AppointmentDetailsPage({
|
|||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
href={`/api/appointments/${appointment.id}/calendar.ics${token ? `?token=${token}` : ""}`}
|
||||||
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
||||||
@@ -171,17 +174,17 @@ export default async function AppointmentDetailsPage({
|
|||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
||||||
<Link
|
<Link
|
||||||
href={`/appointments/${appointment.id}/reschedule`}
|
href={`/appointments/${appointment.id}/reschedule${token ? `?token=${token}` : ""}`}
|
||||||
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
Reschedule
|
Reschedule
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{CANCELLABLE_STATUSES.has(appointment.status) && (
|
{CANCELLABLE_STATUSES.has(appointment.status) && (
|
||||||
<CancelAppointmentDialog appointmentId={appointment.id} />
|
<CancelAppointmentDialog appointmentId={appointment.id} guestToken={token} />
|
||||||
)}
|
)}
|
||||||
{REBOOKABLE_STATUSES.has(appointment.status) && (
|
{REBOOKABLE_STATUSES.has(appointment.status) && (
|
||||||
<BookAgainButton appointmentId={appointment.id} />
|
<BookAgainButton appointmentId={appointment.id} guestToken={token} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ErrorState } from "@/components/feedback/error-state";
|
||||||
|
|
||||||
|
/** Keeps (customer)/layout.tsx's bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
|
||||||
|
export default function CustomerError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return <ErrorState onRetry={reset} />;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { computeOpeningStatus } from "@/lib/business/opening-status";
|
|||||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getAvailableServices } from "@/services/scheduling/availability.service";
|
import { getAvailableServices } from "@/services/scheduling/availability.service";
|
||||||
|
import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
import { getBusinessReviews } from "@/services/reviews/reviews.service";
|
import { getBusinessReviews } from "@/services/reviews/reviews.service";
|
||||||
import type { AppointmentStatus } from "@/types/domain";
|
import type { AppointmentStatus } from "@/types/domain";
|
||||||
|
|
||||||
@@ -67,8 +68,7 @@ export default async function CustomerHomePage() {
|
|||||||
|
|
||||||
let customerProfile: Awaited<ReturnType<typeof prisma.customerProfile.findUnique>> = null;
|
let customerProfile: Awaited<ReturnType<typeof prisma.customerProfile.findUnique>> = null;
|
||||||
let upcomingAppointment: UpcomingAppointment | null = null;
|
let upcomingAppointment: UpcomingAppointment | null = null;
|
||||||
let loyaltyAccount: Awaited<ReturnType<typeof prisma.loyaltyAccount.findFirst>> = null;
|
let loyalty: LoyaltySummary | null = null;
|
||||||
let loyaltyProgramme: Awaited<ReturnType<typeof prisma.loyaltyProgramme.findUnique>> = null;
|
|
||||||
let preferredBarberName: string | null = null;
|
let preferredBarberName: string | null = null;
|
||||||
let lastCompletedAppointment: Awaited<ReturnType<typeof prisma.appointment.findFirst>> = null;
|
let lastCompletedAppointment: Awaited<ReturnType<typeof prisma.appointment.findFirst>> = null;
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ export default async function CustomerHomePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (customerProfile) {
|
if (customerProfile) {
|
||||||
[upcomingAppointment, loyaltyProgramme, lastCompletedAppointment] = await Promise.all([
|
[upcomingAppointment, loyalty, lastCompletedAppointment] = await Promise.all([
|
||||||
prisma.appointment.findFirst({
|
prisma.appointment.findFirst({
|
||||||
where: {
|
where: {
|
||||||
customerId: customerProfile.id,
|
customerId: customerProfile.id,
|
||||||
@@ -88,24 +88,13 @@ export default async function CustomerHomePage() {
|
|||||||
orderBy: { startAt: "asc" },
|
orderBy: { startAt: "asc" },
|
||||||
include: { services: { include: { service: true } }, staff: { include: { user: true } } },
|
include: { services: { include: { service: true } }, staff: { include: { user: true } } },
|
||||||
}),
|
}),
|
||||||
prisma.loyaltyProgramme.findUnique({ where: { businessId: business.id } }),
|
getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id }),
|
||||||
prisma.appointment.findFirst({
|
prisma.appointment.findFirst({
|
||||||
where: { customerId: customerProfile.id, status: "COMPLETED" },
|
where: { customerId: customerProfile.id, status: "COMPLETED" },
|
||||||
orderBy: { startAt: "desc" },
|
orderBy: { startAt: "desc" },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (loyaltyProgramme?.isEnabled) {
|
|
||||||
loyaltyAccount = await prisma.loyaltyAccount.findUnique({
|
|
||||||
where: {
|
|
||||||
customerId_loyaltyProgrammeId: {
|
|
||||||
customerId: customerProfile.id,
|
|
||||||
loyaltyProgrammeId: loyaltyProgramme.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customerProfile.preferredBarberId) {
|
if (customerProfile.preferredBarberId) {
|
||||||
const preferred = await prisma.staffProfile.findUnique({
|
const preferred = await prisma.staffProfile.findUnique({
|
||||||
where: { id: customerProfile.preferredBarberId },
|
where: { id: customerProfile.preferredBarberId },
|
||||||
@@ -145,7 +134,7 @@ export default async function CustomerHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-muted">· {reviews.totalReviews.toLocaleString()} reviews</span>
|
<span className="text-muted">· {reviews.totalReviews.toLocaleString()} reviews</span>
|
||||||
<span
|
<span
|
||||||
className={openingStatus.isOpen ? "text-accent font-bold" : "text-danger font-bold"}
|
className={openingStatus.isOpen ? "text-accent-foreground font-bold" : "text-danger font-bold"}
|
||||||
>
|
>
|
||||||
{openingStatus.label}
|
{openingStatus.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -191,25 +180,38 @@ export default async function CustomerHomePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loyalty summary */}
|
{/* Loyalty summary */}
|
||||||
{loyaltyProgramme?.isEnabled && loyaltyAccount && (
|
{loyalty?.isEnabled && loyalty.accountId && (
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/loyalty"
|
||||||
className="rounded-jumbo border-line mb-6 block border bg-white p-5 shadow-sm"
|
className={
|
||||||
|
loyalty.availableReward
|
||||||
|
? "rounded-jumbo border-accent-loyalty bg-accent-loyalty/10 mb-6 block border p-5 shadow-sm"
|
||||||
|
: "rounded-jumbo border-line mb-6 block border bg-white p-5 shadow-sm"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{loyalty.availableReward ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-bold">Reward available — {loyalty.availableReward.label}</p>
|
||||||
|
<span className="text-accent-loyalty-foreground text-xs font-bold uppercase tracking-widest">View</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<p className="text-sm font-bold">Loyalty Progress</p>
|
<p className="text-sm font-bold">Loyalty Progress</p>
|
||||||
<span className="text-muted text-xs">
|
<span className="text-muted text-xs">
|
||||||
{loyaltyAccount.currentStamps}/{loyaltyProgramme.stampsRequired} stamps
|
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
|
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
className="bg-accent-loyalty h-full rounded-full"
|
className="bg-accent-loyalty h-full rounded-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min(100, (loyaltyAccount.currentStamps / loyaltyProgramme.stampsRequired) * 100)}%`,
|
width: `${Math.min(100, (loyalty.currentStamps / loyalty.stampsRequired) * 100)}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
import { BottomNav, type BottomNavItem } from "@/components/navigation/bottom-nav";
|
import { BottomNav, type BottomNavItem } from "@/components/navigation/bottom-nav";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { NotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
|
|
||||||
const NAV_ITEMS: BottomNavItem[] = [
|
const NAV_ITEMS: BottomNavItem[] = [
|
||||||
{ href: "/home", label: "Home", icon: "house" },
|
{ href: "/home", label: "Home", icon: "house" },
|
||||||
@@ -11,12 +15,19 @@ const NAV_ITEMS: BottomNavItem[] = [
|
|||||||
* "For guests, show the public business experience without personalised
|
* "For guests, show the public business experience without personalised
|
||||||
* content"). /appointments and /settings each check their own session
|
* content"). /appointments and /settings each check their own session
|
||||||
* requirement and show an in-context sign-in prompt rather than bouncing
|
* requirement and show an in-context sign-in prompt rather than bouncing
|
||||||
* a guest away with no explanation — see those pages.
|
* a guest away with no explanation — see those pages. The bell itself is
|
||||||
|
* only rendered for a signed-in session — a guest has no recipientUserId,
|
||||||
|
* so there's nothing in the in-app centre for them (see
|
||||||
|
* notification.service.ts's IN_APP channel resolution).
|
||||||
*/
|
*/
|
||||||
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
|
export default async function CustomerLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const session = await auth();
|
||||||
|
const unreadNotificationCount = session?.user ? await getUnreadNotificationCount(prisma, session.user.id) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone pb-24">
|
<div className="min-h-screen bg-stone pb-24">
|
||||||
{children}
|
{session?.user && <NotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
|
<div id="main-content">{children}</div>
|
||||||
<BottomNav items={NAV_ITEMS} variant="customer" />
|
<BottomNav items={NAV_ITEMS} variant="customer" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Gift, LogIn } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
|
import { LoyaltyCard } from "@/components/ui/loyalty-card";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Loyalty" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function LoyaltyPage() {
|
||||||
|
const [business, session] = await Promise.all([getPrimaryBusiness(), auth()]);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-6 pt-12">
|
||||||
|
<h1 className="mb-6 text-2xl font-bold tracking-tight">Loyalty</h1>
|
||||||
|
<EmptyState
|
||||||
|
icon={LogIn}
|
||||||
|
title="Sign in to see your loyalty card"
|
||||||
|
description="Track your stamps and rewards across visits."
|
||||||
|
action={
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/sign-in?callbackUrl=/loyalty">Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerProfile = await prisma.customerProfile.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const loyalty = customerProfile
|
||||||
|
? await getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!customerProfile || !loyalty?.isEnabled) {
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<TopNav title="Loyalty" onBack sticky className="bg-stone" />
|
||||||
|
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||||
|
<EmptyState
|
||||||
|
icon={Gift}
|
||||||
|
title="Loyalty isn't available here yet"
|
||||||
|
description={`${business.name} hasn't turned on a loyalty programme.`}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = loyalty.accountId
|
||||||
|
? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 20 })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const expiresLabel = loyalty.availableReward?.expiresAt
|
||||||
|
? loyalty.availableReward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<TopNav title="Loyalty" onBack sticky className="bg-stone" />
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
||||||
|
<LoyaltyCard
|
||||||
|
businessName={business.name}
|
||||||
|
memberNumber={customerProfile.id.slice(-6).toUpperCase()}
|
||||||
|
stampsRequired={loyalty.stampsRequired}
|
||||||
|
stampsEarned={loyalty.currentStamps}
|
||||||
|
rewardLabel={loyalty.rewardLabel ?? "Reward"}
|
||||||
|
termsLine={loyalty.availableReward ? undefined : `${loyalty.stampsRequired - loyalty.currentStamps} more to your reward`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loyalty.availableReward ? (
|
||||||
|
<Card className="border-accent-loyalty bg-accent-loyalty/10 space-y-1 border p-5">
|
||||||
|
<p className="text-sm font-bold">Reward available: {loyalty.availableReward.label}</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
Show this to your barber at your next visit to redeem it.
|
||||||
|
{expiresLabel ? ` Expires ${expiresLabel}.` : ""}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="space-y-1 p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-bold">Progress</p>
|
||||||
|
<span className="text-muted text-xs">
|
||||||
|
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="bg-accent-loyalty h-full rounded-full"
|
||||||
|
style={{ width: `${Math.min(100, (loyalty.currentStamps / loyalty.stampsRequired) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">History</h2>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<p className="text-muted text-sm">No loyalty activity yet.</p>
|
||||||
|
) : (
|
||||||
|
<Card className="divide-line divide-y px-5">
|
||||||
|
{history.map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center justify-between py-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t.type.replace(/_/g, " ").toLowerCase()}</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
{t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}
|
||||||
|
{t.reason ? ` · ${t.reason}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold">
|
||||||
|
{t.amount > 0 ? "+" : ""}
|
||||||
|
{t.amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { listInAppNotifications } from "@/services/notifications/notification.service";
|
||||||
|
import { NotificationList } from "@/features/notifications/components/notification-list";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Notifications" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function CustomerNotificationsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-6 pt-12">
|
||||||
|
<h1 className="mb-6 text-2xl font-bold tracking-tight">Notifications</h1>
|
||||||
|
<p className="text-muted mb-4 text-sm">Sign in to see your notifications.</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/sign-in?callbackUrl=/notifications">Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = await listInAppNotifications(prisma, { userId: session.user.id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||||
|
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||||
|
<NotificationList notifications={notifications} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getNotificationPreferences } from "@/services/notifications/notification.service";
|
||||||
|
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
|
||||||
|
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
|
||||||
|
import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
|
||||||
|
import { saveCustomerNotificationPreferencesAction } from "@/features/notifications/actions/preferences";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Notifications" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
|
||||||
|
|
||||||
|
export default async function CustomerNotificationSettingsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/notifications");
|
||||||
|
|
||||||
|
const rows = await getNotificationPreferences(prisma, session.user.id);
|
||||||
|
const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({
|
||||||
|
channel,
|
||||||
|
isEnabled:
|
||||||
|
rows.find((r) => r.type === NotificationType.BOOKING_REMINDER && r.channel === channel)?.isEnabled ?? true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||||
|
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||||
|
<NotificationPreferencesForm initial={cells} action={saveCustomerNotificationPreferencesAction} />
|
||||||
|
<div className="rounded-jumbo border-line border bg-white p-5">
|
||||||
|
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
|
||||||
|
<PushSubscribeToggle />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { LogIn } from "lucide-react";
|
import { Bell, Gift, LogIn } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -7,6 +7,7 @@ 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 { 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";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Settings" };
|
export const metadata: Metadata = { title: "Settings" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -48,10 +49,24 @@ export default async function CustomerSettingsPage() {
|
|||||||
meta={customerProfile?.email ?? session.user.email}
|
meta={customerProfile?.email ?? session.user.email}
|
||||||
avatarUrl={session.user.image ?? undefined}
|
avatarUrl={session.user.image ?? undefined}
|
||||||
/>
|
/>
|
||||||
<p className="text-muted text-sm">
|
<div className="space-y-2">
|
||||||
Preferred barber, loyalty card, and notification preferences are designed in
|
<Link
|
||||||
<code> templates/10, 11, 35</code> and land in a later phase.
|
href="/loyalty"
|
||||||
</p>
|
className="border-line flex items-center gap-3 rounded-xl border bg-white px-4 py-3 text-sm font-bold"
|
||||||
|
>
|
||||||
|
<Gift className="h-4 w-4" aria-hidden="true" /> Loyalty card
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings/notifications"
|
||||||
|
className="border-line flex items-center gap-3 rounded-xl border bg-white px-4 py-3 text-sm font-bold"
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4" aria-hidden="true" /> Notifications
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="border-line rounded-jumbo border bg-white p-5">
|
||||||
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
|
<InstallAppButton />
|
||||||
|
</div>
|
||||||
<SignOutButton />
|
<SignOutButton />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ErrorState } from "@/components/feedback/error-state";
|
||||||
|
|
||||||
|
/** Keeps (owner)/layout.tsx's sidebar/bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
|
||||||
|
export default function OwnerError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return <ErrorState onRetry={reset} />;
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import { auth } from "@/auth";
|
|||||||
import { StaffShell } from "@/components/navigation/staff-shell";
|
import { StaffShell } from "@/components/navigation/staff-shell";
|
||||||
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 { prisma } from "@/lib/db/prisma";
|
||||||
import { OWNER_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
import { OWNER_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owner is a permission on a staff account, not a separate account type
|
* Owner is a permission on a staff account, not a separate account type
|
||||||
@@ -21,8 +23,15 @@ export default async function OwnerLayout({ children }: { children: React.ReactN
|
|||||||
redirect("/staff");
|
redirect("/staff");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StaffShell items={OWNER_NAV_ITEMS} footerLink={{ href: "/staff", label: "Barber Tools" }}>
|
<StaffShell
|
||||||
|
items={OWNER_NAV_ITEMS}
|
||||||
|
footerLink={{ href: "/staff", label: "Barber Tools" }}
|
||||||
|
notificationsHref="/owner/notifications"
|
||||||
|
unreadNotificationCount={unreadNotificationCount}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</StaffShell>
|
</StaffShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||||
@@ -44,10 +45,7 @@ export default async function OwnerAppointmentDetailsPage({
|
|||||||
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||||
if (!canView) redirect("/staff");
|
if (!canView) redirect("/staff");
|
||||||
|
|
||||||
const owner = await prisma.staffProfile.findUnique({
|
const owner = await getActingStaff();
|
||||||
where: { userId: session.user.id },
|
|
||||||
include: { business: { select: { id: true, timezone: true } } },
|
|
||||||
});
|
|
||||||
if (!owner) redirect("/staff");
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AppointmentCard } from "@/components/ui/appointment-card";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import {
|
import {
|
||||||
calendarDateToUtcMidnight,
|
calendarDateToUtcMidnight,
|
||||||
minutesFromMidnight,
|
minutesFromMidnight,
|
||||||
@@ -60,10 +61,7 @@ export default async function OwnerCalendarPage({
|
|||||||
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||||
if (!canView) redirect("/staff");
|
if (!canView) redirect("/staff");
|
||||||
|
|
||||||
const owner = await prisma.staffProfile.findUnique({
|
const owner = await getActingStaff();
|
||||||
where: { userId: session.user.id },
|
|
||||||
include: { business: { select: { id: true, name: true, timezone: true } } },
|
|
||||||
});
|
|
||||||
if (!owner) redirect("/staff");
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
|
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
|
import { listInAppNotifications } from "@/services/notifications/notification.service";
|
||||||
|
import { NotificationList } from "@/features/notifications/components/notification-list";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Notifications" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Access to this route is already gated by (owner)/layout.tsx's MANAGE_STAFF check — this page only fetches this owner's own notifications, same as the staff/customer variants. */
|
||||||
|
export default async function OwnerNotificationsPage() {
|
||||||
|
const staff = await getActingStaff();
|
||||||
|
if (!staff) redirect("/home");
|
||||||
|
|
||||||
|
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||||
|
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||||
|
<NotificationList notifications={notifications} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { todayIn } from "@/lib/time/business-time";
|
import { todayIn } from "@/lib/time/business-time";
|
||||||
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
|
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
|
||||||
|
|
||||||
@@ -43,10 +44,7 @@ export default async function OwnerDashboardPage() {
|
|||||||
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
|
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
|
||||||
if (!canView) redirect("/staff");
|
if (!canView) redirect("/staff");
|
||||||
|
|
||||||
const staff = await prisma.staffProfile.findUnique({
|
const staff = await getActingStaff();
|
||||||
where: { userId: session.user.id },
|
|
||||||
include: { business: { select: { id: true, name: true, timezone: true } } },
|
|
||||||
});
|
|
||||||
if (!staff) redirect("/staff");
|
if (!staff) redirect("/staff");
|
||||||
|
|
||||||
const today = todayIn(staff.business.timezone);
|
const today = todayIn(staff.business.timezone);
|
||||||
@@ -171,6 +169,12 @@ export default async function OwnerDashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
href="/owner/reports"
|
||||||
|
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
||||||
|
>
|
||||||
|
View Reports
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/owner/team"
|
href="/owner/team"
|
||||||
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
|
import { addDays, endOfBusinessDay, startOfBusinessDay, todayIn } from "@/lib/time/business-time";
|
||||||
|
import type { CalendarDate } from "@/lib/time/business-time";
|
||||||
|
import {
|
||||||
|
getAppointmentReport,
|
||||||
|
getCustomerReport,
|
||||||
|
getLoyaltyReport,
|
||||||
|
getNotificationReport,
|
||||||
|
getServiceReport,
|
||||||
|
getStaffReport,
|
||||||
|
} from "@/services/reporting/reports.service";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Reports" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ label: "7 days", days: 7 },
|
||||||
|
{ label: "30 days", days: 30 },
|
||||||
|
{ label: "90 days", days: 90 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function StatTile({ label, value, hint }: { label: string; value: string | number; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="p-5">
|
||||||
|
<p className="text-muted mb-2 text-[10px] font-bold tracking-widest uppercase">{label}</p>
|
||||||
|
<p className="text-2xl font-bold">{value}</p>
|
||||||
|
{hint && <p className="text-muted mt-1 text-xs">{hint}</p>}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OwnerReportsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ from?: string; to?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/sign-in?callbackUrl=/owner/reports");
|
||||||
|
|
||||||
|
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
|
||||||
|
if (!canView) redirect("/staff");
|
||||||
|
|
||||||
|
const staff = await getActingStaff();
|
||||||
|
if (!staff) redirect("/staff");
|
||||||
|
|
||||||
|
const tz = staff.business.timezone;
|
||||||
|
const today = todayIn(tz);
|
||||||
|
const { from: fromParam, to: toParam } = await searchParams;
|
||||||
|
const toDate: CalendarDate = toParam ?? today;
|
||||||
|
const fromDate: CalendarDate = fromParam ?? addDays(toDate, -29);
|
||||||
|
|
||||||
|
const range = { from: startOfBusinessDay(fromDate, tz), to: endOfBusinessDay(toDate, tz) };
|
||||||
|
|
||||||
|
const [appointmentReport, serviceReport, staffReport, customerReport, loyaltyReport, notificationReport] =
|
||||||
|
await Promise.all([
|
||||||
|
getAppointmentReport(prisma, { businessId: staff.businessId, range }),
|
||||||
|
getServiceReport(prisma, { businessId: staff.businessId, range }),
|
||||||
|
getStaffReport(prisma, { businessId: staff.businessId, range }),
|
||||||
|
getCustomerReport(prisma, { businessId: staff.businessId, range }),
|
||||||
|
getLoyaltyReport(prisma, { businessId: staff.businessId, range }),
|
||||||
|
getNotificationReport(prisma, { businessId: staff.businessId, range }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Reports</h1>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
{fromDate} to {toDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRESETS.map((preset) => (
|
||||||
|
<Link
|
||||||
|
key={preset.days}
|
||||||
|
href={`/owner/reports?from=${addDays(today, -(preset.days - 1))}&to=${today}`}
|
||||||
|
className="border-line rounded-full border bg-white px-4 py-2 text-xs font-bold"
|
||||||
|
>
|
||||||
|
Last {preset.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Appointments</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<StatTile label="Total" value={appointmentReport.totalAppointments} />
|
||||||
|
<StatTile label="Completed" value={appointmentReport.completed} hint={`${appointmentReport.completionRate}% of total`} />
|
||||||
|
<StatTile label="Cancelled" value={appointmentReport.cancelled} hint={`${appointmentReport.cancellationRate}% of total`} />
|
||||||
|
<StatTile label="No-shows" value={appointmentReport.noShow} hint={`${appointmentReport.noShowRate}% of total`} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<StatTile label="Gross revenue" value={`£${appointmentReport.grossRevenue}`} />
|
||||||
|
<StatTile label="Discounts given" value={`£${appointmentReport.discountsGiven}`} />
|
||||||
|
<StatTile label="Net revenue" value={`£${appointmentReport.netRevenue}`} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Services</h2>
|
||||||
|
<Card className="divide-line divide-y px-5">
|
||||||
|
{serviceReport.length === 0 ? (
|
||||||
|
<p className="text-muted py-4 text-sm">No completed appointments in this range.</p>
|
||||||
|
) : (
|
||||||
|
serviceReport.map((s) => (
|
||||||
|
<div key={s.serviceId} className="flex items-center justify-between py-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">{s.name}</p>
|
||||||
|
<p className="text-muted text-xs">{s.timesCompleted} completed</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold">£{s.revenue}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Staff</h2>
|
||||||
|
<Card className="divide-line divide-y px-5">
|
||||||
|
{staffReport.length === 0 ? (
|
||||||
|
<p className="text-muted py-4 text-sm">No appointments in this range.</p>
|
||||||
|
) : (
|
||||||
|
staffReport.map((s) => (
|
||||||
|
<div key={s.staffId} className="flex items-center justify-between py-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">{s.name}</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
{s.completed} completed · {s.cancelled} cancelled · {s.noShow} no-show
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold">£{s.revenue}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Customers</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<StatTile label="New customers" value={customerReport.newCustomers} />
|
||||||
|
<StatTile label="Returning customers" value={customerReport.returningCustomers} />
|
||||||
|
</div>
|
||||||
|
{customerReport.topCustomers.length > 0 && (
|
||||||
|
<Card className="divide-line divide-y px-5">
|
||||||
|
{customerReport.topCustomers.map((c) => (
|
||||||
|
<div key={c.customerId} className="flex items-center justify-between py-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">{c.name}</p>
|
||||||
|
<p className="text-muted text-xs">{c.visits} visit{c.visits === 1 ? "" : "s"}</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold">£{c.spend}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Loyalty</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<StatTile label="Stamps issued" value={loyaltyReport.stampsIssued} />
|
||||||
|
<StatTile label="Stamps removed" value={loyaltyReport.stampsRemoved} />
|
||||||
|
<StatTile label="Rewards earned" value={loyaltyReport.rewardsEarned} />
|
||||||
|
<StatTile label="Rewards redeemed" value={loyaltyReport.rewardsRedeemed} />
|
||||||
|
</div>
|
||||||
|
{loyaltyReport.rewardsExpired > 0 && <StatTile label="Rewards expired" value={loyaltyReport.rewardsExpired} />}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Notifications</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<StatTile label="Sent" value={notificationReport.totalSent} />
|
||||||
|
<StatTile label="Failed" value={notificationReport.totalFailed} />
|
||||||
|
</div>
|
||||||
|
{notificationReport.byChannel.length > 0 && (
|
||||||
|
<Card className="divide-line divide-y px-5">
|
||||||
|
{notificationReport.byChannel.map((c) => (
|
||||||
|
<div key={c.channel} className="flex items-center justify-between py-3 text-sm">
|
||||||
|
<span className="font-bold">{c.channel}</span>
|
||||||
|
<span className="text-muted">
|
||||||
|
{c.sent} sent{c.failed > 0 ? ` · ${c.failed} failed` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { auth } from "@/auth";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { listCategories } from "@/services/catalogue/service-catalogue.service";
|
import { listCategories } from "@/services/catalogue/service-catalogue.service";
|
||||||
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
|
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ export default async function OwnerServicesPage() {
|
|||||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
|
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
|
||||||
if (!canManage) redirect("/staff");
|
if (!canManage) redirect("/staff");
|
||||||
|
|
||||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
const owner = await getActingStaff();
|
||||||
if (!owner) redirect("/staff");
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
const categories = await listCategories(prisma, owner.businessId);
|
const categories = await listCategories(prisma, owner.businessId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
|
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
|
||||||
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
|
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export default async function OwnerBookingRulesPage() {
|
|||||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
|
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
|
||||||
if (!canManage) redirect("/staff");
|
if (!canManage) redirect("/staff");
|
||||||
|
|
||||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
const owner = await getActingStaff();
|
||||||
if (!owner) redirect("/staff");
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
const rule = await getBookingRule(prisma, owner.businessId);
|
const rule = await getBookingRule(prisma, owner.businessId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { getBusinessProfile } from "@/services/business/business.service";
|
import { getBusinessProfile } from "@/services/business/business.service";
|
||||||
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
|
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export default async function OwnerBusinessDetailsPage() {
|
|||||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||||
if (!canManage) redirect("/staff");
|
if (!canManage) redirect("/staff");
|
||||||
|
|
||||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
const staff = await getActingStaff();
|
||||||
if (!staff) redirect("/staff");
|
if (!staff) redirect("/staff");
|
||||||
|
|
||||||
const business = await getBusinessProfile(prisma, staff.businessId);
|
const business = await getBusinessProfile(prisma, staff.businessId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { getOpeningHours } from "@/services/business/business.service";
|
import { getOpeningHours } from "@/services/business/business.service";
|
||||||
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
|
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export default async function OwnerOpeningHoursPage() {
|
|||||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||||
if (!canManage) redirect("/staff");
|
if (!canManage) redirect("/staff");
|
||||||
|
|
||||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
const staff = await getActingStaff();
|
||||||
if (!staff) redirect("/staff");
|
if (!staff) redirect("/staff");
|
||||||
|
|
||||||
const hours = await getOpeningHours(prisma, staff.businessId);
|
const hours = await getOpeningHours(prisma, staff.businessId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
||||||
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
|
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export default async function OwnerLoyaltyConfigPage() {
|
|||||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
|
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
|
||||||
if (!canManage) redirect("/staff");
|
if (!canManage) redirect("/staff");
|
||||||
|
|
||||||
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
const owner = await getActingStaff();
|
||||||
if (!owner) redirect("/staff");
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
const [programme, services, staff] = await Promise.all([
|
const [programme, services, staff] = await Promise.all([
|
||||||
|
|||||||
@@ -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 { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Owner Settings" };
|
export const metadata: Metadata = { title: "Owner Settings" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -50,6 +51,11 @@ export default async function OwnerSettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div className="rounded-jumbo border-line border bg-white p-5">
|
||||||
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
|
<InstallAppButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
<SignOutButton />
|
<SignOutButton />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
|
import { listBreaks } from "@/services/staff/staff.service";
|
||||||
|
import { BreaksList } from "@/features/staff/components/breaks-list";
|
||||||
|
import { createOwnerBreakActionSet } from "@/features/owner/actions/break-action-set";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Staff Breaks" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function OwnerStaffBreaksPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/sign-in");
|
||||||
|
|
||||||
|
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||||
|
if (!canManage) redirect("/staff");
|
||||||
|
|
||||||
|
const owner = await getActingStaff();
|
||||||
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const target = await prisma.staffProfile.findFirst({
|
||||||
|
where: { id, businessId: owner.businessId },
|
||||||
|
include: { user: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!target) notFound();
|
||||||
|
|
||||||
|
const breaks = await listBreaks(prisma, id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<TopNav title={`${target.user.name ?? "Staff"} — Breaks`} onBack sticky className="bg-stone" />
|
||||||
|
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||||
|
<BreaksList
|
||||||
|
breaks={breaks.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
label: b.label,
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
days: b.days.map((d) => d.dayOfWeek),
|
||||||
|
}))}
|
||||||
|
actions={createOwnerBreakActionSet(id)}
|
||||||
|
appointmentHrefBase="/owner/appointments"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { hasPermission } from "@/lib/auth/permissions";
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
import { Permission, Weekday } from "@/generated/prisma/enums";
|
import { Permission, Weekday } from "@/generated/prisma/enums";
|
||||||
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { getWorkingHours } from "@/services/staff/staff.service";
|
import { getWorkingHours } from "@/services/staff/staff.service";
|
||||||
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
|
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
|
||||||
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
|
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
|
||||||
@@ -35,7 +36,7 @@ export default async function OwnerStaffWorkingHoursPage({ params }: { params: P
|
|||||||
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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
const owner = await getActingStaff();
|
||||||
if (!owner) redirect("/staff");
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -65,10 +66,17 @@ export default async function OwnerStaffWorkingHoursPage({ params }: { params: P
|
|||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
<TopNav title={`${target.user.name ?? "Staff"} — Working Hours`} onBack sticky className="bg-stone" />
|
<TopNav title={`${target.user.name ?? "Staff"} — Working Hours`} onBack sticky className="bg-stone" />
|
||||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||||
|
{/* .bind(null, id), not an arrow-function wrapper — a Client Component can only
|
||||||
|
receive an actual Server Action reference as a prop. An inline arrow closure
|
||||||
|
here throws "Event handlers cannot be passed to Client Component props" at
|
||||||
|
render time (found empirically in Phase 7 — this page never actually worked
|
||||||
|
until this fix; see docs/phase-7-summary.md and
|
||||||
|
src/features/owner/actions/break-action-set.ts for the same issue). */}
|
||||||
<WorkingHoursForm
|
<WorkingHoursForm
|
||||||
initialRows={workingHours}
|
initialRows={workingHours}
|
||||||
businessHoursLabel={businessHoursLabel}
|
businessHoursLabel={businessHoursLabel}
|
||||||
onSave={(days) => updateStaffWorkingHoursAction(id, days)}
|
onSave={updateStaffWorkingHoursAction.bind(null, id)}
|
||||||
|
appointmentHrefBase="/owner/appointments"
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Mail, Phone } from "lucide-react";
|
import { Mail, Phone } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
@@ -7,6 +8,7 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
|
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
|
||||||
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
|
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
|
||||||
|
|
||||||
@@ -20,7 +22,7 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
|
|||||||
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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
const owner = await getActingStaff();
|
||||||
if (!owner) redirect("/staff");
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -38,8 +40,13 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="flex flex-col items-center p-6 text-center">
|
<Card className="flex flex-col items-center p-6 text-center">
|
||||||
{target.user.image ? (
|
{target.user.image ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- external avatar URL, no next/image domain config for it
|
<Image
|
||||||
<img src={target.user.image} alt="" className="border-stone mb-4 h-24 w-24 rounded-full border-4 object-cover" />
|
src={target.user.image}
|
||||||
|
alt=""
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
className="border-stone mb-4 h-24 w-24 rounded-full border-4 object-cover"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-stone text-muted mb-4 flex h-24 w-24 items-center justify-center rounded-full text-2xl font-bold">
|
<div className="bg-stone text-muted mb-4 flex h-24 w-24 items-center justify-center rounded-full text-2xl font-bold">
|
||||||
{(target.user.name ?? "?").charAt(0)}
|
{(target.user.name ?? "?").charAt(0)}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
|
import { listTimeOff } from "@/services/staff/staff.service";
|
||||||
|
import { TimeOffList } from "@/features/staff/components/time-off-list";
|
||||||
|
import { createOwnerTimeOffActionSet } from "@/features/owner/actions/time-off-action-set";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Staff Time Off" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function toCalendarDate(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OwnerStaffTimeOffPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/sign-in");
|
||||||
|
|
||||||
|
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||||
|
if (!canManage) redirect("/staff");
|
||||||
|
|
||||||
|
const owner = await getActingStaff();
|
||||||
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const target = await prisma.staffProfile.findFirst({
|
||||||
|
where: { id, businessId: owner.businessId },
|
||||||
|
include: { user: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!target) notFound();
|
||||||
|
|
||||||
|
const { upcoming, past } = await listTimeOff(prisma, id);
|
||||||
|
// Whole-day rows only — partial-day "Block Time" entries are managed from the owner calendar (see owner-block-time-button.tsx).
|
||||||
|
const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null);
|
||||||
|
const toEditable = (rows: typeof upcoming) =>
|
||||||
|
wholeDay(rows).map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
startDate: toCalendarDate(t.startDate),
|
||||||
|
endDate: toCalendarDate(t.endDate),
|
||||||
|
reason: t.reason,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<TopNav title={`${target.user.name ?? "Staff"} — Time Off`} onBack sticky className="bg-stone" />
|
||||||
|
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||||
|
<TimeOffList
|
||||||
|
upcoming={toEditable(upcoming)}
|
||||||
|
past={toEditable(past)}
|
||||||
|
actions={createOwnerTimeOffActionSet(id)}
|
||||||
|
appointmentHrefBase="/owner/appointments"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { listRolesForBusiness } from "@/services/staff/staff.service";
|
import { listRolesForBusiness } from "@/services/staff/staff.service";
|
||||||
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
|
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export default async function OwnerAddStaffPage() {
|
|||||||
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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
const owner = await getActingStaff();
|
||||||
if (!owner) redirect("/staff");
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
const [roles, services] = await Promise.all([
|
const [roles, services] = await Promise.all([
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { auth } from "@/auth";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { listStaffForBusiness } from "@/services/staff/staff.service";
|
import { listStaffForBusiness } from "@/services/staff/staff.service";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ export default async function OwnerTeamPage({
|
|||||||
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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
const owner = await getActingStaff();
|
||||||
if (!owner) redirect("/staff");
|
if (!owner) redirect("/staff");
|
||||||
|
|
||||||
const { q = "" } = await searchParams;
|
const { q = "" } = await searchParams;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ErrorState } from "@/components/feedback/error-state";
|
||||||
|
|
||||||
|
/** Keeps (staff)/layout.tsx's sidebar/bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
|
||||||
|
export default function StaffError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return <ErrorState onRetry={reset} />;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { StaffShell } from "@/components/navigation/staff-shell";
|
import { StaffShell } from "@/components/navigation/staff-shell";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
|
|
||||||
export default async function StaffLayout({ children }: { children: React.ReactNode }) {
|
export default async function StaffLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -18,5 +19,11 @@ export default async function StaffLayout({ children }: { children: React.ReactN
|
|||||||
redirect("/home");
|
redirect("/home");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <StaffShell>{children}</StaffShell>;
|
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StaffShell notificationsHref="/staff/notifications" unreadNotificationCount={unreadNotificationCount}>
|
||||||
|
{children}
|
||||||
|
</StaffShell>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -163,14 +163,26 @@ export default async function StaffAppointmentDetailsPage({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
Duration / Price
|
Duration
|
||||||
</span>
|
</span>
|
||||||
<p className="font-bold">
|
<p className="font-bold">{totalDuration} min</p>
|
||||||
{totalDuration} min · £{totalPrice.toFixed(2)}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-line border-t pt-4 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted">Subtotal</span>
|
||||||
|
<span className="font-bold">£{totalPrice.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
{discount > 0 && (
|
{discount > 0 && (
|
||||||
<span className="text-accent-loyalty"> (-£{discount.toFixed(2)} reward)</span>
|
<div className="text-accent-loyalty flex items-center justify-between">
|
||||||
|
<span>Loyalty reward</span>
|
||||||
|
<span className="font-bold">-£{discount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</p>
|
<div className="border-line mt-1 flex items-center justify-between border-t pt-1">
|
||||||
|
<span className="font-bold">Total</span>
|
||||||
|
<span className="font-bold">£{(totalPrice - discount).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
<li key={t.id} className="text-muted text-xs">
|
<li key={t.id} className="text-muted text-xs">
|
||||||
{t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}{" "}
|
{t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}{" "}
|
||||||
— {t.amount > 0 ? "+" : ""}
|
— {t.amount > 0 ? "+" : ""}
|
||||||
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"}
|
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
|
||||||
{t.reason ? ` (${t.reason})` : ""}
|
{t.reason ? ` (${t.reason})` : ""}
|
||||||
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
|
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -26,11 +26,25 @@ export default async function StaffClientsPage({
|
|||||||
|
|
||||||
// Scoped via the appointment relation, not "every CustomerProfile row" —
|
// Scoped via the appointment relation, not "every CustomerProfile row" —
|
||||||
// a real business boundary even though this is a single-business v1 app
|
// a real business boundary even though this is a single-business v1 app
|
||||||
// (see docs/phase-5-summary.md).
|
// (see docs/phase-5-summary.md). `select`-scoped rather than the full
|
||||||
|
// row + full appointment `include` this used to fetch — the card only
|
||||||
|
// ever reads status/startAt off each appointment, never the service
|
||||||
|
// lines/notes/discount that a full include would also pull down for
|
||||||
|
// every appointment of every client on every page view (see the Phase 7
|
||||||
|
// performance pass).
|
||||||
const customers = await prisma.customerProfile.findMany({
|
const customers = await prisma.customerProfile.findMany({
|
||||||
where: { appointments: { some: { businessId: staff.businessId } } },
|
where: { appointments: { some: { businessId: staff.businessId } } },
|
||||||
include: {
|
select: {
|
||||||
appointments: { where: { businessId: staff.businessId }, orderBy: { startAt: "desc" } },
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
appointments: {
|
||||||
|
where: { businessId: staff.businessId },
|
||||||
|
orderBy: { startAt: "desc" },
|
||||||
|
select: { status: true, startAt: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
|
import { listInAppNotifications } from "@/services/notifications/notification.service";
|
||||||
|
import { NotificationList } from "@/features/notifications/components/notification-list";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Notifications" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function StaffNotificationsPage() {
|
||||||
|
const staff = await getActingStaff();
|
||||||
|
if (!staff) redirect("/home");
|
||||||
|
|
||||||
|
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||||
|
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||||
|
<NotificationList notifications={notifications} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,41 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
|
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { getNotificationPreferences } from "@/services/staff/staff.service";
|
import { getNotificationPreferences } from "@/services/notifications/notification.service";
|
||||||
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
|
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
|
||||||
|
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
|
||||||
|
import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
|
||||||
|
import { saveNotificationPreferencesAction } from "@/features/staff/actions/profile";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Notifications" };
|
export const metadata: Metadata = { title: "Notifications" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
|
||||||
|
|
||||||
export default async function StaffNotificationsPage() {
|
export default async function StaffNotificationsPage() {
|
||||||
const staff = await getActingStaff();
|
const staff = await getActingStaff();
|
||||||
if (!staff) redirect("/home");
|
if (!staff) redirect("/home");
|
||||||
|
|
||||||
const cells = await getNotificationPreferences(prisma, staff.userId);
|
const rows = await getNotificationPreferences(prisma, staff.userId);
|
||||||
|
const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({
|
||||||
|
channel,
|
||||||
|
isEnabled:
|
||||||
|
rows.find((r) => r.type === NotificationType.STAFF_APPOINTMENT_REMINDER && r.channel === channel)
|
||||||
|
?.isEnabled ?? true,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||||
<NotificationPreferencesForm initial={cells} />
|
<NotificationPreferencesForm initial={cells} action={saveNotificationPreferencesAction} />
|
||||||
|
<div className="rounded-jumbo border-line border bg-white p-5">
|
||||||
|
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
|
||||||
|
<PushSubscribeToggle />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,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 { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||||
|
|
||||||
const SETTINGS_LINKS = [
|
const SETTINGS_LINKS = [
|
||||||
{ href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock },
|
{ href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock },
|
||||||
@@ -52,6 +53,11 @@ export default async function StaffSettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div className="rounded-jumbo border border-line bg-white p-5">
|
||||||
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
|
<InstallAppButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
<SignOutButton />
|
<SignOutButton />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
|||||||
* response"). Still calls straight into AppointmentService/ics.ts —
|
* response"). Still calls straight into AppointmentService/ics.ts —
|
||||||
* no booking logic lives here.
|
* no booking logic lives here.
|
||||||
*/
|
*/
|
||||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const token = new URL(request.url).searchParams.get("token") ?? undefined;
|
||||||
|
|
||||||
const appointment = await getAppointmentById(prisma, id);
|
const appointment = await getAppointmentById(prisma, id);
|
||||||
if (!appointment) {
|
if (!appointment) {
|
||||||
@@ -23,7 +24,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
try {
|
try {
|
||||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
await assertCanAccessAppointment(prisma, appointment, { sessionUserId: session?.user?.id, guestToken: token });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { expireOverdueRewards } from "@/services/loyalty/loyalty.service";
|
||||||
|
import { getEmailProvider, getPushProvider } from "@/services/notifications/providers";
|
||||||
|
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 —
|
||||||
|
* see reminder-worker.ts for why this exists instead of an in-process
|
||||||
|
* timer. Not a page a person visits; a scheduler (e.g. a hosting
|
||||||
|
* provider's cron/scheduled-function feature, or any external cron
|
||||||
|
* hitting this URL with the right header) is expected to POST here on a
|
||||||
|
* fixed interval.
|
||||||
|
*
|
||||||
|
* Recommended frequency: every 1–2 minutes. Reminders are scheduled to
|
||||||
|
* the minute (see reminder-scheduling.ts) but this app has no promise of
|
||||||
|
* sub-minute delivery — a 1–2 minute poll interval is a reasonable
|
||||||
|
* trade-off between timeliness and load. Every call is safe to run
|
||||||
|
* concurrently with another still-in-flight call (or with itself on a
|
||||||
|
* retry) — see processDueNotificationDeliveries' per-row conditional
|
||||||
|
* claim; overlapping calls just mean some rows get skippedAlreadyClaimed,
|
||||||
|
* not double-sent.
|
||||||
|
*
|
||||||
|
* Auth: a shared-secret bearer token (CRON_SECRET), not a user session —
|
||||||
|
* this is a machine-to-machine endpoint. Returns 503 (not 401) if
|
||||||
|
* CRON_SECRET was never configured at all, so a misconfigured deployment
|
||||||
|
* fails loudly in monitoring rather than silently accepting an empty
|
||||||
|
* expected secret.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const secret = process.env.CRON_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return NextResponse.json({ error: "CRON_SECRET is not configured on this deployment." }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provided = request.headers.get("authorization");
|
||||||
|
if (!provided || !secretsMatch(provided, `Bearer ${secret}`)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await processDueNotificationDeliveries(prisma, {
|
||||||
|
emailProvider: getEmailProvider(),
|
||||||
|
pushProvider: getPushProvider(),
|
||||||
|
});
|
||||||
|
// Piggybacks the loyalty reward expiry sweep on the same scheduled trigger
|
||||||
|
// rather than standing up a second cron endpoint for one small job.
|
||||||
|
await expireOverdueRewards(prisma);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -9,5 +9,9 @@
|
|||||||
* the bottom navigation" allowance.
|
* the bottom navigation" allowance.
|
||||||
*/
|
*/
|
||||||
export default function RescheduleLayout({ children }: { children: React.ReactNode }) {
|
export default function RescheduleLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="min-h-screen bg-stone">{children}</div>;
|
return (
|
||||||
|
<div id="main-content" className="min-h-screen bg-stone">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -15,21 +15,28 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
|
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
|
||||||
|
|
||||||
export default async function RescheduleAppointmentPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function RescheduleAppointmentPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ token?: string }>;
|
||||||
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const { token } = await searchParams;
|
||||||
const appointment = await getAppointmentById(prisma, id);
|
const appointment = await getAppointmentById(prisma, id);
|
||||||
if (!appointment) notFound();
|
if (!appointment) notFound();
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
try {
|
try {
|
||||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
await assertCanAccessAppointment(prisma, appointment, { sessionUserId: session?.user?.id, guestToken: token });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) notFound();
|
if (error instanceof PermissionDeniedError) notFound();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (NON_RESCHEDULABLE.has(appointment.status)) {
|
if (NON_RESCHEDULABLE.has(appointment.status)) {
|
||||||
redirect(`/appointments/${id}`);
|
redirect(`/appointments/${id}${token ? `?token=${token}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const business = await getPrimaryBusiness();
|
const business = await getPrimaryBusiness();
|
||||||
@@ -40,9 +47,10 @@ export default async function RescheduleAppointmentPage({ params }: { params: Pr
|
|||||||
appointmentId: id,
|
appointmentId: id,
|
||||||
staffId: selection.staffId,
|
staffId: selection.staffId,
|
||||||
startAt: selection.startAt,
|
startAt: selection.startAt,
|
||||||
|
guestToken: token,
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
redirect(`/appointments/${id}`);
|
redirect(`/appointments/${id}${token ? `?token=${token}` : ""}`);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ const STATUS_MAP: Record<string, AppointmentStatus> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function BookConfirmationPage() {
|
export default async function BookConfirmationPage() {
|
||||||
const appointmentId = await readLastBookingReference();
|
const reference = await readLastBookingReference();
|
||||||
if (!appointmentId) redirect("/home");
|
if (!reference) redirect("/home");
|
||||||
|
|
||||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
const appointment = await getAppointmentById(prisma, reference.appointmentId);
|
||||||
if (!appointment) redirect("/home");
|
if (!appointment) redirect("/home");
|
||||||
|
|
||||||
const business = await getPrimaryBusiness();
|
const business = await getPrimaryBusiness();
|
||||||
@@ -81,7 +81,7 @@ export default async function BookConfirmationPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
href={`/api/appointments/${appointment.id}/calendar.ics${reference.guestAccessToken ? `?token=${reference.guestAccessToken}` : ""}`}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-jumbo border border-line bg-white py-4 text-sm font-bold"
|
className="flex w-full items-center justify-center gap-2 rounded-jumbo border border-line bg-white py-4 text-sm font-bold"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="h-4 w-4 text-accent" aria-hidden="true" /> Add to Calendar
|
<CalendarPlus className="h-4 w-4 text-accent" aria-hidden="true" /> Add to Calendar
|
||||||
@@ -89,11 +89,18 @@ export default async function BookConfirmationPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="mt-8 w-full max-w-md space-y-3">
|
<div className="mt-8 w-full max-w-md space-y-3">
|
||||||
{session?.user ? (
|
|
||||||
<Button asChild variant="secondary" className="w-full">
|
<Button asChild variant="secondary" className="w-full">
|
||||||
<Link href="/appointments">View Appointment</Link>
|
<Link
|
||||||
|
href={
|
||||||
|
session?.user
|
||||||
|
? "/appointments"
|
||||||
|
: `/appointments/${appointment.id}${reference.guestAccessToken ? `?token=${reference.guestAccessToken}` : ""}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View Appointment
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
{!session?.user && (
|
||||||
<Card className="space-y-3 p-5 text-center">
|
<Card className="space-y-3 p-5 text-center">
|
||||||
<p className="text-sm font-bold">Want to manage this booking later?</p>
|
<p className="text-sm font-bold">Want to manage this booking later?</p>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
|
|||||||
@@ -8,5 +8,9 @@
|
|||||||
* improves focus").
|
* improves focus").
|
||||||
*/
|
*/
|
||||||
export default function BookLayout({ children }: { children: React.ReactNode }) {
|
export default function BookLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="min-h-screen bg-stone">{children}</div>;
|
return (
|
||||||
|
<div id="main-content" className="min-h-screen bg-stone">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Card } from "@/components/ui/card";
|
|||||||
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";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
import { ConfirmBookingButton } from "@/features/booking/components/confirm-booking-button";
|
import { ConfirmBookingButton } from "@/features/booking/components/confirm-booking-button";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Review Booking" };
|
export const metadata: Metadata = { title: "Review Booking" };
|
||||||
@@ -41,6 +42,10 @@ export default async function BookReviewPage() {
|
|||||||
const startAt = new Date(draft.startAt);
|
const startAt = new Date(draft.startAt);
|
||||||
const endAt = new Date(startAt.getTime() + totalDuration * 60_000);
|
const endAt = new Date(startAt.getTime() + totalDuration * 60_000);
|
||||||
|
|
||||||
|
const loyalty = customerProfile
|
||||||
|
? await getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id })
|
||||||
|
: null;
|
||||||
|
|
||||||
const customerName = draft.guest
|
const customerName = draft.guest
|
||||||
? `${draft.guest.firstName} ${draft.guest.lastName}`
|
? `${draft.guest.firstName} ${draft.guest.lastName}`
|
||||||
: `${customerProfile?.firstName ?? session?.user?.name ?? ""} ${customerProfile?.lastName ?? ""}`.trim();
|
: `${customerProfile?.firstName ?? session?.user?.name ?? ""} ${customerProfile?.lastName ?? ""}`.trim();
|
||||||
@@ -169,6 +174,15 @@ export default async function BookReviewPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loyalty?.availableReward && (
|
||||||
|
<div className="border-line bg-accent-loyalty/10 rounded-lg border-t p-4">
|
||||||
|
<p className="text-sm font-bold">You have a reward available</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
Mention it to your barber at check-in — {loyalty.availableReward.label}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{draft.notes && (
|
{draft.notes && (
|
||||||
<div className="border-line border-t pt-6">
|
<div className="border-line border-t pt-6">
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ErrorState } from "@/components/feedback/error-state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root error boundary — catches anything that escapes a more specific
|
||||||
|
* one (see (customer)/error.tsx, (staff)/error.tsx, (owner)/error.tsx),
|
||||||
|
* so a crash anywhere in the app shows this app-styled fallback instead
|
||||||
|
* of Next's default error page. `reset` re-renders the segment that
|
||||||
|
* threw, which is enough to recover from a transient failure (a flaky
|
||||||
|
* fetch, a stale cache) without a full page reload.
|
||||||
|
*/
|
||||||
|
export default function RootError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="bg-stone font-sans">
|
||||||
|
<div id="main-content">
|
||||||
|
<ErrorState onRetry={reset} />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-1
@@ -13,10 +13,22 @@
|
|||||||
--color-ink: #111b15;
|
--color-ink: #111b15;
|
||||||
--color-stone: #f4f6f4;
|
--color-stone: #f4f6f4;
|
||||||
--color-surface: #ffffff;
|
--color-surface: #ffffff;
|
||||||
--color-muted: #6c7870;
|
/* Darkened from the templates' #6c7870 — that value was ~4.2:1 on
|
||||||
|
--color-stone (this app's default body background), just under WCAG
|
||||||
|
AA's 4.5:1 for normal text, and text-muted is used 300+ times across
|
||||||
|
the app for secondary/caption copy. See docs/implementation-plan.md
|
||||||
|
§8 and the Phase 7 accessibility pass. */
|
||||||
|
--color-muted: #5c6961;
|
||||||
--color-line: #e1e7e2;
|
--color-line: #e1e7e2;
|
||||||
--color-accent: #10b981;
|
--color-accent: #10b981;
|
||||||
--color-accent-loyalty: #f59e0b;
|
--color-accent-loyalty: #f59e0b;
|
||||||
|
/* Darker text-only variants — --color-accent/--color-accent-loyalty are
|
||||||
|
correct as-is for backgrounds (buttons, badges, where a light/white
|
||||||
|
foreground sits on top) but fail WCAG AA (~2.5:1, ~2.2:1) when used
|
||||||
|
directly as text on a white/stone background, which several
|
||||||
|
"Saved." confirmation messages and status labels do. */
|
||||||
|
--color-accent-foreground: #047857;
|
||||||
|
--color-accent-loyalty-foreground: #92400e;
|
||||||
|
|
||||||
/* Generic UI-state colors — destructive actions, form-validation errors,
|
/* Generic UI-state colors — destructive actions, form-validation errors,
|
||||||
and warning notices. Deliberately separate variables from the
|
and warning notices. Deliberately separate variables from the
|
||||||
@@ -35,6 +47,17 @@
|
|||||||
--color-status-cancelled: #ef4444;
|
--color-status-cancelled: #ef4444;
|
||||||
--color-status-completed: #6c7870;
|
--color-status-completed: #6c7870;
|
||||||
|
|
||||||
|
/* Darker text-only variants of the status colors above — the base
|
||||||
|
colors are correct for backgrounds/dots/borders (where they're paired
|
||||||
|
with a light tint, not read as text), but at full saturation several
|
||||||
|
of them fail WCAG AA (~2.5:1) as *text* on the near-white tinted pill
|
||||||
|
background StatusPill uses them on. Only status-progress's base color
|
||||||
|
already passes AA as text, so it has no separate foreground variant.
|
||||||
|
See docs/implementation-plan.md §8 and the Phase 7 accessibility pass. */
|
||||||
|
--color-status-confirmed-foreground: #047857;
|
||||||
|
--color-status-pending-foreground: #92400e;
|
||||||
|
--color-status-cancelled-foreground: #b91c1c;
|
||||||
|
|
||||||
--radius-jumbo: 1.5rem;
|
--radius-jumbo: 1.5rem;
|
||||||
--radius-tile: 1rem;
|
--radius-tile: 1rem;
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -16,6 +16,11 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
"Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
|
"Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "NextChair",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
@@ -24,6 +29,7 @@ export const viewport: Viewport = {
|
|||||||
maximumScale: 1,
|
maximumScale: 1,
|
||||||
userScalable: false,
|
userScalable: false,
|
||||||
viewportFit: "cover",
|
viewportFit: "cover",
|
||||||
|
themeColor: "#111b15",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -34,6 +40,15 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className={inter.variable}>
|
<html lang="en" className={inter.variable}>
|
||||||
<body className="font-sans">
|
<body className="font-sans">
|
||||||
|
{/* Visible only when focused — lets a keyboard user jump past the
|
||||||
|
bottom nav / sidebar chrome that's repeated on every page,
|
||||||
|
straight to the #main-content landmark each layout renders. */}
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="bg-ink sr-only z-50 rounded-full px-4 py-2 text-sm font-bold text-white focus:not-sr-only focus:fixed focus:top-4 focus:left-4"
|
||||||
|
>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next.js's manifest file convention — auto-served at /manifest.webmanifest
|
||||||
|
* with the <link rel="manifest"> tag injected automatically, so nothing in
|
||||||
|
* layout.tsx needs to reference this directly. Single business per
|
||||||
|
* deployment (see docs/implementation-plan.md), so the name/colors are
|
||||||
|
* static rather than read from the Business row.
|
||||||
|
*/
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "NextChair — Barber Booking",
|
||||||
|
short_name: "NextChair",
|
||||||
|
description: "Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
|
||||||
|
start_url: "/home",
|
||||||
|
scope: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#f4f6f4",
|
||||||
|
theme_color: "#111b15",
|
||||||
|
orientation: "portrait-primary",
|
||||||
|
icons: [
|
||||||
|
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icons/icon-maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icons/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { WifiOff } from "lucide-react";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "You're offline" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The service worker's navigation fallback (see public/sw.js) — served
|
||||||
|
* from cache when a page request fails with no network. Deliberately
|
||||||
|
* static: no auth, no data fetching, nothing that could itself require a
|
||||||
|
* network round-trip, since the whole point is that it still renders with
|
||||||
|
* zero connectivity. Precached at service-worker install time.
|
||||||
|
*/
|
||||||
|
export default function OfflinePage() {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<WifiOff className="h-7 w-7" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-2 text-xl font-bold tracking-tight">You're offline</h1>
|
||||||
|
<p className="text-muted max-w-xs text-sm">
|
||||||
|
NextChair needs a connection for booking, schedules, and account details. Reconnect and try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,5 +6,9 @@
|
|||||||
* meant for "Confirm Reschedule".
|
* meant for "Confirm Reschedule".
|
||||||
*/
|
*/
|
||||||
export default function OwnerRescheduleLayout({ children }: { children: React.ReactNode }) {
|
export default function OwnerRescheduleLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="bg-stone min-h-screen">{children}</div>;
|
return (
|
||||||
|
<div id="main-content" className="bg-stone min-h-screen">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+1
-1
@@ -11,7 +11,7 @@ export default async function WelcomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div 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">
|
||||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
* clicks meant for "Confirm Reschedule".
|
* clicks meant for "Confirm Reschedule".
|
||||||
*/
|
*/
|
||||||
export default function StaffRescheduleLayout({ children }: { children: React.ReactNode }) {
|
export default function StaffRescheduleLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="bg-stone min-h-screen">{children}</div>;
|
return (
|
||||||
|
<div id="main-content" className="bg-stone min-h-screen">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -6,5 +6,9 @@
|
|||||||
* the persistent staff bottom nav.
|
* the persistent staff bottom nav.
|
||||||
*/
|
*/
|
||||||
export default function StaffBookLayout({ children }: { children: React.ReactNode }) {
|
export default function StaffBookLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="bg-stone min-h-screen">{children}</div>;
|
return (
|
||||||
|
<div id="main-content" className="bg-stone min-h-screen">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+29
-4
@@ -6,6 +6,17 @@ import Google from "next-auth/providers/google";
|
|||||||
import { authConfig } from "@/auth.config";
|
import { authConfig } from "@/auth.config";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { signInSchema } from "@/features/auth/schemas";
|
import { signInSchema } from "@/features/auth/schemas";
|
||||||
|
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||||
|
|
||||||
|
// A dummy hash to compare against when no real user/passwordHash exists,
|
||||||
|
// so a "no such account" / "Google-only account" attempt still pays
|
||||||
|
// bcrypt's cost — without this, that branch returns near-instantly while
|
||||||
|
// a wrong-password-for-a-real-account attempt always waits for a real
|
||||||
|
// bcrypt.compare, letting a timing measurement distinguish "no such
|
||||||
|
// account" from "wrong password" (an account-enumeration side channel).
|
||||||
|
// Computed once at module load (not hand-typed) so it's guaranteed to be
|
||||||
|
// a valid hash at the same cost factor registration uses.
|
||||||
|
const DUMMY_HASH = bcrypt.hashSync("nextchair-timing-safety-placeholder", 12);
|
||||||
|
|
||||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
...authConfig,
|
...authConfig,
|
||||||
@@ -28,11 +39,25 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
if (!parsed.success) return null;
|
if (!parsed.success) return null;
|
||||||
|
|
||||||
const { email, password } = parsed.data;
|
const { email, password } = parsed.data;
|
||||||
const user = await prisma.user.findUnique({ where: { email } });
|
|
||||||
if (!user?.passwordHash) return null;
|
|
||||||
|
|
||||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
// Keyed by the attempted email, not IP — this is what actually
|
||||||
if (!valid) return null;
|
// stops credential stuffing against one account regardless of how
|
||||||
|
// many source addresses it's spread across. 30 attempts per 15
|
||||||
|
// minutes is well above anything a real forgetful user (or a
|
||||||
|
// shared/office login) would ever hit, while still making online
|
||||||
|
// guessing impractical — a real brute-force attempt wants
|
||||||
|
// thousands of guesses, not tens.
|
||||||
|
const { allowed } = checkRateLimit(`signin:${email.toLowerCase()}`, {
|
||||||
|
maxAttempts: 30,
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
});
|
||||||
|
if (!allowed) return null;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
// Always compare against *something* — see DUMMY_HASH above — so
|
||||||
|
// this call's timing doesn't reveal whether the account exists.
|
||||||
|
const valid = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH);
|
||||||
|
if (!user?.passwordHash || !valid) return null;
|
||||||
|
|
||||||
return { id: user.id, email: user.email, name: user.name, image: user.image };
|
return { id: user.id, email: user.email, name: user.name, image: user.image };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
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";
|
||||||
@@ -14,15 +15,20 @@ export function StaffShell({
|
|||||||
children,
|
children,
|
||||||
items = STAFF_NAV_ITEMS,
|
items = STAFF_NAV_ITEMS,
|
||||||
footerLink,
|
footerLink,
|
||||||
|
notificationsHref = "/staff/notifications",
|
||||||
|
unreadNotificationCount = 0,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
items?: BottomNavItem[];
|
items?: BottomNavItem[];
|
||||||
footerLink?: { href: string; label: string };
|
footerLink?: { href: string; label: string };
|
||||||
|
notificationsHref?: string;
|
||||||
|
unreadNotificationCount?: number;
|
||||||
}) {
|
}) {
|
||||||
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} />
|
||||||
{children}
|
<NotificationBell href={notificationsHref} unreadCount={unreadNotificationCount} />
|
||||||
|
<div id="main-content">{children}</div>
|
||||||
<BottomNav items={items} variant="staff" />
|
<BottomNav items={items} variant="staff" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { ServiceWorkerManager } from "@/features/pwa/components/service-worker-manager";
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
return <SessionProvider>{children}</SessionProvider>;
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<ServiceWorkerManager />
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,10 @@ export function LoyaltyCard({
|
|||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{Array.from({ length: stampsRequired }, (_, i) => {
|
{Array.from({ length: stampsRequired }, (_, i) => {
|
||||||
const earned = i < stampsEarned;
|
const earned = i < stampsEarned;
|
||||||
const isRewardSlot = i === stampsRequired - 1;
|
// Only the final slot gets reward styling, and only once it's
|
||||||
|
// actually been earned — otherwise an in-progress card (e.g.
|
||||||
|
// 3/10 stamps) would show the reward label on slot 10 already.
|
||||||
|
const isRewardSlot = earned && i === stampsRequired - 1;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ const STATUS_CONFIG: Record<
|
|||||||
> = {
|
> = {
|
||||||
pending_approval: {
|
pending_approval: {
|
||||||
label: "Pending Approval",
|
label: "Pending Approval",
|
||||||
className: "bg-status-pending/10 text-status-pending",
|
className: "bg-status-pending/10 text-status-pending-foreground",
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
},
|
},
|
||||||
confirmed: {
|
confirmed: {
|
||||||
label: "Confirmed",
|
label: "Confirmed",
|
||||||
className: "bg-status-confirmed/10 text-status-confirmed",
|
className: "bg-status-confirmed/10 text-status-confirmed-foreground",
|
||||||
icon: CheckCheck,
|
icon: CheckCheck,
|
||||||
},
|
},
|
||||||
arrived: {
|
arrived: {
|
||||||
label: "Arrived",
|
label: "Arrived",
|
||||||
className: "bg-status-confirmed/10 text-status-confirmed",
|
className: "bg-status-confirmed/10 text-status-confirmed-foreground",
|
||||||
icon: LogIn,
|
icon: LogIn,
|
||||||
},
|
},
|
||||||
in_progress: {
|
in_progress: {
|
||||||
@@ -37,12 +37,12 @@ const STATUS_CONFIG: Record<
|
|||||||
completed: { label: "Completed", className: "bg-stone text-muted", icon: CheckCheck },
|
completed: { label: "Completed", className: "bg-stone text-muted", icon: CheckCheck },
|
||||||
cancelled: {
|
cancelled: {
|
||||||
label: "Cancelled",
|
label: "Cancelled",
|
||||||
className: "bg-status-cancelled/10 text-status-cancelled",
|
className: "bg-status-cancelled/10 text-status-cancelled-foreground",
|
||||||
icon: Ban,
|
icon: Ban,
|
||||||
},
|
},
|
||||||
no_show: {
|
no_show: {
|
||||||
label: "No-show",
|
label: "No-show",
|
||||||
className: "bg-status-cancelled/10 text-status-cancelled",
|
className: "bg-status-cancelled/10 text-status-cancelled-foreground",
|
||||||
icon: UserX,
|
icon: UserX,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import bcrypt from "bcryptjs";
|
|||||||
import { signIn } from "@/auth";
|
import { signIn } from "@/auth";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
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 { getRequestIp } from "@/lib/security/request-ip";
|
||||||
|
|
||||||
export interface RegisterResult {
|
export interface RegisterResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -19,6 +21,12 @@ export interface RegisterResult {
|
|||||||
* docs/implementation-plan.md §15, Question 3-adjacent).
|
* docs/implementation-plan.md §15, Question 3-adjacent).
|
||||||
*/
|
*/
|
||||||
export async function registerAction(formData: FormData): Promise<RegisterResult> {
|
export async function registerAction(formData: FormData): Promise<RegisterResult> {
|
||||||
|
const ip = await getRequestIp();
|
||||||
|
const { allowed } = checkRateLimit(`register:${ip}`, { maxAttempts: 5, windowMs: 60 * 60 * 1000 });
|
||||||
|
if (!allowed) {
|
||||||
|
return { success: false, error: "Too many attempts. Please try again later." };
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = registerSchema.safeParse({
|
const parsed = registerSchema.safeParse({
|
||||||
firstName: formData.get("firstName"),
|
firstName: formData.get("firstName"),
|
||||||
lastName: formData.get("lastName"),
|
lastName: formData.get("lastName"),
|
||||||
|
|||||||
@@ -1,35 +1,104 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PermissionDeniedError, assertCanAccessAppointment, canAccessAppointment } from "../authorization";
|
import { PermissionDeniedError, assertCanAccessAppointment, canAccessAppointment } from "../authorization";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registered-customer branch never touches the database (it's a plain
|
||||||
|
* userId comparison), so it's tested here in isolation. The guest-token
|
||||||
|
* branch's real hash/expiry/revocation behaviour is covered against a real
|
||||||
|
* database in customer-booking.integration.test.ts — this file only checks
|
||||||
|
* that authorization.ts calls into validateGuestAccessToken correctly and
|
||||||
|
* respects its result.
|
||||||
|
*/
|
||||||
|
vi.mock("@/services/scheduling/guest-access.service", () => ({
|
||||||
|
validateGuestAccessToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { validateGuestAccessToken } = await import("@/services/scheduling/guest-access.service");
|
||||||
|
const mockValidate = vi.mocked(validateGuestAccessToken);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockValidate.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not a real PrismaClient — the mocked guest-access service never touches it.
|
||||||
|
const prisma = {} as never;
|
||||||
|
|
||||||
describe("assertCanAccessAppointment", () => {
|
describe("assertCanAccessAppointment", () => {
|
||||||
it("allows a registered customer to access their own appointment", () => {
|
it("allows a registered customer to access their own appointment", async () => {
|
||||||
expect(() =>
|
await expect(
|
||||||
assertCanAccessAppointment({ customer: { userId: "user_1" } }, "user_1"),
|
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_1" }),
|
||||||
).not.toThrow();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("denies a different signed-in user from accessing a registered customer's appointment", () => {
|
it("denies a different signed-in user from accessing a registered customer's appointment", async () => {
|
||||||
expect(() => assertCanAccessAppointment({ customer: { userId: "user_1" } }, "user_2")).toThrow(
|
await expect(
|
||||||
PermissionDeniedError,
|
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_2" }),
|
||||||
);
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("denies an unauthenticated caller from accessing a registered customer's appointment", () => {
|
it("denies an unauthenticated caller from accessing a registered customer's appointment", async () => {
|
||||||
expect(() => assertCanAccessAppointment({ customer: { userId: "user_1" } }, undefined)).toThrow(
|
await expect(
|
||||||
PermissionDeniedError,
|
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: undefined }),
|
||||||
);
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows access to a guest appointment (no linked user) regardless of session — the documented guest access model", () => {
|
it("never even calls the guest-token validator for a registered customer's appointment", async () => {
|
||||||
expect(() => assertCanAccessAppointment({ customer: { userId: null } }, undefined)).not.toThrow();
|
mockValidate.mockClear();
|
||||||
expect(() => assertCanAccessAppointment({ customer: { userId: null } }, "some_other_user")).not.toThrow();
|
await assertCanAccessAppointment(
|
||||||
|
prisma,
|
||||||
|
{ id: "a1", customer: { userId: "user_1" } },
|
||||||
|
{ sessionUserId: "user_1" },
|
||||||
|
).catch(() => {});
|
||||||
|
expect(mockValidate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a guest appointment when the guest-token validator confirms the token", async () => {
|
||||||
|
mockValidate.mockResolvedValueOnce(true);
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(
|
||||||
|
prisma,
|
||||||
|
{ id: "a1", customer: { userId: null } },
|
||||||
|
{ sessionUserId: undefined, guestToken: "correct-token" },
|
||||||
|
),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(mockValidate).toHaveBeenCalledWith(prisma, { appointmentId: "a1", rawToken: "correct-token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies a guest appointment when no token is supplied at all — the appointment id is no longer sufficient", async () => {
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: null } }, { sessionUserId: undefined }),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
|
expect(mockValidate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies a guest appointment when the guest-token validator rejects the token", async () => {
|
||||||
|
mockValidate.mockResolvedValueOnce(false);
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(
|
||||||
|
prisma,
|
||||||
|
{ id: "a1", customer: { userId: null } },
|
||||||
|
{ sessionUserId: undefined, guestToken: "wrong-token" },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("canAccessAppointment", () => {
|
describe("canAccessAppointment", () => {
|
||||||
it("mirrors assertCanAccessAppointment as a boolean", () => {
|
it("mirrors assertCanAccessAppointment as a boolean", async () => {
|
||||||
expect(canAccessAppointment({ customer: { userId: "user_1" } }, "user_1")).toBe(true);
|
expect(
|
||||||
expect(canAccessAppointment({ customer: { userId: "user_1" } }, "user_2")).toBe(false);
|
await canAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_1" }),
|
||||||
expect(canAccessAppointment({ customer: { userId: null } }, undefined)).toBe(true);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
await canAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_2" }),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
mockValidate.mockResolvedValueOnce(true);
|
||||||
|
expect(
|
||||||
|
await canAccessAppointment(
|
||||||
|
prisma,
|
||||||
|
{ id: "a1", customer: { userId: null } },
|
||||||
|
{ sessionUserId: undefined, guestToken: "correct-token" },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,7 @@ import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/busi
|
|||||||
import type { CalendarDate } from "@/lib/time/business-time";
|
import type { CalendarDate } from "@/lib/time/business-time";
|
||||||
import { cancelBooking, createBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
import { cancelBooking, createBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||||
|
import { revokeGuestAccessToken } from "@/services/scheduling/guest-access.service";
|
||||||
import {
|
import {
|
||||||
cleanupTestBusiness,
|
cleanupTestBusiness,
|
||||||
createTestBusiness,
|
createTestBusiness,
|
||||||
@@ -357,10 +358,16 @@ describe("customer booking flows (integration)", () => {
|
|||||||
});
|
});
|
||||||
if (!created.success) throw new Error("setup failed");
|
if (!created.success) throw new Error("setup failed");
|
||||||
|
|
||||||
expect(() => assertCanAccessAppointment(created.appointment, ownerUserId)).not.toThrow();
|
await expect(
|
||||||
expect(() => assertCanAccessAppointment(created.appointment, strangerUserId)).toThrow(PermissionDeniedError);
|
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: ownerUserId }),
|
||||||
expect(() => assertCanAccessAppointment(created.appointment, undefined)).toThrow(PermissionDeniedError);
|
).resolves.toBeUndefined();
|
||||||
expect(canAccessAppointment(created.appointment, strangerUserId)).toBe(false);
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: strangerUserId }),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: undefined }),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
|
expect(await canAccessAppointment(prisma, created.appointment, { sessionUserId: strangerUserId })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not treat the appointment id alone as permission — an unrelated session cannot claim it", async () => {
|
it("does not treat the appointment id alone as permission — an unrelated session cannot claim it", async () => {
|
||||||
@@ -378,7 +385,102 @@ describe("customer booking flows (integration)", () => {
|
|||||||
|
|
||||||
expect(created.appointment.customer.userId).toBe(ownerUserId);
|
expect(created.appointment.customer.userId).toBe(ownerUserId);
|
||||||
const randomOtherId = "some-other-user-id-that-is-not-the-owner";
|
const randomOtherId = "some-other-user-id-that-is-not-the-owner";
|
||||||
expect(canAccessAppointment(created.appointment, randomOtherId)).toBe(false);
|
expect(await canAccessAppointment(prisma, created.appointment, { sessionUserId: randomOtherId })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("guest access tokens (Phase 7 — see guest-access.service.ts)", () => {
|
||||||
|
it("a guest booking issues a token, and only the correct token grants access", async () => {
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const guest = await createTestGuestCustomer();
|
||||||
|
guestProfileIds.push(guest.customerId);
|
||||||
|
|
||||||
|
const created = await createBooking(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: guest.customerId,
|
||||||
|
staffId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||||
|
});
|
||||||
|
if (!created.success) throw new Error("setup failed");
|
||||||
|
|
||||||
|
expect(created.guestAccessToken).toBeTruthy();
|
||||||
|
expect(created.appointment.customer.userId).toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// The bare appointment id is no longer sufficient — the exact gap this closes.
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, created.appointment, { guestToken: undefined }),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, created.appointment, { guestToken: "totally-wrong-token" }),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a token scoped to one guest appointment does not grant access to another", async () => {
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const guest = await createTestGuestCustomer();
|
||||||
|
guestProfileIds.push(guest.customerId);
|
||||||
|
|
||||||
|
const first = await createBooking(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: guest.customerId,
|
||||||
|
staffId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||||
|
});
|
||||||
|
const second = await createBooking(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: guest.customerId,
|
||||||
|
staffId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
|
||||||
|
});
|
||||||
|
if (!first.success || !second.success) throw new Error("setup failed");
|
||||||
|
expect(first.guestAccessToken).not.toBe(second.guestAccessToken);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, second.appointment, { guestToken: first.guestAccessToken }),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an expired or revoked token is rejected even though it's otherwise correct", async () => {
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const guest = await createTestGuestCustomer();
|
||||||
|
guestProfileIds.push(guest.customerId);
|
||||||
|
|
||||||
|
const created = await createBooking(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: guest.customerId,
|
||||||
|
staffId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
|
||||||
|
});
|
||||||
|
if (!created.success) throw new Error("setup failed");
|
||||||
|
|
||||||
|
await prisma.guestAppointmentAccessToken.update({
|
||||||
|
where: { appointmentId: created.appointment.id },
|
||||||
|
data: { expiresAt: new Date(Date.now() - 1000) },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
|
|
||||||
|
await prisma.guestAppointmentAccessToken.update({
|
||||||
|
where: { appointmentId: created.appointment.id },
|
||||||
|
data: { expiresAt: new Date(Date.now() + 1000 * 60 * 60), revokedAt: null },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await revokeGuestAccessToken(prisma, created.appointment.id);
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { redirect } from "next/navigation";
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { clearDraft, readDraft, writeLastBookingReference } from "@/lib/booking-draft/cookie";
|
import { clearDraft, readDraft, writeLastBookingReference } from "@/lib/booking-draft/cookie";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||||
|
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 { getFirstFriendlyMessage } from "../error-messages";
|
import { getFirstFriendlyMessage } from "../error-messages";
|
||||||
@@ -42,6 +44,14 @@ 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." };
|
||||||
}
|
}
|
||||||
|
// Registered customers already have a rate-limited signin gate on
|
||||||
|
// their account; a guest has none, so this is the one place spam
|
||||||
|
// bookings can otherwise be generated with no account at all.
|
||||||
|
const ip = await getRequestIp();
|
||||||
|
const { allowed } = checkRateLimit(`guest-booking:${ip}`, { maxAttempts: 8, windowMs: 60 * 60 * 1000 });
|
||||||
|
if (!allowed) {
|
||||||
|
return { success: false, error: "Too many booking attempts. Please try again later." };
|
||||||
|
}
|
||||||
const resolved = await resolveCustomerForBooking(prisma, { businessId, guest: draft.guest });
|
const resolved = await resolveCustomerForBooking(prisma, { businessId, guest: draft.guest });
|
||||||
customerId = resolved.customerId;
|
customerId = resolved.customerId;
|
||||||
}
|
}
|
||||||
@@ -62,7 +72,10 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
|
|||||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeLastBookingReference(result.appointment.id);
|
await writeLastBookingReference({
|
||||||
|
appointmentId: result.appointment.id,
|
||||||
|
guestAccessToken: result.guestAccessToken,
|
||||||
|
});
|
||||||
await clearDraft();
|
await clearDraft();
|
||||||
redirect("/book/confirmation");
|
redirect("/book/confirmation");
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,10 @@ export async function cancelCustomerAppointmentAction(input: unknown): Promise<C
|
|||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
try {
|
try {
|
||||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
await assertCanAccessAppointment(prisma, appointment, {
|
||||||
|
sessionUserId: session?.user?.id,
|
||||||
|
guestToken: parsed.data.guestToken,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
|
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
|
||||||
throw error;
|
throw error;
|
||||||
@@ -65,7 +68,10 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi
|
|||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
try {
|
try {
|
||||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
await assertCanAccessAppointment(prisma, appointment, {
|
||||||
|
sessionUserId: session?.user?.id,
|
||||||
|
guestToken: parsed.data.guestToken,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
|
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -140,7 +140,10 @@ export async function startRebookAction(input: unknown): Promise<void> {
|
|||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
try {
|
try {
|
||||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
await assertCanAccessAppointment(prisma, appointment, {
|
||||||
|
sessionUserId: session?.user?.id,
|
||||||
|
guestToken: parsed.data.guestToken,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
redirect("/appointments");
|
redirect("/appointments");
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import { validateGuestAccessToken } from "@/services/scheduling/guest-access.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer-facing ownership checks. Kept in the feature/action layer, not
|
* Customer-facing ownership checks. Kept in the feature/action layer, not
|
||||||
* the domain services — AppointmentService/BookingService are also used
|
* the domain services — AppointmentService/BookingService are also used
|
||||||
@@ -17,31 +20,55 @@ export class PermissionDeniedError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OwnableAppointment {
|
export interface OwnableAppointment {
|
||||||
|
id: string;
|
||||||
customer: { userId: string | null };
|
customer: { userId: string | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppointmentAccess {
|
||||||
|
sessionUserId?: string | undefined;
|
||||||
|
/** Raw token from the guest's booking-confirmation link — see guest-access.service.ts. Ignored for a registered customer's appointment. */
|
||||||
|
guestToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ownership rule:
|
* Ownership rule:
|
||||||
* - A REGISTERED customer's appointment (customer.userId is set) always
|
* - A REGISTERED customer's appointment (customer.userId is set) always
|
||||||
* requires the requesting session to match that exact user. An
|
* requires the requesting session to match that exact user. An
|
||||||
* appointment id alone is never sufficient — see the Phase 4 brief's
|
* appointment id alone is never sufficient — see the Phase 4 brief's
|
||||||
* explicit "Appointment IDs are not treated as permission".
|
* explicit "Appointment IDs are not treated as permission".
|
||||||
* - A GUEST appointment (customer.userId is null) has no stronger
|
* - A GUEST appointment (customer.userId is null) requires the current,
|
||||||
* credential available in this phase — no magic-link/OTP system was
|
* unexpired, unrevoked access token for THIS exact appointment (see
|
||||||
* built (explicitly deferred, see docs/phase-4-summary.md). Access is
|
* GuestAppointmentAccessToken in schema.prisma and
|
||||||
* by the appointment's own unguessable id, the same "order tracking"
|
* guest-access.service.ts). Closes the Phase 4 gap where the
|
||||||
* pattern many guest-checkout systems use. This is a deliberate,
|
* appointment's own id — unguessable but never actually secret — was
|
||||||
* documented scope limitation, not an oversight.
|
* the only credential (see docs/phase-4-summary.md and
|
||||||
|
* docs/phase-7-summary.md).
|
||||||
*/
|
*/
|
||||||
export function assertCanAccessAppointment(appointment: OwnableAppointment, sessionUserId: string | undefined): void {
|
export async function assertCanAccessAppointment(
|
||||||
if (appointment.customer.userId && appointment.customer.userId !== sessionUserId) {
|
prisma: PrismaClient,
|
||||||
|
appointment: OwnableAppointment,
|
||||||
|
access: AppointmentAccess,
|
||||||
|
): Promise<void> {
|
||||||
|
if (appointment.customer.userId) {
|
||||||
|
if (appointment.customer.userId !== access.sessionUserId) throw new PermissionDeniedError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!access.guestToken ||
|
||||||
|
!(await validateGuestAccessToken(prisma, { appointmentId: appointment.id, rawToken: access.guestToken }))
|
||||||
|
) {
|
||||||
throw new PermissionDeniedError();
|
throw new PermissionDeniedError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canAccessAppointment(appointment: OwnableAppointment, sessionUserId: string | undefined): boolean {
|
export async function canAccessAppointment(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
appointment: OwnableAppointment,
|
||||||
|
access: AppointmentAccess,
|
||||||
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
assertCanAccessAppointment(appointment, sessionUserId);
|
await assertCanAccessAppointment(prisma, appointment, access);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import { useTransition } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { startRebookAction } from "../actions/draft";
|
import { startRebookAction } from "../actions/draft";
|
||||||
|
|
||||||
export function BookAgainButton({ appointmentId, className }: { appointmentId: string; className?: string }) {
|
export function BookAgainButton({
|
||||||
|
appointmentId,
|
||||||
|
guestToken,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
appointmentId: string;
|
||||||
|
guestToken?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,7 +21,7 @@ export function BookAgainButton({ appointmentId, className }: { appointmentId: s
|
|||||||
size="sm"
|
size="sm"
|
||||||
className={className}
|
className={className}
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
onClick={() => startTransition(() => startRebookAction({ appointmentId }))}
|
onClick={() => startTransition(() => startRebookAction({ appointmentId, guestToken }))}
|
||||||
>
|
>
|
||||||
Book Again
|
Book Again
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import { Modal } from "@/components/ui/modal";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { cancelCustomerAppointmentAction } from "../actions/customer-appointments";
|
import { cancelCustomerAppointmentAction } from "../actions/customer-appointments";
|
||||||
|
|
||||||
export function CancelAppointmentDialog({ appointmentId }: { appointmentId: string }) {
|
export function CancelAppointmentDialog({
|
||||||
|
appointmentId,
|
||||||
|
guestToken,
|
||||||
|
}: {
|
||||||
|
appointmentId: string;
|
||||||
|
guestToken?: string;
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -21,6 +27,7 @@ export function CancelAppointmentDialog({ appointmentId }: { appointmentId: stri
|
|||||||
const res = await cancelCustomerAppointmentAction({
|
const res = await cancelCustomerAppointmentAction({
|
||||||
appointmentId,
|
appointmentId,
|
||||||
reason: reason || undefined,
|
reason: reason || undefined,
|
||||||
|
guestToken,
|
||||||
});
|
});
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
setError(res.error ?? "Something went wrong. Please try again.");
|
setError(res.error ?? "Something went wrong. Please try again.");
|
||||||
|
|||||||
@@ -85,15 +85,18 @@ export const saveDetailsStepSchema = z.object({
|
|||||||
|
|
||||||
export const startRebookSchema = z.object({
|
export const startRebookSchema = z.object({
|
||||||
appointmentId: z.string(),
|
appointmentId: z.string(),
|
||||||
|
guestToken: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const customerCancelSchema = z.object({
|
export const customerCancelSchema = z.object({
|
||||||
appointmentId: z.string(),
|
appointmentId: z.string(),
|
||||||
reason: z.string().max(500).optional(),
|
reason: z.string().max(500).optional(),
|
||||||
|
guestToken: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const customerRescheduleSchema = z.object({
|
export const customerRescheduleSchema = z.object({
|
||||||
appointmentId: z.string(),
|
appointmentId: z.string(),
|
||||||
staffId: z.string().optional(),
|
staffId: z.string().optional(),
|
||||||
startAt: z.iso.datetime(),
|
startAt: z.iso.datetime(),
|
||||||
|
guestToken: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { markAllNotificationsRead, markNotificationRead } from "@/services/notifications/notification.service";
|
||||||
|
|
||||||
|
export interface MarkReadActionResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared across all three notification-centre pages (customer/staff/owner)
|
||||||
|
* — ownership is enforced by recipientUserId matching the session, exactly
|
||||||
|
* the same rule regardless of which role is asking (see
|
||||||
|
* NotificationService.markNotificationRead's own scoped WHERE clause), so
|
||||||
|
* one action file covers all of them rather than three near-identical
|
||||||
|
* copies.
|
||||||
|
*/
|
||||||
|
export async function markNotificationReadAction(notificationId: string): Promise<MarkReadActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||||
|
|
||||||
|
await markNotificationRead(prisma, { notificationId, userId: session.user.id });
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
revalidatePath("/staff/notifications");
|
||||||
|
revalidatePath("/owner/notifications");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsReadAction(): Promise<MarkReadActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { success: false, error: "You must be signed in." };
|
||||||
|
|
||||||
|
await markAllNotificationsRead(prisma, session.user.id);
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
revalidatePath("/staff/notifications");
|
||||||
|
revalidatePath("/owner/notifications");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user