Compare commits

...
21 Commits
Author SHA1 Message Date
Stefan Lazic 9fe5b4f038 Revert "Update README.md"
CI / ci (push) Canceled after 0s
This reverts commit 5472db320e.
2026-09-05 09:32:37 +01:00
Stefan Lazic 89bce03ebb Revert "Deployment Redesign"
This reverts commit 764d087eac.
2026-09-05 09:32:34 +01:00
Stefan Lazic e5668b3f23 Revert "initial NextChair Manager."
This reverts commit b219fb4519.
2026-09-05 09:32:32 +01:00
Stefan Lazic eaa0d2e513 Revert "Build failure fix"
This reverts commit 7f774e95bb.
2026-09-05 09:32:30 +01:00
Stefan Lazic 7e70fa0583 Revert "Fixed the Manager deployment database dependency."
This reverts commit a8cdc2671e.
2026-09-05 09:32:28 +01:00
Stefan Lazic d063918aae Revert "Build faliure fix 2"
This reverts commit 69bc0adf29.
2026-09-05 09:32:26 +01:00
Stefan Lazic 3e9fe60faf Revert "Fixed the remaining Manager runtime coupling."
This reverts commit bc45608023.
2026-09-05 09:32:23 +01:00
Stefan Lazic 65d2c7322b Revert "Request fix"
This reverts commit d9a253a974.
2026-09-05 09:32:21 +01:00
Stefan Lazic 638b851fce Revert "root-route separation."
This reverts commit 2da916a4c6.
2026-09-05 09:32:20 +01:00
Stefan Lazic c237b565bd Revert "Auth fix"
This reverts commit 1c281c9f5d.
2026-09-05 09:32:18 +01:00
Stefan Lazic 32e93c6c23 Revert "Update management-auth.ts"
This reverts commit 0dd50c0375.
2026-09-05 09:32:16 +01:00
Stefan Lazic 3cef279d2d Revert "Redirect loop fix"
This reverts commit 8fddde6d4b.
2026-09-05 09:32:14 +01:00
Stefan Lazic bcf7fa58d3 Revert "cookie handling"
This reverts commit ac9791c8ef.
2026-09-05 09:32:12 +01:00
Stefan Lazic 3de53f0a67 Revert "Update management-auth.ts"
This reverts commit a65360b82d.
2026-09-05 09:32:10 +01:00
Stefan Lazic 344c08b840 Revert "SSR Leak fix"
This reverts commit 8a2b211069.
2026-09-05 09:32:08 +01:00
Stefan Lazic 2aa8405006 Revert "PHP Rebuild"
This reverts commit 2ef3654841.
2026-09-05 09:32:05 +01:00
Stefan Lazic a59f998f2b Revert "Docker"
This reverts commit c4325018e0.
2026-09-05 09:32:03 +01:00
Stefan Lazic 39eeb937de Revert "Update 2026_09_04_000300_create_nextchair_platform_tables.php"
This reverts commit cd6abeca6e.
2026-09-05 09:32:01 +01:00
Stefan Lazic 33fb14b773 Revert "Management UI"
This reverts commit f11404c574.
2026-09-05 09:31:59 +01:00
Stefan Lazic 3e052e56b1 Revert "Login Loop"
This reverts commit 16c6c28f07.
2026-09-05 09:31:50 +01:00
Stefan Lazic fa96fe0e15 Revert "Update entrypoint.sh"
This reverts commit 5972d37425.
2026-09-05 09:31:41 +01:00
717 changed files with 12285 additions and 36444 deletions

No files matched your search

+15 -20
View File
@@ -1,26 +1,21 @@
.git
.github
.idea
.vscode
# The previous Next.js application is intentionally outside this build.
legacy-nextjs
node_modules
vendor
.next
generated
.git
.gitea
.env
.env.*
!.env.example
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*.php
public/build
docker-compose*.yml
docker-compose*.yaml
*.log
tests
!.env.local.example
test-results
playwright-report
blob-report
playwright/.cache
coverage
e2e
docs
templates
*.md
.vscode
.idea
.DS_Store
npm-debug.log*
-18
View File
@@ -1,18 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
+100
View File
@@ -0,0 +1,100 @@
# One business's config on the shared multi-business VPS setup — see
# docker-compose.infra.yaml for the one-time setup this depends on
# (creating this business's database on the shared MySQL server) and
# docker-compose.<business>.yaml for how this file is used. Copy this to
# `.env.business.<slug>` (e.g. `.env.business.blake-heritage` — kept out
# of git, see .gitignore) per business and fill in real values. Nothing
# here is a real credential.
#
# The app starts and runs a complete first-install flow with ONLY the
# "Required for basic startup" section below filled in — every other
# integration (email, push, Google sign-in, Google Calendar) shows a
# disabled state rather than crashing when its variables are left blank.
# ── Required for basic startup ─────────────────────────────────────────
# The public domain this business is served on — used by Traefik's
# routing label (see docker-compose.<business>.yaml) to decide which
# container handles requests for it, so this must be set and must match
# the domain's real DNS (pointed at the VPS) before Traefik can issue a
# certificate for it.
DOMAIN="blake-heritage.example.com"
# The app reaches MySQL via the shared infra's service hostname
# `next-chair-mysql`, never `localhost` — see docker-compose.infra.yaml.
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for
# this business on the shared server (see docker-compose.infra.yaml's
# "CREATE DATABASE ..." step) — this file doesn't create them, it only
# tells the app what to connect as.
DATABASE_HOST="next-chair-mysql"
DATABASE_PORT="3306"
DATABASE_NAME="blake_heritage"
DATABASE_USER="blake_heritage"
DATABASE_PASSWORD="replace-me"
# Generate with: npx auth secret
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
# Auth.js needs to trust the host it's served from when not on localhost:3000.
AUTH_TRUST_HOST="true"
NEXT_PUBLIC_APP_URL="https://blake-heritage.example.com"
# One-time setup token the installation wizard (/install) requires before
# it will create the first business/administrator — generate with:
# openssl rand -hex 32
INSTALLATION_TOKEN="replace-me"
# Shared secret an external scheduler presents to
# /api/cron/process-notifications — generate with: openssl rand -hex 32
CRON_SECRET="replace-me"
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
# Platform-level account with every Owner permission. Not run
# automatically by this compose file (that's a Docker-entrypoint-only
# behaviour, see docker/entrypoint.sh) — run it manually, once, the same
# way migrations were run for the Vercel/Hostinger deployment earlier:
# docker exec -it next-chair-blake-heritage npx tsx scripts/ensure-super-admin.ts
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install
# wizard's own administrator account instead.
SUPER_ADMIN_EMAIL=""
SUPER_ADMIN_PASSWORD=""
SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME=""
# ── Google sign-in (optional — "Continue with Google" on Sign In) ─────
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""
# ── Google Calendar sync (optional — staff-level, see /staff/settings/calendar) ─
# From https://console.cloud.google.com/apis/credentials — a separate
# OAuth client from AUTH_GOOGLE_ID/SECRET above (different scopes).
GOOGLE_CALENDAR_CLIENT_ID=""
GOOGLE_CALENDAR_CLIENT_SECRET=""
GOOGLE_CALENDAR_REDIRECT_URI="https://blake-heritage.example.com/api/staff/calendar/google/callback"
# Encrypts stored refresh tokens at rest — generate with: openssl rand -hex 32
CALENDAR_TOKEN_ENCRYPTION_KEY=""
# ── Media Manager (optional — see src/lib/media/media-storage.ts) ──────
# Max upload size in bytes for the Media Manager. Defaults to 5,000,000
# (5MB) if unset.
MEDIA_MAX_UPLOAD_BYTES="5000000"
# ── Google Reviews (optional — see /owner/settings/reviews) ────────────
# From https://console.cloud.google.com/apis/credentials — needs "Places
# API (New)" enabled on that project (a different product from the older
# "Places API" — enabling the wrong one returns REQUEST_DENIED). Leave
# blank to keep review sync disabled entirely (Home shows clearly-labelled
# demo reviews instead).
GOOGLE_PLACES_API_KEY=""
# ── Email (optional — defaults to logging to the console instead of sending) ─
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=""
# ── Web Push (optional — defaults to logging to the console instead of sending) ─
# Generate with: npx web-push generate-vapid-keys
PUSH_PROVIDER="console"
VAPID_PUBLIC_KEY=""
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY=""
VAPID_SUBJECT="mailto:owner@example.com"
+79 -59
View File
@@ -1,70 +1,90 @@
APP_NAME=NextChair
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8085
# Database (MySQL 8.0 — migrated from PostgreSQL in Phase 7, see
# docs/phase-7-summary.md; the production host only supports MySQL).
DATABASE_HOST="localhost"
DATABASE_PORT="3306"
DATABASE_NAME="nextchair"
DATABASE_USER="user"
DATABASE_PASSWORD="password"
# 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"
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
# Auth.js (NextAuth v5). Generate with: npx auth secret
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# Google OAuth — https://console.cloud.google.com/apis/credentials
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""
# PHP_CLI_SERVER_WORKERS=4
# Base URL of the app (used for callback URLs, email links, etc.)
NEXT_PUBLIC_APP_URL="http://localhost:3000"
BCRYPT_ROUNDS=12
# Media Manager (Phase 9) — max upload size in bytes. Defaults to
# 5,000,000 (5MB) if unset. See src/lib/media/media-storage.ts.
MEDIA_MAX_UPLOAD_BYTES="5000000"
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Google Reviews (Phase 9) — from https://console.cloud.google.com/apis/credentials,
# needs the Places API enabled. Leave unset to keep review sync disabled
# (Home shows clearly-labelled demo reviews instead). See
# src/services/reviews/review-sync.service.ts and /owner/settings/reviews.
GOOGLE_PLACES_API_KEY=""
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=nextchair
DB_USERNAME=nextchair
DB_PASSWORD=change-me
DB_ROOT_PASSWORD=change-me-root
# 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 is optional and is only used when no business name can
# be resolved. Normal business emails use the installed business name
# automatically (see docs/phase-8-email-sender-summary.md) — you do not
# need to set or keep this in sync with Business Settings.
EMAIL_FROM_NAME=""
# Docker publishes Nginx on this local bind address by default.
NEXTCHAIR_HTTP_BIND=127.0.0.1
NEXTCHAIR_HTTP_PORT=8085
# 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"
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# 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=""
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
# First-run installation wizard (Phase 8) — required before /install will
# create the first business/administrator. Generate with:
# openssl rand -hex 32
INSTALLATION_TOKEN=""
CACHE_STORE=database
# CACHE_PREFIX=
# Super Administrator (Phase 9) — platform-level account with every Owner
# permission, provisioned from these env vars rather than the installation
# wizard (see scripts/ensure-super-admin.ts, run on every container start).
# Has no StaffProfile by design: never appears in Team, the customer-facing
# barber picker, or staff totals, and can never receive an appointment. All
# four optional — leave SUPER_ADMIN_EMAIL unset to skip provisioning one
# entirely. Re-run to rotate the password (re-hashed every run).
SUPER_ADMIN_EMAIL=""
SUPER_ADMIN_PASSWORD=""
SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME=""
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Staff Google Calendar sync (Phase 8) — separate OAuth client from
# AUTH_GOOGLE_ID/SECRET above (different scopes; see
# src/services/calendar/google-calendar.provider.ts). Leave unset to keep
# calendar sync disabled — Settings shows a "not configured" state rather
# than crashing.
GOOGLE_CALENDAR_CLIENT_ID=""
GOOGLE_CALENDAR_CLIENT_SECRET=""
GOOGLE_CALENDAR_REDIRECT_URI="http://localhost:3000/api/staff/calendar/google/callback"
# Encrypts stored refresh tokens at rest. Generate with: openssl rand -hex 32
CALENDAR_TOKEN_ENCRYPTION_KEY=""
+20
View File
@@ -0,0 +1,20 @@
# Shared infrastructure config — see docker-compose.infra.yaml for the
# full setup sequence. Copy this to `.env.infra` (kept out of git — see
# .gitignore) and fill in real values.
#
# docker network create next-chair-edge
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d
# ── MySQL (shared server — one database per business, created manually
# per business, see docker-compose.infra.yaml's own header) ────────────
MYSQL_ROOT_PASSWORD="replace-me"
# Host-machine port MySQL is reachable on, e.g. for a GUI client over an
# SSH tunnel — every business's app container always reaches it via the
# shared Docker network and the service name `next-chair-mysql`, never
# this.
MYSQL_HOST_PORT=3306
# ── Traefik (reverse proxy + automatic TLS via Let's Encrypt) ──────────
# Let's Encrypt sends renewal/expiry notices here — use a real address
# you actually check, not a placeholder.
ACME_EMAIL="you@example.com"
-11
View File
@@ -1,11 +0,0 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+63 -37
View File
@@ -1,41 +1,67 @@
*.log
.DS_Store
# dependencies
node_modules/
.pnp
.pnp.js
# testing
coverage/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# next.js
.next/
out/
next-env.d.ts
# production
build/
dist/
# env files
.env
.env.backup
.env*.local
.env.development
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
/.phpunit.result.cache
/.phpunit.cache
/tsconfig.tsbuildinfo
# preserved legacy application dependencies and build output
/legacy-nextjs/node_modules/
/legacy-nextjs/.next/
/legacy-nextjs/generated/
/legacy-nextjs/tsconfig.tsbuildinfo
# per-deployment production env files (multi-business VPS setup — see
# docker-compose.infra.yaml / docker-compose.<business>.yaml). Named
# .env.business.* (not .env.<business> directly) specifically so this one
# wildcard rule keeps covering every future business without another
# .gitignore edit. The template itself (.env.business.example) stays
# tracked, same as .env.local.example above.
.env.infra
.env.business.*
!.env.business.example
# Laravel build output
/public/build
/public/hot
/storage/framework/views/
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
# prisma
prisma/dev.db
prisma/dev.db-journal
# misc
.DS_Store
*.pem
.vercel
*.tsbuildinfo
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# editor
.vscode/*
!.vscode/extensions.json
.idea/
/generated/prisma
.env.example
.env.local.example
.env.example
.env.example
.agents/
.claude/
.windsurf/
+1 -2
View File
@@ -1,2 +1 @@
ignore-scripts=true
audit=true
cache=/private/tmp/claude-501/-Users-stefanlazic-Documents-GitHub-NextChair/11cd8d75-4d20-4339-a2c5-329dd2e59132/scratchpad/npm-cache
File renamed without changes.
-47
View File
@@ -1,47 +0,0 @@
<laravel-boost-guidelines>
# Laravel Application
This repository contains a Laravel application. Complete the following setup before working on the user's request.
## Prerequisites
Verify that PHP and Composer are available:
```sh
php -v
composer -V
```
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
macOS:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
```
Windows PowerShell:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
```
Linux:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
```
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
## Agent Setup
Install Laravel Boost from the application root before making application changes:
```sh
composer require laravel/boost --dev
php artisan boost:install
```
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
</laravel-boost-guidelines>
-47
View File
@@ -1,47 +0,0 @@
<laravel-boost-guidelines>
# Laravel Application
This repository contains a Laravel application. Complete the following setup before working on the user's request.
## Prerequisites
Verify that PHP and Composer are available:
```sh
php -v
composer -V
```
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
macOS:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
```
Windows PowerShell:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
```
Linux:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
```
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
## Agent Setup
Install Laravel Boost from the application root before making application changes:
```sh
composer require laravel/boost --dev
php artisan boost:install
```
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
</laravel-boost-guidelines>
+72 -73
View File
@@ -1,83 +1,82 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS frontend
WORKDIR /var/www/html
# Multi-stage build. NextChair is the internal project/image name only —
# see docs/phase-8-summary.md's white-label section for why that's fine
# here (Docker/package names are explicitly "internal and acceptable")
# even though the running application never shows that name to a user.
#
# Prisma 7 uses driver adapters (@prisma/adapter-mariadb), not a native
# Rust query-engine binary — the generated client is pure JS, so this
# image needs no special native-binary handling the way older Prisma
# versions on Alpine sometimes did.
#
# `output: "standalone"` in next.config.ts was deliberately NOT enabled:
# this image runs `prisma migrate deploy` (and, for the optional seed
# service, `tsx prisma/seed.ts`) at container start, both of which need
# the full `prisma`/`tsx` CLIs and their dependencies present — a pruned
# standalone bundle would need those re-added anyway, so a single
# `npm ci` (full dependencies, including the small set of dev tools this
# image actually runs) is simpler and no less correct. See
# docs/phase-8-summary.md, "Docker image quality" for this trade-off.
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources ./resources
COPY public ./public
COPY vite.config.js .
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# `npm run build` runs `prisma generate` itself (see package.json — needed
# so a plain Vercel/`npm run build` deploy generates the client too, not
# just this Docker path, which used to have its own separate `prisma
# generate` step here).
# `next build`'s page-data collection step imports every route module
# (including /api/health) to inspect it — that pulls in
# src/lib/db/prisma.ts, which requires DATABASE_HOST/NAME/USER/PASSWORD
# just to construct its connection-pool config object, even though nothing
# actually connects at build time. These placeholders only exist to
# satisfy that check; the real values are supplied at container runtime
# via env_file (see docker-compose.local.yaml) and these are never used to
# connect to anything or baked into a layer that matters.
ENV DATABASE_HOST="localhost"
ENV DATABASE_PORT="3306"
ENV DATABASE_NAME="build"
ENV DATABASE_USER="build"
ENV DATABASE_PASSWORD="build"
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
FROM composer:2 AS composer
# Non-root — see the brief's "Docker image quality: non-root user."
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextchair
COPY --from=builder --chown=nextchair:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextchair:nodejs /app/.next ./.next
COPY --from=builder --chown=nextchair:nodejs /app/public ./public
COPY --from=builder --chown=nextchair:nodejs /app/generated ./generated
COPY --from=builder --chown=nextchair:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextchair:nodejs /app/src ./src
COPY --from=builder --chown=nextchair:nodejs /app/scripts ./scripts
COPY --from=builder --chown=nextchair:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextchair:nodejs /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder --chown=nextchair:nodejs /app/next.config.ts ./next.config.ts
COPY --from=builder --chown=nextchair:nodejs /app/tsconfig.json ./tsconfig.json
COPY --chown=nextchair:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
RUN chmod +x ./docker/entrypoint.sh
# Media Manager uploads (Phase 10) — storage/media (NOT public/) is a
# named volume mount point (see docker-compose.local.yaml). Served by
# src/app/api/media/[...path]/route.ts, not Next's static handling for
# public/ — verified against a real production build that runtime-added
# public/ files 404 (Next resolves public assets at build time, not from
# a live filesystem check). Creating this here, owned by the non-root
# user, before the volume first attaches means Docker initializes the
# volume with this ownership rather than root's.
RUN mkdir -p ./storage/media && chown -R nextchair:nodejs ./storage
FROM php:8.4-fpm-bookworm AS app
USER nextchair
EXPOSE 3000
ENV PORT=3000
ENV COMPOSER_ALLOW_SUPERUSER=1 \
PHP_OPCACHE_VALIDATE_TIMESTAMPS=0
WORKDIR /var/www/html
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
libfreetype6-dev \
libicu-dev \
libjpeg62-turbo-dev \
libonig-dev \
libpng-dev \
libxml2-dev \
libzip-dev \
unzip \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" \
bcmath \
exif \
gd \
intl \
mbstring \
opcache \
pcntl \
pdo_mysql \
xml \
zip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer /usr/bin/composer /usr/bin/composer
COPY . .
COPY --from=frontend /var/www/html/public/build ./public/build
RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist \
&& mkdir -p \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R ug+rwX storage bootstrap/cache
COPY docker/entrypoint.sh /usr/local/bin/nextchair-entrypoint
RUN chmod +x /usr/local/bin/nextchair-entrypoint
ENTRYPOINT ["/usr/local/bin/nextchair-entrypoint"]
CMD ["php-fpm", "-F"]
FROM nginx:1.27-alpine AS nginx
WORKDIR /var/www/html
COPY public ./public
COPY --from=frontend /var/www/html/public/build ./public/build
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
RUN mkdir -p /var/www/html/storage/app/public \
&& ln -s /var/www/html/storage/app/public /var/www/html/public/storage
ENTRYPOINT ["./docker/entrypoint.sh"]
+1 -107
View File
@@ -1,109 +1,3 @@
# NextChair
NextChair is a multi-tenant, white-label booking platform for barbershops.
The application is being rebuilt as a conventional Laravel web application
using PHP, MySQL, Blade, Tailwind CSS, Alpine.js, and Vite.
The audited Next.js implementation is preserved in [`legacy-nextjs`](legacy-nextjs)
as the functional and visual migration reference. The Laravel application has
its own deployment-neutral Docker option at the repository root; files inside
`legacy-nextjs` are not used by it.
## Local development
```bash
composer install
cp .env.example .env
php artisan key:generate
npm install
npm run build
php artisan serve
```
Configure the MySQL connection in `.env` before running migrations. The
application resolves a business only from the request hostname through the
`business_domains` table; it never guesses a tenant from the first business
in the database. For local multi-tenant work, map each configured hostname to
127.0.0.1 in `/etc/hosts`.
## Platform and tenant setup
Migrations create schema only. They do not create a demo business or a
business owner. After migrating, open `/install` once and create the platform
administrator. That account can sign in at `/manager/sign-in`, create
businesses, edit their primary domains, and share the one-time owner setup
link generated during provisioning.
For an existing installation created before this platform/tenant split, the
recorded `initialAdminUserId` account is allowed to sign in once and is
promoted to the platform administrator. No other business owner can use that
upgrade path.
Business provisioning creates the tenant domain, settings, booking rules,
opening hours, system roles, owner staff profile, and seven-day owner
invitation in one transaction. The owner chooses a password through that
invitation; platform administrators and business owners are separate account
types.
If demo data is useful for local development, opt into the existing seeder
explicitly:
```bash
NEXTCHAIR_SEED_DEMO=true php artisan db:seed
```
## Docker deployment
The root `compose.yaml` runs Laravel with PHP 8.4 FPM, Nginx, MySQL 8,
the database-backed queue worker, and the scheduler. It does not include or
configure Caddy; an existing reverse proxy can forward traffic to Nginx on
`127.0.0.1:8085`.
First run:
```bash
cp .env.example .env
```
Set private values in `.env`, including:
```dotenv
APP_URL=http://localhost:8085
APP_KEY=
DB_HOST=db
DB_DATABASE=nextchair
DB_USERNAME=nextchair
DB_PASSWORD=replace-with-a-private-password
DB_ROOT_PASSWORD=replace-with-a-private-root-password
```
Then build and start the stack:
```bash
docker compose build
docker compose up -d
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate
```
Then visit `http://localhost:8085/install` to create the first platform
administrator. Do not run migrations or create tenants from container startup;
the tenant owner is provisioned manually from Manager.
Migrations are intentionally not run by container startup. To inspect the
application logs:
```bash
docker compose logs -f app nginx queue scheduler db
```
The default host binding is `127.0.0.1:8085:80`. Override it with
`NEXTCHAIR_HTTP_BIND` and `NEXTCHAIR_HTTP_PORT` in `.env` when needed. MySQL
has no published host port and is reachable only as `db:3306` inside Compose.
## Verification
```bash
php artisan test
npm run build
```
Booking app as an alternative to Booksy
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace App\Enums;
enum AppointmentStatus: string
{
case Pending = 'PENDING';
case Confirmed = 'CONFIRMED';
case CheckedIn = 'CHECKED_IN';
case InProgress = 'IN_PROGRESS';
case Completed = 'COMPLETED';
case Cancelled = 'CANCELLED';
case NoShow = 'NO_SHOW';
public function isTerminal(): bool
{
return in_array($this, [self::Completed, self::Cancelled, self::NoShow], true);
}
}
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum CalendarProvider: string { case Google = 'GOOGLE'; }
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum CalendarSyncStatus: string { case Synced = 'SYNCED'; case Pending = 'PENDING'; case Failed = 'FAILED'; case Cancelled = 'CANCELLED'; }
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum GalleryPlacement: string { case Hero = 'HERO'; case Portfolio = 'PORTFOLIO'; }
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum InstallationStatus: string { case NotInstalled = 'NOT_INSTALLED'; case Installing = 'INSTALLING'; case Installed = 'INSTALLED'; case Failed = 'FAILED'; }
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum LoyaltyCardFont: string { case Sans = 'SANS'; case Serif = 'SERIF'; case Mono = 'MONO'; }
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum LoyaltyCardTextSize: string { case Sm = 'SM'; case Md = 'MD'; case Lg = 'LG'; }
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum LoyaltyTransactionType: string { case StampAdded = 'STAMP_ADDED'; case StampRemoved = 'STAMP_REMOVED'; case StampExpired = 'STAMP_EXPIRED'; case RewardRedeemed = 'REWARD_REDEEMED'; }
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum NotificationChannel: string { case Email = 'EMAIL'; case Push = 'PUSH'; case InApp = 'IN_APP'; }
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum NotificationDeliveryStatus: string { case Pending = 'PENDING'; case Scheduled = 'SCHEDULED'; case Processing = 'PROCESSING'; case Sent = 'SENT'; case Failed = 'FAILED'; case Cancelled = 'CANCELLED'; case Skipped = 'SKIPPED'; }
-8
View File
@@ -1,8 +0,0 @@
<?php
namespace App\Enums;
enum NotificationType: string
{
case BookingConfirmed = 'BOOKING_CONFIRMED'; case BookingPendingApproval = 'BOOKING_PENDING_APPROVAL'; case BookingApproved = 'BOOKING_APPROVED'; case BookingReminder = 'BOOKING_REMINDER'; case BookingRescheduled = 'BOOKING_RESCHEDULED'; case BookingCancelled = 'BOOKING_CANCELLED'; case BookingBarberChanged = 'BOOKING_BARBER_CHANGED'; case BookingCompleted = 'BOOKING_COMPLETED'; case LoyaltyStampAdded = 'LOYALTY_STAMP_ADDED'; case LoyaltyRewardEarned = 'LOYALTY_REWARD_EARNED'; case LoyaltyRewardRedeemed = 'LOYALTY_REWARD_REDEEMED'; case LoyaltyRewardExpiring = 'LOYALTY_REWARD_EXPIRING'; case StaffNewAppointment = 'STAFF_NEW_APPOINTMENT'; case StaffAppointmentRescheduled = 'STAFF_APPOINTMENT_RESCHEDULED'; case StaffAppointmentCancelled = 'STAFF_APPOINTMENT_CANCELLED'; case StaffAppointmentCheckedIn = 'STAFF_APPOINTMENT_CHECKED_IN'; case StaffAppointmentReminder = 'STAFF_APPOINTMENT_REMINDER'; case StaffScheduleChanged = 'STAFF_SCHEDULE_CHANGED'; case StaffInvitationReceived = 'STAFF_INVITATION_RECEIVED'; case OwnerNewBooking = 'OWNER_NEW_BOOKING'; case OwnerBookingCancelled = 'OWNER_BOOKING_CANCELLED'; case OwnerNoShow = 'OWNER_NO_SHOW'; case OwnerStaffInvitationAccepted = 'OWNER_STAFF_INVITATION_ACCEPTED'; case OwnerRepeatedDeliveryFailure = 'OWNER_REPEATED_DELIVERY_FAILURE';
}
-24
View File
@@ -1,24 +0,0 @@
<?php
namespace App\Enums;
enum Permission: string
{
case ManageBusinessProfile = 'MANAGE_BUSINESS_PROFILE';
case ManageBookingRules = 'MANAGE_BOOKING_RULES';
case ManageServices = 'MANAGE_SERVICES';
case ManageStaff = 'MANAGE_STAFF';
case ManageLoyaltyProgramme = 'MANAGE_LOYALTY_PROGRAMME';
case ManageNotificationSettings = 'MANAGE_NOTIFICATION_SETTINGS';
case ViewAllStaffAppointments = 'VIEW_ALL_STAFF_APPOINTMENTS';
case ManageAllAppointments = 'MANAGE_ALL_APPOINTMENTS';
case ManageOwnAppointments = 'MANAGE_OWN_APPOINTMENTS';
case CheckInCustomers = 'CHECK_IN_CUSTOMERS';
case ManageLoyaltyStamps = 'MANAGE_LOYALTY_STAMPS';
case ManageOwnAvailability = 'MANAGE_OWN_AVAILABILITY';
case ViewCustomerDirectory = 'VIEW_CUSTOMER_DIRECTORY';
case BookOnBehalfOfCustomer = 'BOOK_ON_BEHALF_OF_CUSTOMER';
case ViewReporting = 'VIEW_REPORTING';
case ManageMediaLibrary = 'MANAGE_MEDIA_LIBRARY';
case ManageReviews = 'MANAGE_REVIEWS';
}
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum RewardPositionMode: string { case AfterAllStamps = 'AFTER_ALL_STAMPS'; case FinalSlotIsReward = 'FINAL_SLOT_IS_REWARD'; }
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum RewardStatus: string { case Available = 'AVAILABLE'; case Redeemed = 'REDEEMED'; case Expired = 'EXPIRED'; }
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum RewardType: string { case Percentage = 'PERCENTAGE'; case FixedAmount = 'FIXED_AMOUNT'; case FreeService = 'FREE_SERVICE'; }
-10
View File
@@ -1,10 +0,0 @@
<?php
namespace App\Enums;
enum StaffStatus: string
{
case Active = 'ACTIVE';
case OnBreak = 'ON_BREAK';
case Inactive = 'INACTIVE';
}
-5
View File
@@ -1,5 +0,0 @@
<?php
namespace App\Enums;
enum TimeOffStatus: string { case Pending = 'PENDING'; case Approved = 'APPROVED'; case Declined = 'DECLINED'; }
-114
View File
@@ -1,114 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\CustomerProfile;
use App\Models\User;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class AuthController extends Controller
{
public function showLogin(TenantContext $tenant): View
{
return view('auth.sign-in', ['business' => $tenant->require()]);
}
public function showWelcome(TenantContext $tenant): View
{
return view('auth.welcome', ['business' => $tenant->require()]);
}
public function showVerify(TenantContext $tenant): View
{
return view('auth.verify', ['business' => $tenant->require()]);
}
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email', 'max:190'],
'password' => ['required', 'string'],
]);
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
return back()->withErrors(['email' => 'Those details do not match our records.'])->onlyInput('email');
}
$request->session()->regenerate();
if ($request->user()->isSuperAdmin) {
return redirect()->intended(route('manager.dashboard'));
}
return redirect()->intended(route('customer.dashboard'));
}
public function showRegister(TenantContext $tenant): View
{
return view('auth.register', ['business' => $tenant->require()]);
}
public function showForgot(TenantContext $tenant): View
{
return view('auth.forgot-password', ['business' => $tenant->require()]);
}
public function forgot(Request $request): RedirectResponse
{
$request->validate(['email' => ['required', 'email']]);
return back()->with('status', 'If an account exists for that email, reset instructions will be sent shortly.');
}
public function register(Request $request, TenantContext $tenant): RedirectResponse
{
$validated = $request->validate([
'firstName' => ['required', 'string', 'max:80'],
'lastName' => ['required', 'string', 'max:80'],
'email' => ['required', 'email', 'max:190', 'unique:User,email'],
'phone' => ['nullable', 'string', 'max:40'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
$user = DB::transaction(function () use ($validated, $tenant): User {
$user = User::create([
'name' => trim($validated['firstName'].' '.$validated['lastName']),
'email' => strtolower(trim($validated['email'])),
'phone' => $validated['phone'] ?? null,
'passwordHash' => Hash::make($validated['password']),
]);
CustomerProfile::create([
'businessId' => $tenant->require()->id,
'userId' => $user->id,
'firstName' => trim($validated['firstName']),
'lastName' => trim($validated['lastName']),
'email' => strtolower(trim($validated['email'])),
'phone' => $validated['phone'] ?? null,
'isGuest' => false,
]);
return $user;
});
Auth::login($user);
$request->session()->regenerate();
return redirect()->route('customer.dashboard');
}
public function logout(Request $request): RedirectResponse
{
$wasSuperAdmin = $request->user()?->isSuperAdmin === true;
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route($wasSuperAdmin ? 'manager.login' : 'public.home');
}
}
-106
View File
@@ -1,106 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Availability\AvailabilityService;
use App\Services\Booking\BookingService;
use App\Services\Tenant\TenantContext;
use App\Models\Appointment;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Throwable;
class BookingController extends Controller
{
public function services(TenantContext $tenant): View
{
$business = $tenant->require();
$categories = $business->serviceCategories()->where('isActive', true)->with(['services' => fn ($query) => $query->where('isActive', true)->where('allowOnlineBooking', true)->orderBy('displayOrder')])->orderBy('displayOrder')->get();
return view('booking.services', compact('business', 'categories'));
}
public function selectServices(Request $request): RedirectResponse
{
$ids = array_values(array_filter((array) $request->input('serviceIds')));
if ($ids === []) return back()->withErrors(['serviceIds' => 'Choose at least one service.']);
$request->session()->put('booking.serviceIds', $ids);
return redirect()->route('booking.time');
}
public function time(Request $request, TenantContext $tenant, AvailabilityService $availability): View|RedirectResponse
{
$business = $tenant->require();
$serviceIds = $request->session()->get('booking.serviceIds', []);
if ($serviceIds === []) return redirect()->route('booking.services');
$date = CarbonImmutable::parse($request->query('date', now($business->timezone)->toDateString()), $business->timezone);
$slots = $availability->slots($business, $date, $serviceIds);
$services = $business->services()->whereIn('id', $serviceIds)->get();
return view('booking.time', compact('business', 'date', 'slots', 'services'));
}
public function selectTime(Request $request): RedirectResponse
{
$validated = $request->validate(['staffId' => ['required', 'string'], 'startAt' => ['required', 'date']]);
$request->session()->put('booking.time', $validated);
return redirect()->route('booking.details');
}
public function details(Request $request, TenantContext $tenant): View|RedirectResponse
{
$business = $tenant->require();
if (! $request->session()->has('booking.time')) return redirect()->route('booking.time');
return view('booking.details', compact('business'));
}
public function saveDetails(Request $request): RedirectResponse
{
$validated = $request->validate(['firstName' => ['required', 'string', 'max:80'], 'lastName' => ['required', 'string', 'max:80'], 'email' => ['required', 'email', 'max:190'], 'phone' => ['nullable', 'string', 'max:40'], 'notes' => ['nullable', 'string', 'max:2000']]);
$request->session()->put('booking.customer', $validated);
return redirect()->route('booking.review');
}
public function review(Request $request, TenantContext $tenant): View|RedirectResponse
{
$business = $tenant->require();
$draft = $request->session()->get('booking');
if (! isset($draft['serviceIds'], $draft['time'], $draft['customer'])) return redirect()->route('booking.services');
$services = $business->services()->whereIn('id', $draft['serviceIds'])->get();
$staff = $business->staff()->with('user')->find($draft['time']['staffId']);
if (! $staff) return redirect()->route('booking.time')->withErrors(['time' => 'That barber is no longer available.']);
return view('booking.review', ['business' => $business, 'services' => $services, 'staff' => $staff, 'draft' => $draft, 'total' => $services->sum('price')]);
}
public function confirm(Request $request, TenantContext $tenant, BookingService $booking): RedirectResponse
{
$business = $tenant->require();
$draft = $request->session()->get('booking');
if (! isset($draft['serviceIds'], $draft['time'], $draft['customer'])) return redirect()->route('booking.services');
try {
$result = $booking->create($business, array_merge($draft['customer'], $draft['time'], ['serviceIds' => $draft['serviceIds']]));
} catch (Throwable $exception) {
return redirect()->route('booking.time')->withErrors(['booking' => $exception->getMessage()]);
}
$request->session()->forget('booking');
$request->session()->put('booking.result', [
'appointmentId' => $result['appointment']->id,
'accessToken' => $result['accessToken'],
]);
return redirect()->route('booking.confirmation');
}
public function confirmation(Request $request, TenantContext $tenant): View|RedirectResponse
{
$business = $tenant->require();
$result = $request->session()->get('booking.result');
if (! $result) return redirect()->route('public.home');
$request->session()->forget('booking.result');
$appointment = Appointment::query()
->where('businessId', $business->id)
->with(['customer', 'staff.user', 'services.service'])
->find($result['appointmentId'] ?? null);
if (! $appointment) return redirect()->route('public.home');
return view('booking.confirmation', compact('business', 'appointment'));
}
}
-8
View File
@@ -1,8 +0,0 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -1,36 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Loyalty\LoyaltyService;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CustomerAccountController extends Controller
{
public function __invoke(Request $request, TenantContext $tenant, LoyaltyService $loyalty): View
{
$business = $tenant->require();
$business->loadMissing(['staff.user', 'serviceCategories.services']);
$profile = $request->user()->customerProfiles()
->where('businessId', $business->id)
->first();
$appointments = $profile?->appointments()
->with(['staff.user', 'services.service'])
->where('startAt', '>=', now()->utc())
->orderBy('startAt')
->get() ?? collect();
$loyaltySummary = $profile ? $loyalty->summary($business, $profile) : null;
return view('customer.dashboard', [
'business' => $business,
'profile' => $profile,
'appointments' => $appointments,
'user' => $request->user(),
'loyaltySummary' => $loyaltySummary,
]);
}
}
@@ -1,42 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\{AppointmentStatus, NotificationType};
use App\Models\Appointment;
use App\Services\Notifications\NotificationService;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class CustomerAppointmentsController extends Controller
{
public function index(Request $request, TenantContext $tenant): View
{
$business = $tenant->require(); $customer = $request->user()->customerProfiles()->where('businessId', $business->id)->firstOrFail();
$appointments = $customer->appointments()->with(['staff.user', 'services.service'])->orderByDesc('startAt')->get();
return view('customer.appointments', compact('business', 'appointments'));
}
public function show(Request $request, TenantContext $tenant, Appointment $appointment): View
{
$business = $tenant->require(); $customer = $request->user()->customerProfiles()->where('businessId', $business->id)->firstOrFail();
abort_unless($appointment->businessId === $business->id && $appointment->customerId === $customer->id, 404);
$appointment->load(['staff.user', 'services.service']);
return view('customer.appointment', compact('business', 'appointment'));
}
public function cancel(Request $request, TenantContext $tenant, Appointment $appointment, NotificationService $notifications): RedirectResponse
{
$business = $tenant->require(); $customer = $request->user()->customerProfiles()->where('businessId', $business->id)->firstOrFail();
abort_unless($appointment->businessId === $business->id && $appointment->customerId === $customer->id, 404);
if (! in_array($appointment->status, [AppointmentStatus::Pending, AppointmentStatus::Confirmed], true)) throw new RuntimeException('This appointment cannot be cancelled.');
$window = (int) ($business->bookingRule?->cancellationWindowHours ?? 24);
if ($appointment->startAt->isBefore(now()->addHours($window))) throw new RuntimeException('This appointment is inside the cancellation window.');
$appointment->status = AppointmentStatus::Cancelled; $appointment->cancelledAt = now(); $appointment->cancelledByUserId = $request->user()->id; $appointment->cancellationReason = 'Cancelled by customer'; $appointment->save();
$notifications->create($business, NotificationType::BookingCancelled, 'Your appointment has been cancelled.', "appointment-cancelled:{$appointment->id}", null, $customer->id, $appointment->id, '/appointments', $business->name);
return redirect()->route('customer.appointments')->with('status', 'Appointment cancelled.');
}
}
@@ -1,21 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Loyalty\LoyaltyService;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CustomerLoyaltyController extends Controller
{
public function __invoke(Request $request, TenantContext $tenant, LoyaltyService $loyalty): View
{
$business = $tenant->require();
$customer = $request->user()->customerProfiles()->where('businessId', $business->id)->firstOrFail();
$summary = $loyalty->summary($business, $customer);
$history = $customer->loyaltyAccounts()->with(['transactions' => fn ($query) => $query->latest('createdAt')->limit(20), 'programme'])->get()->flatMap->transactions->sortByDesc('createdAt')->take(20);
return view('customer.loyalty', compact('business', 'customer', 'summary', 'history'));
}
}
@@ -1,21 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CustomerProfileController extends Controller
{
public function edit(Request $request, TenantContext $tenant): View
{
$business = $tenant->require(); $profile = $request->user()->customerProfiles()->where('businessId', $business->id)->firstOrFail(); return view('customer.profile', compact('business', 'profile'));
}
public function update(Request $request, TenantContext $tenant): RedirectResponse
{
$business = $tenant->require(); $profile = $request->user()->customerProfiles()->where('businessId', $business->id)->firstOrFail(); $data = $request->validate(['firstName' => ['required', 'string', 'max:80'], 'lastName' => ['required', 'string', 'max:80'], 'phone' => ['nullable', 'string', 'max:40']]); $profile->fill($data); $profile->save(); $request->user()->update(['name' => trim($data['firstName'].' '.$data['lastName']), 'phone' => $data['phone'] ?? null]); return back()->with('status', 'Profile updated.');
}
}
@@ -1,76 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\InstallationStatus;
use App\Models\ApplicationInstallation;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class InstallController extends Controller
{
public function create(Request $request): View|RedirectResponse
{
if ($this->isInstalled()) {
return $request->user()?->isSuperAdmin
? redirect()->route('manager.dashboard')
: redirect()->route('manager.login');
}
return view('install');
}
public function store(Request $request): RedirectResponse
{
if ($this->isInstalled()) {
return redirect()->route('manager.login');
}
$data = $request->validate([
'adminName' => ['required', 'string', 'max:160'],
'adminEmail' => ['required', 'email', 'max:190', 'unique:User,email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
$admin = DB::transaction(function () use ($data): User {
$admin = User::create([
'name' => trim($data['adminName']),
'email' => strtolower(trim($data['adminEmail'])),
'passwordHash' => Hash::make($data['password']),
'isSuperAdmin' => true,
'emailVerified' => now(),
]);
ApplicationInstallation::create([
'id' => 'singleton',
'status' => InstallationStatus::Installed,
'installedAt' => now(),
'installedVersion' => 'laravel-1',
'initialAdminUserId' => $admin->id,
'primaryBusinessId' => null,
'metadata' => ['installer' => 'platform-admin-only'],
]);
return $admin;
});
Auth::login($admin);
$request->session()->regenerate();
return redirect()->route('manager.dashboard')->with('status', 'Platform setup complete. Create your first business from Manager.');
}
private function isInstalled(): bool
{
try {
return ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists();
} catch (\Throwable) {
return false;
}
}
}
@@ -1,43 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\{LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode, RewardType};
use App\Models\LoyaltyProgramme;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class LoyaltySettingsController extends Controller
{
public function edit(TenantContext $tenant): View
{
$business = $tenant->require();
$programme = $business->loyaltyProgramme ?: new LoyaltyProgramme(['stampsRequired' => 10, 'rewardType' => RewardType::Percentage, 'rewardPosition' => RewardPositionMode::AfterAllStamps, 'cardFont' => LoyaltyCardFont::Sans, 'cardTitleTextSize' => LoyaltyCardTextSize::Md]);
$services = $business->services()->where('isActive', true)->orderBy('name')->get();
return view('owner.loyalty-settings', compact('business', 'programme', 'services'));
}
public function update(Request $request, TenantContext $tenant): RedirectResponse
{
$business = $tenant->require();
$data = $request->validate([
'isEnabled' => ['nullable', 'boolean'], 'stampsRequired' => ['required', 'integer', 'min:1', 'max:10'],
'rewardPosition' => ['required', 'in:AFTER_ALL_STAMPS,FINAL_SLOT_IS_REWARD'], 'rewardType' => ['required', 'in:PERCENTAGE,FIXED_AMOUNT,FREE_SERVICE'],
'rewardValue' => ['nullable', 'numeric', 'min:0'], 'rewardLabel' => ['nullable', 'string', 'max:100'], 'freeServiceId' => ['nullable', 'string'],
'autoApplyReward' => ['nullable', 'boolean'], 'requiresStaffApproval' => ['nullable', 'boolean'], 'rewardExpiresAfterDays' => ['nullable', 'integer', 'min:1'],
'cardTitleText' => ['nullable', 'string', 'max:80'], 'cardFont' => ['required', 'in:SANS,SERIF,MONO'], 'cardTitleTextSize' => ['required', 'in:SM,MD,LG'],
'termsLine' => ['nullable', 'string', 'max:255'],
]);
if (($data['rewardType'] ?? null) === 'FREE_SERVICE') {
abort_unless($data['freeServiceId'] && $business->services()->whereKey($data['freeServiceId'])->where('isActive', true)->exists(), 422);
}
$data['businessId'] = $business->id;
$data['isEnabled'] = $request->boolean('isEnabled');
$data['autoApplyReward'] = $request->boolean('autoApplyReward');
$data['requiresStaffApproval'] = $request->boolean('requiresStaffApproval');
LoyaltyProgramme::updateOrCreate(['businessId' => $business->id], $data);
return back()->with('status', 'Loyalty settings saved.');
}
}
@@ -1,127 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Business;
use App\Models\BusinessDomain;
use App\Services\Businesses\BusinessProvisioningService;
use App\Services\Tenant\DomainNormalizer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
use InvalidArgumentException;
class ManagerBusinessController extends Controller
{
public function create(): View
{
return view('manager.businesses.create');
}
public function store(Request $request, BusinessProvisioningService $provisioner, DomainNormalizer $domains): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:160'],
'shortName' => ['nullable', 'string', 'max:80'],
'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug'],
'domain' => ['required', 'string', 'max:253'],
'email' => ['nullable', 'email', 'max:190'],
'phone' => ['nullable', 'string', 'max:40'],
'timezone' => ['required', 'timezone'],
'currency' => ['required', 'string', 'size:3'],
'ownerName' => ['required', 'string', 'max:160'],
'ownerEmail' => ['required', 'email', 'max:190', 'unique:User,email'],
]);
try {
$data['domain'] = $domains->normalize($data['domain']);
} catch (InvalidArgumentException $exception) {
return back()->withErrors(['domain' => $exception->getMessage()])->withInput();
}
if (BusinessDomain::query()->where('hostname', $data['domain'])->exists()) {
return back()->withErrors(['domain' => 'That hostname is already assigned to another business.'])->withInput();
}
try {
$result = $provisioner->provision($data, [
'name' => $data['ownerName'],
'email' => $data['ownerEmail'],
], $request->user());
} catch (InvalidArgumentException $exception) {
return back()->withErrors(['domain' => $exception->getMessage()])->withInput();
}
$hostname = $result->business->primaryDomain()->value('hostname');
$invitationUrl = request()->getScheme().'://'.$hostname.'/invite/'.$result->ownerInvitation->token;
return redirect()->route('manager.businesses.show', $result->business)
->with('status', 'Business created. The owner can finish setup using the invitation link below.')
->with('owner_invitation_url', $invitationUrl);
}
public function show(Business $business): View
{
$business->load(['domains', 'settings', 'bookingRule', 'hours', 'staff.user', 'staff.role']);
return view('manager.businesses.show', compact('business'));
}
public function edit(Business $business): View
{
$business->load('primaryDomain');
return view('manager.businesses.edit', compact('business'));
}
public function update(Request $request, Business $business, DomainNormalizer $domains): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:160'],
'shortName' => ['nullable', 'string', 'max:80'],
'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug,'.$business->id.',id'],
'domain' => ['required', 'string', 'max:253'],
'email' => ['nullable', 'email', 'max:190'],
'phone' => ['nullable', 'string', 'max:40'],
'timezone' => ['required', 'timezone'],
'currency' => ['required', 'string', 'size:3'],
]);
try {
$hostname = $domains->normalize($data['domain']);
} catch (InvalidArgumentException $exception) {
return back()->withErrors(['domain' => $exception->getMessage()])->withInput();
}
$domainTaken = BusinessDomain::query()
->where('hostname', $hostname)
->where('businessId', '!=', $business->id)
->exists();
if ($domainTaken) {
return back()->withErrors(['domain' => 'That hostname is already assigned to another business.'])->withInput();
}
DB::transaction(function () use ($business, $data, $hostname): void {
$business->update([
'name' => trim($data['name']),
'shortName' => filled($data['shortName'] ?? null) ? trim($data['shortName']) : null,
'slug' => strtolower(trim($data['slug'])),
'email' => filled($data['email'] ?? null) ? strtolower(trim($data['email'])) : null,
'phone' => filled($data['phone'] ?? null) ? trim($data['phone']) : null,
'timezone' => $data['timezone'],
'currency' => strtoupper($data['currency']),
]);
$primary = $business->domains()->where('isPrimary', true)->first();
if ($primary) {
$primary->update(['hostname' => $hostname]);
} else {
BusinessDomain::create(['businessId' => $business->id, 'hostname' => $hostname, 'isPrimary' => true, 'isActive' => true]);
}
});
return redirect()->route('manager.businesses.show', $business)->with('status', 'Business details updated.');
}
}
@@ -1,23 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Business;
use Illuminate\View\View;
class ManagerDashboardController extends Controller
{
public function __invoke(): View
{
$businesses = Business::query()
->with(['primaryDomain', 'staff.user', 'staff.role'])
->withCount(['staff', 'domains'])
->latest('createdAt')
->get();
return view('manager.dashboard', [
'businesses' => $businesses,
'businessCount' => $businesses->count(),
]);
}
}
@@ -1,27 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request, TenantContext $tenant): View
{
$business = $tenant->require();
$notifications = $request->user()->receivedNotifications()->where('businessId', $business->id)->latest('createdAt')->with('appointment')->paginate(20);
return view('customer.notifications', compact('business', 'notifications'));
}
public function read(Request $request, TenantContext $tenant, string $notification): RedirectResponse
{
$tenant->require();
$item = $request->user()->receivedNotifications()->where('businessId', $tenant->id())->findOrFail($notification);
$item->readAt = now();
$item->save();
return back();
}
}
@@ -1,32 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\{BookingRule, BusinessSettings};
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OwnerBusinessSettingsController extends Controller
{
public function business(TenantContext $tenant): View
{
$business = $tenant->require(); return view('owner.business-settings', compact('business'));
}
public function updateBusiness(Request $request, TenantContext $tenant): RedirectResponse
{
$business = $tenant->require(); $data = $request->validate(['name' => ['required', 'string', 'max:190'], 'shortName' => ['nullable', 'string', 'max:80'], 'description' => ['nullable', 'string', 'max:2000'], 'phone' => ['nullable', 'string', 'max:40'], 'email' => ['nullable', 'email', 'max:190'], 'website' => ['nullable', 'url', 'max:190'], 'timezone' => ['required', 'timezone'], 'currency' => ['required', 'string', 'size:3']]); $business->fill($data); $business->save(); return back()->with('status', 'Business details saved.');
}
public function bookingRules(TenantContext $tenant): View
{
$business = $tenant->require(); $rules = $business->bookingRule ?: new BookingRule(['minNoticeMinutes' => 120, 'maxAdvanceDays' => 60, 'slotIntervalMinutes' => 30]); return view('owner.booking-rules', compact('business', 'rules'));
}
public function updateBookingRules(Request $request, TenantContext $tenant): RedirectResponse
{
$business = $tenant->require(); $data = $request->validate(['minNoticeMinutes' => ['required', 'integer', 'min:0', 'max:10080'], 'maxAdvanceDays' => ['required', 'integer', 'min:1', 'max:365'], 'slotIntervalMinutes' => ['required', 'integer', 'in:5,10,15,20,30,60'], 'cancellationWindowHours' => ['required', 'integer', 'min:0'], 'rescheduleWindowHours' => ['required', 'integer', 'min:0'], 'bufferBeforeMinutes' => ['required', 'integer', 'min:0'], 'bufferAfterMinutes' => ['required', 'integer', 'min:0'], 'allowGuestBooking' => ['nullable', 'boolean'], 'requireApproval' => ['nullable', 'boolean'], 'customerCanChooseStaff' => ['nullable', 'boolean']]); $data['businessId'] = $business->id; foreach (['allowGuestBooking', 'requireApproval', 'customerCanChooseStaff'] as $flag) $data[$flag] = $request->boolean($flag); BookingRule::updateOrCreate(['businessId' => $business->id], $data); return back()->with('status', 'Booking rules saved.');
}
}
@@ -1,37 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\BusinessHours;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OwnerHoursController extends Controller
{
private const DAYS = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'];
public function edit(TenantContext $tenant): View
{
$business = $tenant->require(); $hours = $business->hours()->get()->keyBy('dayOfWeek'); return view('owner.hours', ['business' => $business, 'hours' => $hours, 'days' => self::DAYS]);
}
public function update(Request $request, TenantContext $tenant): RedirectResponse
{
$business = $tenant->require();
foreach (self::DAYS as $day) {
$prefix = strtolower($day); $closed = $request->boolean("{$prefix}_closed");
$open = $closed ? null : $this->minutes($request->input("{$prefix}_open")); $close = $closed ? null : $this->minutes($request->input("{$prefix}_close"));
if (! $closed && ($open === null || $close === null || $close <= $open)) return back()->withErrors(['hours' => "Enter valid opening hours for {$day}."])->withInput();
BusinessHours::updateOrCreate(['businessId' => $business->id, 'dayOfWeek' => $day], ['isClosed' => $closed, 'opensAtMinutes' => $open, 'closesAtMinutes' => $close]);
}
return back()->with('status', 'Opening hours saved.');
}
private function minutes(?string $value): ?int
{
if (! $value || ! preg_match('/^(\d{2}):(\d{2})$/', $value, $matches)) return null;
$minutes = ((int) $matches[1] * 60) + (int) $matches[2]; return $minutes >= 0 && $minutes <= 1439 ? $minutes : null;
}
}
@@ -1,60 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\StaffInvitation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class OwnerInvitationController extends Controller
{
public function show(string $token): View|RedirectResponse
{
$invitation = $this->findValidInvitation($token);
return view('auth.owner-invite', [
'invitation' => $invitation,
'business' => $invitation->business,
]);
}
public function accept(Request $request, string $token): RedirectResponse
{
$data = $request->validate([
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
$invitation = $this->findValidInvitation($token);
DB::transaction(function () use ($invitation, $data): void {
$invitation->staff->user->update([
'passwordHash' => Hash::make($data['password']),
'emailVerified' => now(),
]);
$invitation->update(['acceptedAt' => now()]);
});
Auth::login($invitation->staff->user->fresh());
$request->session()->regenerate();
$hostname = $invitation->business->primaryDomain?->hostname;
$destination = $hostname
? request()->getScheme().'://'.$hostname.'/owner'
: route('owner.dashboard');
return redirect()->to($destination)->with('status', 'Your owner workspace is ready.');
}
private function findValidInvitation(string $token): StaffInvitation
{
$invitation = StaffInvitation::query()->with(['business.primaryDomain', 'staff.user'])->where('token', $token)->firstOrFail();
abort_if($invitation->acceptedAt || $invitation->expiresAt->isPast(), 410, 'This invitation has expired or has already been used.');
return $invitation;
}
}
@@ -1,16 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\AppointmentStatus;
use App\Services\Tenant\TenantContext;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class OwnerReportsController extends Controller
{
public function __invoke(TenantContext $tenant): View
{
$business = $tenant->require(); $month = Carbon::now($business->timezone)->startOfMonth(); $appointments = $business->appointments()->with('services')->where('startAt', '>=', $month->utc())->get(); $completed = $appointments->where('status', AppointmentStatus::Completed); $revenue = $completed->sum(fn ($appointment) => $appointment->services->sum('priceAtBooking') - (float) ($appointment->discountAmount ?? 0)); $staffCount = $business->staff()->where('status', 'ACTIVE')->count(); return view('owner.reports', compact('business', 'appointments', 'completed', 'revenue', 'staffCount'));
}
}
@@ -1,21 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OwnerReviewsController extends Controller
{
public function index(TenantContext $tenant): View
{
$business = $tenant->require(); $reviews = $business->reviews()->latest('publishedAt')->get(); return view('owner.reviews', compact('business', 'reviews'));
}
public function toggle(Request $request, TenantContext $tenant, string $review): RedirectResponse
{
$business = $tenant->require(); $item = $business->reviews()->findOrFail($review); $item->isHidden = ! $item->isHidden; $item->save(); return back()->with('status', 'Review visibility updated.');
}
}
@@ -1,43 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\{Service, ServiceCategory};
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OwnerServicesController extends Controller
{
public function index(TenantContext $tenant): View
{
$business = $tenant->require();
$categories = $business->serviceCategories()->with('services')->orderBy('displayOrder')->get();
return view('owner.services', compact('business', 'categories'));
}
public function store(Request $request, TenantContext $tenant): RedirectResponse
{
$business = $tenant->require();
$data = $request->validate(['categoryId' => ['nullable', 'string'], 'categoryName' => ['nullable', 'string', 'max:80'], 'name' => ['required', 'string', 'max:120'], 'description' => ['nullable', 'string', 'max:1000'], 'durationMinutes' => ['required', 'integer', 'min:5', 'max:480'], 'price' => ['required', 'numeric', 'min:0'], 'isActive' => ['nullable', 'boolean'], 'allowOnlineBooking' => ['nullable', 'boolean']]);
$category = $data['categoryId'] ? $business->serviceCategories()->findOrFail($data['categoryId']) : ServiceCategory::create(['businessId' => $business->id, 'name' => $data['categoryName'] ?: 'General', 'displayOrder' => $business->serviceCategories()->count()]);
Service::create(['businessId' => $business->id, 'categoryId' => $category->id, 'name' => $data['name'], 'description' => $data['description'] ?? null, 'durationMinutes' => $data['durationMinutes'], 'price' => $data['price'], 'isActive' => $request->boolean('isActive', true), 'allowOnlineBooking' => $request->boolean('allowOnlineBooking', true)]);
return back()->with('status', 'Service added.');
}
public function update(Request $request, TenantContext $tenant, Service $service): RedirectResponse
{
$business = $tenant->require();
abort_unless($service->businessId === $business->id, 404);
$data = $request->validate(['name' => ['required', 'string', 'max:120'], 'description' => ['nullable', 'string', 'max:1000'], 'durationMinutes' => ['required', 'integer', 'min:5', 'max:480'], 'price' => ['required', 'numeric', 'min:0'], 'isActive' => ['nullable', 'boolean'], 'allowOnlineBooking' => ['nullable', 'boolean']]);
$service->fill($data); $service->isActive = $request->boolean('isActive'); $service->allowOnlineBooking = $request->boolean('allowOnlineBooking'); $service->save();
return back()->with('status', 'Service updated.');
}
public function destroy(TenantContext $tenant, Service $service): RedirectResponse
{
$business = $tenant->require(); abort_unless($service->businessId === $business->id, 404); $service->isActive = false; $service->allowOnlineBooking = false; $service->save();
return back()->with('status', 'Service archived.');
}
}
@@ -1,31 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\Permission;
use App\Models\{Role, RolePermission, StaffInvitation, StaffProfile, User};
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\View\View;
class OwnerTeamController extends Controller
{
public function index(TenantContext $tenant): View
{
$business = $tenant->require(); $staff = $business->staff()->with(['user', 'role', 'invitation'])->orderBy('createdAt')->get(); return view('owner.team', compact('business', 'staff'));
}
public function store(Request $request, TenantContext $tenant): RedirectResponse
{
$business = $tenant->require(); $data = $request->validate(['firstName' => ['required', 'string', 'max:80'], 'lastName' => ['required', 'string', 'max:80'], 'email' => ['required', 'email', 'max:190', 'unique:User,email'], 'bio' => ['nullable', 'string', 'max:1000']]);
$barberRole = Role::firstOrCreate(['businessId' => $business->id, 'name' => 'Barber'], ['isSystem' => true]);
foreach ([Permission::ManageOwnAppointments, Permission::CheckInCustomers, Permission::ManageOwnAvailability, Permission::BookOnBehalfOfCustomer, Permission::ManageLoyaltyStamps] as $permission) RolePermission::firstOrCreate(['roleId' => $barberRole->id, 'permission' => $permission]);
$user = User::create(['name' => trim($data['firstName'].' '.$data['lastName']), 'email' => strtolower($data['email']), 'passwordHash' => Hash::make(Str::random(32))]);
$profile = StaffProfile::create(['userId' => $user->id, 'businessId' => $business->id, 'roleId' => $barberRole->id, 'bio' => $data['bio'] ?? null, 'status' => 'ACTIVE', 'isBookableOnline' => true]);
StaffInvitation::create(['businessId' => $business->id, 'staffId' => $profile->id, 'email' => $user->email, 'token' => Str::random(48), 'invitedByUserId' => $request->user()->id, 'expiresAt' => now()->addDays(7)]);
return back()->with('status', 'Team member added. Share their invitation link when ready.');
}
}
@@ -1,67 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\InstallationStatus;
use App\Models\ApplicationInstallation;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class PlatformAuthController extends Controller
{
public function showLogin(Request $request): View|RedirectResponse
{
if ($request->user()?->isSuperAdmin) {
return redirect()->route('manager.dashboard');
}
if (! ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) {
return redirect()->route('install');
}
return view('auth.platform-sign-in');
}
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email', 'max:190'],
'password' => ['required', 'string'],
]);
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
Auth::logout();
return back()->withErrors(['email' => 'Those platform administrator details do not match our records.'])->onlyInput('email');
}
$user = $request->user();
if (! $user?->isSuperAdmin) {
$installation = ApplicationInstallation::query()
->where('status', InstallationStatus::Installed)
->first();
$canUpgradeLegacyAdmin = ! User::query()->where('isSuperAdmin', true)->exists()
&& $installation?->initialAdminUserId === $user?->id;
if (! $canUpgradeLegacyAdmin) {
Auth::logout();
return back()->withErrors(['email' => 'Those platform administrator details do not match our records.'])->onlyInput('email');
}
// Existing installations created before platform administration
// was separated may have only an initial business owner. Promote
// that exact recorded account once so the migration is usable.
$user->forceFill(['isSuperAdmin' => true])->save();
Auth::setUser($user->fresh());
}
$request->session()->regenerate();
return redirect()->intended(route('manager.dashboard'));
}
}
@@ -1,22 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Tenant\TenantContext;
use Illuminate\View\View;
class PublicBusinessController extends Controller
{
public function __invoke(TenantContext $tenant): View
{
$business = $tenant->business();
$business?->load(['locations', 'galleryImages' => fn ($query) => $query->where('isActive', true)->orderBy('displayOrder'), 'reviews' => fn ($query) => $query->where('isHidden', false)->latest('publishedAt')->limit(6), 'staff.user', 'hours']);
$categories = $business?->serviceCategories()
->where('isActive', true)
->with(['services' => fn ($query) => $query->where('isActive', true)->where('allowOnlineBooking', true)->orderBy('displayOrder')])
->orderBy('displayOrder')
->get() ?? collect();
return view('public.business', compact('business', 'categories'));
}
}
@@ -1,61 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\{AppointmentStatus, NotificationType, Permission};
use App\Models\Appointment;
use App\Services\Loyalty\LoyaltyService;
use App\Services\Notifications\NotificationService;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use RuntimeException;
class StaffAppointmentController extends Controller
{
public function status(Request $request, TenantContext $tenant, Appointment $appointment, LoyaltyService $loyalty, NotificationService $notifications): RedirectResponse
{
$business = $tenant->require();
abort_unless($appointment->businessId === $business->id, 404);
$staff = $request->user()->staffProfiles()->where('businessId', $business->id)->with('role.permissions')->firstOrFail();
abort_unless($appointment->staffId === $staff->id || $staff->hasPermission(Permission::ManageAllAppointments), 403);
$validated = $request->validate(['status' => ['required', 'in:PENDING,CONFIRMED,CHECKED_IN,IN_PROGRESS,COMPLETED,CANCELLED,NO_SHOW'], 'reason' => ['nullable', 'string', 'max:255']]);
$next = AppointmentStatus::from($validated['status']);
$this->assertTransition($appointment->status, $next);
$appointment->loadMissing(['customer', 'services', 'business']);
$appointment->status = $next;
if ($next === AppointmentStatus::Cancelled) {
$appointment->cancellationReason = $validated['reason'] ?? null;
$appointment->cancelledAt = now();
$appointment->cancelledByUserId = $request->user()->id;
}
$appointment->save();
if ($next === AppointmentStatus::Completed) {
try { $loyalty->awardForCompletedAppointment($appointment, $staff); } catch (RuntimeException) { /* loyalty is optional and must not block completion */ }
}
$type = match ($next) {
AppointmentStatus::Cancelled => NotificationType::BookingCancelled,
AppointmentStatus::Completed => NotificationType::BookingCompleted,
AppointmentStatus::Confirmed => NotificationType::BookingApproved,
default => null,
};
if ($type) $notifications->create($business, $type, "Your appointment status is now ".strtolower(str_replace('_', ' ', $next->value)).'.', "appointment-status:{$appointment->id}:{$next->value}", $appointment->customer->userId, $appointment->customer->id, $appointment->id, '/account', $business->name);
return back()->with('status', 'Appointment updated.');
}
private function assertTransition(AppointmentStatus $current, AppointmentStatus $next): void
{
$allowed = [
AppointmentStatus::Pending->value => [AppointmentStatus::Confirmed, AppointmentStatus::Cancelled],
AppointmentStatus::Confirmed->value => [AppointmentStatus::CheckedIn, AppointmentStatus::Cancelled, AppointmentStatus::NoShow],
AppointmentStatus::CheckedIn->value => [AppointmentStatus::InProgress, AppointmentStatus::Cancelled],
AppointmentStatus::InProgress->value => [AppointmentStatus::Completed],
];
if (! in_array($next, $allowed[$current->value] ?? [], true)) throw new RuntimeException('That appointment status transition is not allowed.');
}
}
@@ -1,36 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\Permission;
use App\Models\Appointment;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class StaffDashboardController extends Controller
{
public function __invoke(Request $request, TenantContext $tenant): View
{
$business = $tenant->require();
$staff = $request->user()->staffProfiles()
->where('businessId', $business->id)
->with('role.permissions')
->first();
abort_unless($staff, 403);
$isOwner = $staff->hasPermission(Permission::ViewAllStaffAppointments);
$today = Carbon::now($business->timezone);
$appointments = Appointment::query()
->where('businessId', $business->id)
->when(! $isOwner, fn ($query) => $query->where('staffId', $staff->id))
->whereBetween('startAt', [$today->copy()->startOfDay()->utc(), $today->copy()->endOfDay()->utc()])
->with(['customer', 'staff.user', 'services.service'])
->orderBy('startAt')
->get();
return view('staff.dashboard', compact('business', 'staff', 'appointments', 'isOwner'));
}
}
@@ -1,26 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Permission;
use App\Services\Tenant\TenantContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureStaffPermission
{
public function handle(Request $request, Closure $next, string $permission): Response
{
$permissionCase = Permission::tryFrom($permission);
$business = app(TenantContext::class)->require();
$staff = $request->user()?->staffProfiles()
->where('businessId', $business->id)
->with('role.permissions')
->first();
abort_unless($permissionCase && $staff?->hasPermission($permissionCase), 403);
return $next($request);
}
}
-21
View File
@@ -1,21 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureSuperAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()) {
return redirect()->route('manager.login');
}
abort_unless($request->user()->isSuperAdmin === true, 403);
return $next($request);
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Models\BusinessDomain;
use App\Services\Tenant\TenantContext;
use App\Services\Tenant\DomainNormalizer;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class ResolveTenant
{
public function handle(Request $request, Closure $next): Response
{
$hostname = null;
try {
$hostname = app(DomainNormalizer::class)->normalize($request->getHost());
$business = BusinessDomain::query()
->where('hostname', $hostname)
->where('isActive', true)
->with('business')
->first()?->business;
} catch (Throwable) {
// The install page and framework smoke tests must remain
// reachable before the tenant schema has been created.
$business = null;
}
if (! $business) {
app(TenantContext::class)->forget();
}
if ($business) {
app(TenantContext::class)->set($business);
}
return $next($request);
}
}
-16
View File
@@ -1,16 +0,0 @@
<?php
namespace App\Models;
use App\Enums\InstallationStatus;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
class ApplicationInstallation extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt'; public const UPDATED_AT = 'updatedAt';
protected $table = 'ApplicationInstallation';
protected $fillable = ['id', 'status', 'installedAt', 'installedVersion', 'initialAdminUserId', 'primaryBusinessId', 'metadata', 'failureReason'];
protected function casts(): array { return ['status' => InstallationStatus::class, 'installedAt' => 'datetime', 'metadata' => 'array']; }
}
-32
View File
@@ -1,32 +0,0 @@
<?php
namespace App\Models;
use App\Enums\AppointmentStatus;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Appointment extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt';
public const UPDATED_AT = 'updatedAt';
protected $table = 'Appointment';
protected $fillable = ['businessId', 'customerId', 'staffId', 'status', 'startAt', 'endAt', 'notes', 'discountAmount', 'cancellationReason', 'cancelledAt', 'cancelledByUserId', 'createdByUserId'];
protected function casts(): array { return ['status' => AppointmentStatus::class, 'startAt' => 'datetime', 'endAt' => 'datetime', 'cancelledAt' => 'datetime', 'discountAmount' => 'decimal:2']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function customer(): BelongsTo { return $this->belongsTo(CustomerProfile::class, 'customerId'); }
public function staff(): BelongsTo { return $this->belongsTo(StaffProfile::class, 'staffId'); }
public function services(): HasMany { return $this->hasMany(AppointmentService::class, 'appointmentId'); }
public function guestAccessToken(): HasOne { return $this->hasOne(GuestAppointmentAccessToken::class, 'appointmentId'); }
public function calendarEvents(): HasMany { return $this->hasMany(AppointmentCalendarEvent::class, 'appointmentId'); }
public function loyaltyTransactions(): HasMany { return $this->hasMany(LoyaltyTransaction::class, 'appointmentId'); }
public function notifications(): HasMany { return $this->hasMany(Notification::class, 'appointmentId'); }
public function appliedLoyaltyRewards(): HasMany { return $this->hasMany(LoyaltyReward::class, 'appliedToAppointmentId'); }
public function cancelledByUser(): BelongsTo { return $this->belongsTo(User::class, 'cancelledByUserId'); }
public function createdByUser(): BelongsTo { return $this->belongsTo(User::class, 'createdByUserId'); }
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace App\Models;
use App\Enums\CalendarSyncStatus;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AppointmentCalendarEvent extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt'; public const UPDATED_AT = 'updatedAt';
protected $table = 'AppointmentCalendarEvent';
protected $fillable = ['appointmentId', 'connectionId', 'providerEventId', 'providerCalendarId', 'status', 'lastSyncedAt', 'lastSyncError', 'sourceVersion'];
protected function casts(): array { return ['status' => CalendarSyncStatus::class, 'lastSyncedAt' => 'datetime']; }
public function appointment(): BelongsTo { return $this->belongsTo(Appointment::class, 'appointmentId'); }
public function connection(): BelongsTo { return $this->belongsTo(StaffCalendarConnection::class, 'connectionId'); }
}
-18
View File
@@ -1,18 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AppointmentService extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'AppointmentService';
protected $fillable = ['appointmentId', 'serviceId', 'priceAtBooking', 'durationAtBookingMinutes'];
protected function casts(): array { return ['priceAtBooking' => 'decimal:2']; }
public function appointment(): BelongsTo { return $this->belongsTo(Appointment::class, 'appointmentId'); }
public function service(): BelongsTo { return $this->belongsTo(Service::class, 'serviceId'); }
}
-18
View File
@@ -1,18 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuditLog extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'AuditLog';
protected $fillable = ['businessId', 'actorUserId', 'action', 'entityType', 'entityId', 'metadata'];
protected function casts(): array { return ['metadata' => 'array']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function actorUser(): BelongsTo { return $this->belongsTo(User::class, 'actorUserId'); }
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BookingRule extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'BookingRule';
protected $fillable = ['businessId', 'minNoticeMinutes', 'maxAdvanceDays', 'cancellationWindowHours', 'rescheduleWindowHours', 'slotIntervalMinutes', 'bufferBeforeMinutes', 'bufferAfterMinutes', 'maxAppointmentsPerDay', 'maxAppointmentsPerStaffPerDay', 'customerCanChooseStaff', 'allowGuestBooking', 'requireApproval', 'requireDeposit'];
protected function casts(): array { return ['customerCanChooseStaff' => 'boolean', 'allowGuestBooking' => 'boolean', 'requireApproval' => 'boolean', 'requireDeposit' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
}
-16
View File
@@ -1,16 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BreakDay extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'BreakDay';
protected $fillable = ['breakId', 'dayOfWeek'];
public function break(): BelongsTo { return $this->belongsTo(StaffBreak::class, 'breakId'); }
}
-50
View File
@@ -1,50 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Business extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt';
public const UPDATED_AT = 'updatedAt';
protected $table = 'Business';
protected $fillable = [
'slug', 'name', 'shortName', 'description', 'logoUrl', 'coverImageUrl',
'faviconUrl', 'appIconUrl', 'themeColor', 'backgroundColor', 'phone',
'email', 'website', 'instagramUrl', 'facebookUrl', 'timezone', 'currency',
'locale', 'bottomNavShowLabels', 'icon192Url', 'icon512Url', 'maskableIcon192Url',
'maskableIcon512Url', 'splashScreens', 'welcomeBackgroundDesktopUrl',
'welcomeBackgroundMobileUrl', 'googlePlaceId', 'googleRating', 'googleRatingCount',
];
protected function casts(): array
{
return ['bottomNavShowLabels' => 'boolean', 'splashScreens' => 'array', 'googleRating' => 'decimal:1'];
}
public function domains(): HasMany { return $this->hasMany(BusinessDomain::class, 'businessId'); }
public function primaryDomain(): HasOne { return $this->hasOne(BusinessDomain::class, 'businessId')->where('isPrimary', true); }
public function locations(): HasMany { return $this->hasMany(BusinessLocation::class, 'businessId'); }
public function hours(): HasMany { return $this->hasMany(BusinessHours::class, 'businessId'); }
public function settings(): HasOne { return $this->hasOne(BusinessSettings::class, 'businessId'); }
public function bookingRule(): HasOne { return $this->hasOne(BookingRule::class, 'businessId'); }
public function staff(): HasMany { return $this->hasMany(StaffProfile::class, 'businessId'); }
public function serviceCategories(): HasMany { return $this->hasMany(ServiceCategory::class, 'businessId'); }
public function services(): HasMany { return $this->hasMany(Service::class, 'businessId'); }
public function appointments(): HasMany { return $this->hasMany(Appointment::class, 'businessId'); }
public function loyaltyProgramme(): HasOne { return $this->hasOne(LoyaltyProgramme::class, 'businessId'); }
public function staffInvitations(): HasMany { return $this->hasMany(StaffInvitation::class, 'businessId'); }
public function notifications(): HasMany { return $this->hasMany(Notification::class, 'businessId'); }
public function pushSubscriptions(): HasMany { return $this->hasMany(PushSubscription::class, 'businessId'); }
public function mediaAssets(): HasMany { return $this->hasMany(MediaAsset::class, 'businessId'); }
public function galleryImages(): HasMany { return $this->hasMany(BusinessGalleryImage::class, 'businessId'); }
public function reviews(): HasMany { return $this->hasMany(Review::class, 'businessId'); }
}
-26
View File
@@ -1,26 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BusinessDomain extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt';
public const UPDATED_AT = 'updatedAt';
protected $table = 'business_domains';
protected $fillable = ['businessId', 'hostname', 'isPrimary', 'isActive'];
protected function casts(): array
{
return ['isPrimary' => 'boolean', 'isActive' => 'boolean'];
}
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace App\Models;
use App\Enums\GalleryPlacement;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BusinessGalleryImage extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt'; public const UPDATED_AT = 'updatedAt';
protected $table = 'BusinessGalleryImage';
protected $fillable = ['businessId', 'placement', 'mediaAssetId', 'imageUrl', 'category', 'caption', 'displayOrder', 'isActive'];
protected function casts(): array { return ['placement' => GalleryPlacement::class, 'isActive' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function mediaAsset(): BelongsTo { return $this->belongsTo(MediaAsset::class, 'mediaAssetId'); }
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BusinessHours extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'BusinessHours';
protected $fillable = ['businessId', 'dayOfWeek', 'isClosed', 'opensAtMinutes', 'closesAtMinutes'];
protected function casts(): array { return ['isClosed' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
}
-21
View File
@@ -1,21 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BusinessLocation extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt';
public const UPDATED_AT = 'updatedAt';
protected $table = 'BusinessLocation';
protected $fillable = ['businessId', 'name', 'addressLine1', 'addressLine2', 'city', 'region', 'postalCode', 'country', 'isPrimary'];
protected function casts(): array { return ['isPrimary' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BusinessSettings extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'BusinessSettings';
protected $fillable = ['businessId', 'cancellationPolicy', 'lateArrivalPolicy', 'bookingTerms', 'privacyPolicyUrl', 'reviewsMinRating', 'reviewsExcludeRatingOnly'];
protected function casts(): array { return ['reviewsExcludeRatingOnly' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
}
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Str;
trait UsesStringIds
{
protected static function bootUsesStringIds(): void
{
static::creating(function ($model): void {
if (! $model->getKey()) {
$model->setAttribute($model->getKeyName(), Str::lower(Str::random(25)));
}
});
}
public function getIncrementing(): bool
{
return false;
}
public function getKeyType(): string
{
return 'string';
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CustomerNote extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt'; public const UPDATED_AT = 'updatedAt';
protected $table = 'CustomerNote';
protected $fillable = ['customerId', 'authorStaffId', 'body'];
public function customer(): BelongsTo { return $this->belongsTo(CustomerProfile::class, 'customerId'); }
public function author(): BelongsTo { return $this->belongsTo(StaffProfile::class, 'authorStaffId'); }
}
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class CustomerProfile extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt';
public const UPDATED_AT = 'updatedAt';
protected $table = 'CustomerProfile';
protected $fillable = ['businessId', 'userId', 'firstName', 'lastName', 'email', 'phone', 'isGuest', 'preferredBarberId'];
protected function casts(): array { return ['isGuest' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function user(): BelongsTo { return $this->belongsTo(User::class, 'userId'); }
public function preferredBarber(): BelongsTo { return $this->belongsTo(StaffProfile::class, 'preferredBarberId'); }
public function appointments(): HasMany { return $this->hasMany(Appointment::class, 'customerId'); }
public function loyaltyAccounts(): HasMany { return $this->hasMany(LoyaltyAccount::class, 'customerId'); }
public function notes(): HasMany { return $this->hasMany(CustomerNote::class, 'customerId'); }
public function receivedNotifications(): HasMany { return $this->hasMany(Notification::class, 'recipientCustomerId'); }
public function pushSubscriptions(): HasMany { return $this->hasMany(PushSubscription::class, 'customerId'); }
}
@@ -1,17 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GuestAppointmentAccessToken extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'GuestAppointmentAccessToken';
protected $fillable = ['appointmentId', 'tokenHash', 'expiresAt', 'revokedAt', 'lastUsedAt'];
protected function casts(): array { return ['expiresAt' => 'datetime', 'revokedAt' => 'datetime', 'lastUsedAt' => 'datetime']; }
public function appointment(): BelongsTo { return $this->belongsTo(Appointment::class, 'appointmentId'); }
}
-20
View File
@@ -1,20 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LoyaltyAccount extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt'; public const UPDATED_AT = 'updatedAt';
protected $table = 'LoyaltyAccount';
protected $fillable = ['customerId', 'loyaltyProgrammeId', 'currentStamps'];
public function customer(): BelongsTo { return $this->belongsTo(CustomerProfile::class, 'customerId'); }
public function programme(): BelongsTo { return $this->belongsTo(LoyaltyProgramme::class, 'loyaltyProgrammeId'); }
public function transactions(): HasMany { return $this->hasMany(LoyaltyTransaction::class, 'loyaltyAccountId'); }
public function rewards(): HasMany { return $this->hasMany(LoyaltyReward::class, 'loyaltyAccountId'); }
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LoyaltyEligibleService extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'LoyaltyEligibleService';
protected $fillable = ['loyaltyProgrammeId', 'serviceId'];
public function programme(): BelongsTo { return $this->belongsTo(LoyaltyProgramme::class, 'loyaltyProgrammeId'); }
public function service(): BelongsTo { return $this->belongsTo(Service::class, 'serviceId'); }
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LoyaltyEligibleStaff extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'LoyaltyEligibleStaff';
protected $fillable = ['loyaltyProgrammeId', 'staffId'];
public function programme(): BelongsTo { return $this->belongsTo(LoyaltyProgramme::class, 'loyaltyProgrammeId'); }
public function staff(): BelongsTo { return $this->belongsTo(StaffProfile::class, 'staffId'); }
}
-23
View File
@@ -1,23 +0,0 @@
<?php
namespace App\Models;
use App\Enums\{LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode, RewardType};
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LoyaltyProgramme extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'LoyaltyProgramme';
protected $fillable = ['businessId', 'isEnabled', 'stampsRequired', 'rewardPosition', 'rewardType', 'rewardValue', 'rewardLabel', 'maxRewardValue', 'freeServiceId', 'autoApplyReward', 'requiresStaffApproval', 'stampsExpireAfterDays', 'rewardExpiresAfterDays', 'earnOnCancelled', 'earnOnNoShow', 'cardBackgroundColor', 'cardBackgroundImageUrl', 'stampIcon', 'termsLine', 'cardTitleText', 'cardFont', 'cardTitleTextSize', 'cardTitleTextColor', 'cardBorderColor', 'cardMemberNumberColor'];
protected function casts(): array { return ['isEnabled' => 'boolean', 'rewardType' => RewardType::class, 'rewardPosition' => RewardPositionMode::class, 'cardFont' => LoyaltyCardFont::class, 'cardTitleTextSize' => LoyaltyCardTextSize::class, 'rewardValue' => 'decimal:2', 'maxRewardValue' => 'decimal:2', 'autoApplyReward' => 'boolean', 'requiresStaffApproval' => 'boolean', 'earnOnCancelled' => 'boolean', 'earnOnNoShow' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function freeService(): BelongsTo { return $this->belongsTo(Service::class, 'freeServiceId'); }
public function eligibleServices(): HasMany { return $this->hasMany(LoyaltyEligibleService::class, 'loyaltyProgrammeId'); }
public function eligibleStaff(): HasMany { return $this->hasMany(LoyaltyEligibleStaff::class, 'loyaltyProgrammeId'); }
public function accounts(): HasMany { return $this->hasMany(LoyaltyAccount::class, 'loyaltyProgrammeId'); }
}
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace App\Models;
use App\Enums\{RewardStatus, RewardType};
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LoyaltyReward extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'LoyaltyReward';
protected $fillable = ['loyaltyAccountId', 'rewardType', 'value', 'label', 'status', 'appliedToAppointmentId', 'redeemedAt', 'redeemedByStaffId', 'expiresAt'];
protected function casts(): array { return ['rewardType' => RewardType::class, 'status' => RewardStatus::class, 'value' => 'decimal:2', 'redeemedAt' => 'datetime', 'expiresAt' => 'datetime']; }
public function account(): BelongsTo { return $this->belongsTo(LoyaltyAccount::class, 'loyaltyAccountId'); }
public function appointment(): BelongsTo { return $this->belongsTo(Appointment::class, 'appliedToAppointmentId'); }
public function redeemedByStaff(): BelongsTo { return $this->belongsTo(StaffProfile::class, 'redeemedByStaffId'); }
public function notifications(): HasMany { return $this->hasMany(Notification::class, 'loyaltyRewardId'); }
}
-20
View File
@@ -1,20 +0,0 @@
<?php
namespace App\Models;
use App\Enums\LoyaltyTransactionType;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LoyaltyTransaction extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'LoyaltyTransaction';
protected $fillable = ['loyaltyAccountId', 'type', 'amount', 'reason', 'appointmentId', 'authoredByStaffId'];
protected function casts(): array { return ['type' => LoyaltyTransactionType::class]; }
public function account(): BelongsTo { return $this->belongsTo(LoyaltyAccount::class, 'loyaltyAccountId'); }
public function appointment(): BelongsTo { return $this->belongsTo(Appointment::class, 'appointmentId'); }
public function authoredByStaff(): BelongsTo { return $this->belongsTo(StaffProfile::class, 'authoredByStaffId'); }
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MediaAsset extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'MediaAsset';
protected $fillable = ['businessId', 'url', 'originalFilename', 'mimeType', 'sizeBytes', 'width', 'height', 'category', 'uploadedByUserId'];
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function uploadedByUser(): BelongsTo { return $this->belongsTo(User::class, 'uploadedByUserId'); }
public function galleryImages(): HasMany { return $this->hasMany(BusinessGalleryImage::class, 'mediaAssetId'); }
}
-24
View File
@@ -1,24 +0,0 @@
<?php
namespace App\Models;
use App\Enums\NotificationType;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Notification extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'Notification';
protected $fillable = ['businessId', 'recipientUserId', 'recipientCustomerId', 'type', 'appointmentId', 'loyaltyRewardId', 'subject', 'bodyText', 'deepLinkPath', 'idempotencyKey', 'readAt'];
protected function casts(): array { return ['type' => NotificationType::class, 'readAt' => 'datetime']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function recipientUser(): BelongsTo { return $this->belongsTo(User::class, 'recipientUserId'); }
public function recipientCustomer(): BelongsTo { return $this->belongsTo(CustomerProfile::class, 'recipientCustomerId'); }
public function appointment(): BelongsTo { return $this->belongsTo(Appointment::class, 'appointmentId'); }
public function loyaltyReward(): BelongsTo { return $this->belongsTo(LoyaltyReward::class, 'loyaltyRewardId'); }
public function deliveries(): HasMany { return $this->hasMany(NotificationDelivery::class, 'notificationId'); }
}
-18
View File
@@ -1,18 +0,0 @@
<?php
namespace App\Models;
use App\Enums\{NotificationChannel, NotificationDeliveryStatus};
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NotificationDelivery extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'NotificationDelivery';
protected $fillable = ['notificationId', 'channel', 'status', 'scheduledFor', 'attemptCount', 'lastAttemptedAt', 'providerName', 'providerMessageId', 'failureReason', 'sentAt'];
protected function casts(): array { return ['channel' => NotificationChannel::class, 'status' => NotificationDeliveryStatus::class, 'scheduledFor' => 'datetime', 'lastAttemptedAt' => 'datetime', 'sentAt' => 'datetime']; }
public function notification(): BelongsTo { return $this->belongsTo(Notification::class, 'notificationId'); }
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace App\Models;
use App\Enums\{NotificationChannel, NotificationType};
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NotificationPreference extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'NotificationPreference';
protected $fillable = ['businessId', 'userId', 'type', 'channel', 'isEnabled'];
protected function casts(): array { return ['type' => NotificationType::class, 'channel' => NotificationChannel::class, 'isEnabled' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function user(): BelongsTo { return $this->belongsTo(User::class, 'userId'); }
}
-20
View File
@@ -1,20 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PushSubscription extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'PushSubscription';
protected $fillable = ['businessId', 'userId', 'customerId', 'endpoint', 'p256dhKey', 'authKey', 'deviceLabel', 'lastUsedAt', 'revokedAt'];
protected $hidden = ['p256dhKey', 'authKey'];
protected function casts(): array { return ['lastUsedAt' => 'datetime', 'revokedAt' => 'datetime']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function user(): BelongsTo { return $this->belongsTo(User::class, 'userId'); }
public function customer(): BelongsTo { return $this->belongsTo(CustomerProfile::class, 'customerId'); }
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Review extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt'; public const UPDATED_AT = 'updatedAt';
protected $table = 'Review';
protected $fillable = ['businessId', 'externalId', 'authorName', 'authorPhotoUrl', 'rating', 'text', 'relativeTimeDescription', 'publishedAt', 'isHidden', 'fetchedAt'];
protected function casts(): array { return ['publishedAt' => 'datetime', 'fetchedAt' => 'datetime', 'isHidden' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
}
-20
View File
@@ -1,20 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Role extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'Role';
protected $fillable = ['businessId', 'name', 'isSystem'];
protected function casts(): array { return ['isSystem' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function permissions(): HasMany { return $this->hasMany(RolePermission::class, 'roleId'); }
public function staff(): HasMany { return $this->hasMany(StaffProfile::class, 'roleId'); }
}
-18
View File
@@ -1,18 +0,0 @@
<?php
namespace App\Models;
use App\Enums\Permission;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RolePermission extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'RolePermission';
protected $fillable = ['roleId', 'permission'];
protected function casts(): array { return ['permission' => Permission::class]; }
public function role(): BelongsTo { return $this->belongsTo(Role::class, 'roleId'); }
}
-26
View File
@@ -1,26 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Service extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt';
public const UPDATED_AT = 'updatedAt';
protected $table = 'Service';
protected $fillable = ['businessId', 'categoryId', 'name', 'description', 'imageUrl', 'durationMinutes', 'price', 'bufferBeforeMinutes', 'bufferAfterMinutes', 'isActive', 'allowOnlineBooking', 'displayOrder'];
protected function casts(): array { return ['price' => 'decimal:2', 'isActive' => 'boolean', 'allowOnlineBooking' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function category(): BelongsTo { return $this->belongsTo(ServiceCategory::class, 'categoryId'); }
public function staff(): BelongsToMany { return $this->belongsToMany(StaffProfile::class, 'StaffService', 'serviceId', 'staffId')->withPivot(['priceOverride', 'durationOverrideMinutes']); }
public function appointmentServices(): HasMany { return $this->hasMany(AppointmentService::class, 'serviceId'); }
public function loyaltyEligibleFor(): HasMany { return $this->hasMany(LoyaltyEligibleService::class, 'serviceId'); }
public function loyaltyProgrammesUsingAsFreeReward(): HasMany { return $this->hasMany(LoyaltyProgramme::class, 'freeServiceId'); }
}
-21
View File
@@ -1,21 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ServiceCategory extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt';
public const UPDATED_AT = 'updatedAt';
protected $table = 'ServiceCategory';
protected $fillable = ['businessId', 'name', 'displayOrder', 'isActive'];
protected function casts(): array { return ['isActive' => 'boolean']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function services(): HasMany { return $this->hasMany(Service::class, 'categoryId'); }
}
-18
View File
@@ -1,18 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class StaffBreak extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'Break';
protected $fillable = ['staffId', 'label', 'startMinutes', 'endMinutes'];
public function staff(): BelongsTo { return $this->belongsTo(StaffProfile::class, 'staffId'); }
public function days(): HasMany { return $this->hasMany(BreakDay::class, 'breakId'); }
}
-21
View File
@@ -1,21 +0,0 @@
<?php
namespace App\Models;
use App\Enums\CalendarProvider;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class StaffCalendarConnection extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt'; public const UPDATED_AT = 'updatedAt';
protected $table = 'StaffCalendarConnection';
protected $fillable = ['staffId', 'provider', 'externalAccountId', 'accountEmail', 'encryptedAccessToken', 'encryptedRefreshToken', 'tokenExpiresAt', 'selectedCalendarId', 'selectedCalendarName', 'connectedAt', 'lastSyncAt', 'lastSyncError', 'revokedAt'];
protected $hidden = ['encryptedAccessToken', 'encryptedRefreshToken'];
protected function casts(): array { return ['provider' => CalendarProvider::class, 'tokenExpiresAt' => 'datetime', 'connectedAt' => 'datetime', 'lastSyncAt' => 'datetime', 'revokedAt' => 'datetime']; }
public function staff(): BelongsTo { return $this->belongsTo(StaffProfile::class, 'staffId'); }
public function events(): HasMany { return $this->hasMany(AppointmentCalendarEvent::class, 'connectionId'); }
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StaffInvitation extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'StaffInvitation';
protected $fillable = ['businessId', 'staffId', 'email', 'token', 'invitedByUserId', 'expiresAt', 'acceptedAt'];
protected function casts(): array { return ['expiresAt' => 'datetime', 'acceptedAt' => 'datetime']; }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function staff(): BelongsTo { return $this->belongsTo(StaffProfile::class, 'staffId'); }
public function invitedByUser(): BelongsTo { return $this->belongsTo(User::class, 'invitedByUserId'); }
}
-41
View File
@@ -1,41 +0,0 @@
<?php
namespace App\Models;
use App\Enums\StaffStatus;
use App\Enums\Permission;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class StaffProfile extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt';
public const UPDATED_AT = 'updatedAt';
protected $table = 'StaffProfile';
protected $fillable = ['userId', 'businessId', 'roleId', 'bio', 'status', 'isBookableOnline'];
protected function casts(): array { return ['status' => StaffStatus::class, 'isBookableOnline' => 'boolean']; }
public function user(): BelongsTo { return $this->belongsTo(User::class, 'userId'); }
public function business(): BelongsTo { return $this->belongsTo(Business::class, 'businessId'); }
public function role(): BelongsTo { return $this->belongsTo(Role::class, 'roleId'); }
public function services(): BelongsToMany { return $this->belongsToMany(Service::class, 'StaffService', 'staffId', 'serviceId')->withPivot(['priceOverride', 'durationOverrideMinutes']); }
public function workingHours(): HasMany { return $this->hasMany(WorkingHours::class, 'staffId'); }
public function breaks(): HasMany { return $this->hasMany(StaffBreak::class, 'staffId'); }
public function timeOff(): HasMany { return $this->hasMany(TimeOff::class, 'staffId'); }
public function appointments(): HasMany { return $this->hasMany(Appointment::class, 'staffId'); }
public function invitation(): HasOne { return $this->hasOne(StaffInvitation::class, 'staffId'); }
public function calendarConnection(): HasOne { return $this->hasOne(StaffCalendarConnection::class, 'staffId'); }
public function authoredLoyaltyTransactions(): HasMany { return $this->hasMany(LoyaltyTransaction::class, 'authoredByStaffId'); }
public function authoredCustomerNotes(): HasMany { return $this->hasMany(CustomerNote::class, 'authorStaffId'); }
public function redeemedLoyaltyRewards(): HasMany { return $this->hasMany(LoyaltyReward::class, 'redeemedByStaffId'); }
public function hasPermission(Permission $permission): bool
{
return $this->role?->permissions->contains('permission', $permission) ?? false;
}
}
-15
View File
@@ -1,15 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
class StaffService extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'StaffService';
protected $fillable = ['staffId', 'serviceId', 'priceOverride', 'durationOverrideMinutes'];
protected function casts(): array { return ['priceOverride' => 'decimal:2']; }
}
-18
View File
@@ -1,18 +0,0 @@
<?php
namespace App\Models;
use App\Enums\TimeOffStatus;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TimeOff extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'TimeOff';
protected $fillable = ['staffId', 'startDate', 'endDate', 'reason', 'status', 'startMinutes', 'endMinutes'];
protected function casts(): array { return ['startDate' => 'date', 'endDate' => 'date', 'status' => TimeOffStatus::class]; }
public function staff(): BelongsTo { return $this->belongsTo(StaffProfile::class, 'staffId'); }
}
-72
View File
@@ -1,72 +0,0 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasMany as UserHasMany;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, UsesStringIds;
public const CREATED_AT = 'createdAt';
public const UPDATED_AT = 'updatedAt';
protected $table = 'User';
protected $keyType = 'string';
public $incrementing = false;
protected $fillable = [
'name', 'email', 'phone', 'passwordHash', 'isSuperAdmin',
];
protected $hidden = ['passwordHash', 'remember_token'];
public function getAuthPasswordName(): string
{
return 'passwordHash';
}
public function getAuthPassword(): ?string
{
return $this->passwordHash;
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'emailVerified' => 'datetime',
'phoneVerified' => 'datetime',
'isSuperAdmin' => 'boolean',
];
}
public function customerProfiles(): HasMany
{
return $this->hasMany(CustomerProfile::class, 'userId');
}
public function staffProfiles(): HasMany
{
return $this->hasMany(StaffProfile::class, 'userId');
}
public function receivedNotifications(): UserHasMany { return $this->hasMany(Notification::class, 'recipientUserId'); }
public function notificationPreferences(): UserHasMany { return $this->hasMany(NotificationPreference::class, 'userId'); }
public function pushSubscriptions(): UserHasMany { return $this->hasMany(PushSubscription::class, 'userId'); }
public function uploadedMediaAssets(): UserHasMany { return $this->hasMany(MediaAsset::class, 'uploadedByUserId'); }
public function sentStaffInvitations(): UserHasMany { return $this->hasMany(StaffInvitation::class, 'invitedByUserId'); }
}
-15
View File
@@ -1,15 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
class WorkingHours extends Model
{
use UsesStringIds;
public $timestamps = false;
protected $table = 'WorkingHours';
protected $fillable = ['staffId', 'dayOfWeek', 'isOff', 'startMinutes', 'endMinutes'];
protected function casts(): array { return ['isOff' => 'boolean']; }
}
-20
View File
@@ -1,20 +0,0 @@
<?php
namespace App\Providers;
use App\Services\Tenant\TenantContext;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->scoped(TenantContext::class, fn (): TenantContext => new TenantContext());
}
public function boot(): void
{
// Tenant-aware policies and observers are registered here as the
// feature areas move from the legacy application.
}
}
@@ -1,100 +0,0 @@
<?php
namespace App\Services\Availability;
use App\Enums\AppointmentStatus;
use App\Models\Business;
use App\Models\Service;
use App\Models\StaffProfile;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
class AvailabilityService
{
public function slots(Business $business, CarbonImmutable $date, array $serviceIds, ?string $staffId = null): Collection
{
$services = Service::query()
->where('businessId', $business->id)
->where('isActive', true)
->where('allowOnlineBooking', true)
->whereIn('id', $serviceIds)
->get();
if ($services->count() !== count(array_unique($serviceIds))) {
return collect();
}
$totalMinutes = $services->sum('durationMinutes') + $services->sum('bufferBeforeMinutes') + $services->sum('bufferAfterMinutes');
$rule = $business->bookingRule;
$interval = max(5, (int) ($rule?->slotIntervalMinutes ?? 30));
$businessTimezone = $business->timezone ?: 'UTC';
$localDate = $date->setTimezone($businessTimezone)->startOfDay();
$weekday = strtoupper($localDate->format('l'));
$businessHours = $business->hours()->where('dayOfWeek', $weekday)->first();
if (! $businessHours || $businessHours->isClosed || $businessHours->opensAtMinutes === null || $businessHours->closesAtMinutes === null) {
return collect();
}
$staffQuery = StaffProfile::query()
->where('businessId', $business->id)
->where('status', 'ACTIVE')
->where('isBookableOnline', true)
->when($staffId, fn ($query) => $query->whereKey($staffId))
->whereHas('services', fn ($query) => $query->whereIn('Service.id', $serviceIds)->groupBy('StaffProfile.id')->havingRaw('COUNT(DISTINCT Service.id) = ?', [count(array_unique($serviceIds))]));
return $staffQuery->with(['user', 'workingHours', 'breaks.days', 'timeOff'])->get()->flatMap(function (StaffProfile $staff) use ($business, $localDate, $weekday, $totalMinutes, $interval, $businessHours): Collection {
$hours = $staff->workingHours->firstWhere('dayOfWeek', $weekday);
if (! $hours || $hours->isOff || $hours->startMinutes === null || $hours->endMinutes === null) {
return collect();
}
$startMinute = max($hours->startMinutes, $businessHours->opensAtMinutes);
$endMinute = min($hours->endMinutes, $businessHours->closesAtMinutes);
$slots = collect();
for ($minute = $startMinute; $minute + $totalMinutes <= $endMinute; $minute += $interval) {
$start = $localDate->addMinutes($minute);
$end = $start->addMinutes($totalMinutes);
$blocked = $staff->breaks->contains(fn ($break) => $this->overlapsRecurringBreak($break, $weekday, $minute, $minute + $totalMinutes))
|| $staff->timeOff->contains(fn ($timeOff) => $this->overlapsTimeOff($timeOff, $localDate, $minute, $minute + $totalMinutes));
if ($blocked || $start->lessThan(CarbonImmutable::now($business->timezone)->addMinutes((int) ($business->bookingRule?->minNoticeMinutes ?? 120)))) {
continue;
}
$hasConflict = $staff->appointments()
->whereNotIn('status', [AppointmentStatus::Cancelled->value, AppointmentStatus::NoShow->value])
->where('startAt', '<', $end->utc())
->where('endAt', '>', $start->utc())
->exists();
if (! $hasConflict) {
$slots->push([
'staffId' => $staff->id,
'staffName' => $staff->user?->name ?: 'Available barber',
'startAt' => $start->toIso8601String(),
'endAt' => $end->toIso8601String(),
'label' => $start->format('g:i A'),
]);
}
}
return $slots;
})->sortBy('startAt')->values();
}
private function overlapsRecurringBreak(object $break, string $weekday, int $start, int $end): bool
{
$applies = $break->days->contains('dayOfWeek', $weekday);
return $applies && $break->startMinutes < $end && $break->endMinutes > $start;
}
private function overlapsTimeOff(object $timeOff, CarbonImmutable $date, int $start, int $end): bool
{
if ($date->lessThan($timeOff->startDate->startOfDay()) || $date->greaterThan($timeOff->endDate->endOfDay())) {
return false;
}
return $timeOff->startMinutes === null || ($timeOff->startMinutes < $end && $timeOff->endMinutes > $start);
}
}
-96
View File
@@ -1,96 +0,0 @@
<?php
namespace App\Services\Booking;
use App\Enums\AppointmentStatus;
use App\Models\Appointment;
use App\Models\AppointmentService;
use App\Models\Business;
use App\Models\CustomerProfile;
use App\Models\GuestAppointmentAccessToken;
use App\Models\Service;
use App\Services\Availability\AvailabilityService;
use App\Services\Notifications\NotificationService;
use App\Enums\NotificationType;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class BookingService
{
public function __construct(private readonly AvailabilityService $availability, private readonly NotificationService $notifications) {}
/** @return array{appointment: Appointment, accessToken: string} */
public function create(Business $business, array $input): array
{
$serviceIds = array_values(array_unique($input['serviceIds']));
$services = Service::query()->where('businessId', $business->id)->whereIn('id', $serviceIds)->where('isActive', true)->get();
if ($services->count() !== count($serviceIds)) {
throw new RuntimeException('One or more selected services are no longer available.');
}
$start = CarbonImmutable::parse($input['startAt'])->setTimezone($business->timezone);
$end = $start->addMinutes($services->sum('durationMinutes') + $services->sum('bufferBeforeMinutes') + $services->sum('bufferAfterMinutes'));
$available = $this->availability->slots($business, $start, $serviceIds, $input['staffId'])->first(fn ($slot) => $slot['startAt'] === $start->toIso8601String());
if (! $available) {
throw new RuntimeException('That time has just been booked. Please choose another slot.');
}
$rawToken = Str::random(64);
$appointment = DB::transaction(function () use ($business, $input, $services, $start, $end, $rawToken): Appointment {
$conflict = Appointment::query()
->where('businessId', $business->id)
->where('staffId', $input['staffId'])
->whereNotIn('status', [AppointmentStatus::Cancelled->value, AppointmentStatus::NoShow->value])
->where('startAt', '<', $end->utc())
->where('endAt', '>', $start->utc())
->lockForUpdate()
->exists();
if ($conflict) {
throw new RuntimeException('That time has just been booked. Please choose another slot.');
}
$email = strtolower(trim($input['email']));
$customer = CustomerProfile::query()->where('businessId', $business->id)->where('email', $email)->first();
if (! $customer) $customer = new CustomerProfile(['businessId' => $business->id, 'email' => $email]);
$customer->firstName = trim($input['firstName']);
$customer->lastName = trim($input['lastName']);
$customer->phone = trim($input['phone'] ?? '') ?: $customer->phone;
$customer->isGuest = ! $customer->userId;
$customer->save();
$appointment = Appointment::create([
'businessId' => $business->id,
'customerId' => $customer->id,
'staffId' => $input['staffId'],
'status' => $business->bookingRule?->requireApproval ? AppointmentStatus::Pending : AppointmentStatus::Confirmed,
'startAt' => $start->utc(),
'endAt' => $end->utc(),
'notes' => $input['notes'] ?? null,
]);
foreach ($services as $service) {
AppointmentService::create(['appointmentId' => $appointment->id, 'serviceId' => $service->id, 'priceAtBooking' => $service->price, 'durationAtBookingMinutes' => $service->durationMinutes]);
}
GuestAppointmentAccessToken::create(['appointmentId' => $appointment->id, 'tokenHash' => hash('sha256', $rawToken), 'expiresAt' => now()->addMonths(6)]);
return $appointment;
});
$appointment->load(['customer', 'staff.user', 'services.service']);
$notificationType = $appointment->status === AppointmentStatus::Pending ? NotificationType::BookingPendingApproval : NotificationType::BookingConfirmed;
$this->notifications->create(
$business,
$notificationType,
$appointment->status === AppointmentStatus::Pending
? 'Your booking request is waiting for the shop to approve.'
: 'Your appointment is confirmed.',
"booking-created:{$appointment->id}",
$appointment->customer->userId,
$appointment->customer->id,
$appointment->id,
"/book/confirmation",
$business->name,
);
return ['appointment' => $appointment, 'accessToken' => $rawToken];
}
}
Loaded 100 of 717 files, more files were not shown because too many files have changed in this diff. Show more