690 files changed
+10398
-32296
No files matched your search
@@ -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
|
|
||||||
File renamed without changes.
+79
-55
@@ -1,66 +1,90 @@
|
|||||||
APP_NAME=NextChair
|
# Database (MySQL 8.0 — migrated from PostgreSQL in Phase 7, see
|
||||||
APP_ENV=local
|
# docs/phase-7-summary.md; the production host only supports MySQL).
|
||||||
APP_KEY=
|
DATABASE_HOST="localhost"
|
||||||
APP_DEBUG=true
|
DATABASE_PORT="3306"
|
||||||
APP_URL=http://localhost:8000
|
DATABASE_NAME="nextchair"
|
||||||
APP_DEFAULT_BUSINESS_SLUG=
|
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
|
# Auth.js (NextAuth v5). Generate with: npx auth secret
|
||||||
APP_FALLBACK_LOCALE=en
|
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
|
||||||
APP_FAKER_LOCALE=en_US
|
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
# Google OAuth — https://console.cloud.google.com/apis/credentials
|
||||||
# APP_MAINTENANCE_STORE=database
|
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
|
# Google Reviews (Phase 9) — from https://console.cloud.google.com/apis/credentials,
|
||||||
LOG_STACK=single
|
# needs the Places API enabled. Leave unset to keep review sync disabled
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
# (Home shows clearly-labelled demo reviews instead). See
|
||||||
LOG_LEVEL=debug
|
# src/services/reviews/review-sync.service.ts and /owner/settings/reviews.
|
||||||
|
GOOGLE_PLACES_API_KEY=""
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
# Email (Phase 7) — see src/services/notifications/providers.
|
||||||
DB_HOST=127.0.0.1
|
# "console" logs would-be emails to the server console instead of sending
|
||||||
DB_PORT=3306
|
# (safe default — never send real email from an unconfigured environment).
|
||||||
DB_DATABASE=nextchair
|
# "brevo" sends for real over Brevo's SMTP relay via nodemailer (see
|
||||||
DB_USERNAME=root
|
# brevo-email-provider.ts for why SMTP rather than Brevo's REST API).
|
||||||
DB_PASSWORD=
|
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=""
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
# Web Push (Phase 7) — generate with: npx web-push generate-vapid-keys
|
||||||
SESSION_LIFETIME=120
|
# NEXT_PUBLIC_VAPID_PUBLIC_KEY must be the same value as VAPID_PUBLIC_KEY —
|
||||||
SESSION_ENCRYPT=false
|
# it's duplicated because the browser needs it for pushManager.subscribe.
|
||||||
SESSION_PATH=/
|
# PUSH_PROVIDER="web-push" to actually send; defaults to "console" (logs only).
|
||||||
SESSION_DOMAIN=null
|
PUSH_PROVIDER="console"
|
||||||
|
VAPID_PUBLIC_KEY=""
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
||||||
|
VAPID_PRIVATE_KEY=""
|
||||||
|
VAPID_SUBJECT="mailto:owner@example.com"
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
# Background notification processing (Phase 7) — shared secret an external
|
||||||
FILESYSTEM_DISK=local
|
# scheduler presents as `Authorization: Bearer <value>` to
|
||||||
QUEUE_CONNECTION=database
|
# /api/cron/process-notifications. Generate with: openssl rand -hex 32
|
||||||
|
CRON_SECRET=""
|
||||||
|
|
||||||
CACHE_STORE=database
|
# First-run installation wizard (Phase 8) — required before /install will
|
||||||
# CACHE_PREFIX=
|
# create the first business/administrator. Generate with:
|
||||||
|
# openssl rand -hex 32
|
||||||
|
INSTALLATION_TOKEN=""
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
# 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=""
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
# Staff Google Calendar sync (Phase 8) — separate OAuth client from
|
||||||
REDIS_HOST=127.0.0.1
|
# AUTH_GOOGLE_ID/SECRET above (different scopes; see
|
||||||
REDIS_PASSWORD=null
|
# src/services/calendar/google-calendar.provider.ts). Leave unset to keep
|
||||||
REDIS_PORT=6379
|
# calendar sync disabled — Settings shows a "not configured" state rather
|
||||||
|
# than crashing.
|
||||||
MAIL_MAILER=log
|
GOOGLE_CALENDAR_CLIENT_ID=""
|
||||||
MAIL_SCHEME=null
|
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
||||||
MAIL_HOST=127.0.0.1
|
GOOGLE_CALENDAR_REDIRECT_URI="http://localhost:3000/api/staff/calendar/google/callback"
|
||||||
MAIL_PORT=2525
|
# Encrypts stored refresh tokens at rest. Generate with: openssl rand -hex 32
|
||||||
MAIL_USERNAME=null
|
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
||||||
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}"
|
|
||||||
File renamed without changes.
@@ -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
|
|
||||||
+67
-37
@@ -1,41 +1,71 @@
|
|||||||
*.log
|
# dependencies
|
||||||
.DS_Store
|
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
|
||||||
.env.backup
|
.env*.local
|
||||||
|
.env.development
|
||||||
.env.production
|
.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
|
# per-deployment production env files (multi-business VPS setup — see
|
||||||
/legacy-nextjs/node_modules/
|
# docker-compose.infra.yaml / docker-compose.<business>.yaml). Named
|
||||||
/legacy-nextjs/.next/
|
# .env.business.* (not .env.<business> directly) specifically so this one
|
||||||
/legacy-nextjs/generated/
|
# wildcard rule keeps covering every future business without another
|
||||||
/legacy-nextjs/tsconfig.tsbuildinfo
|
# .gitignore edit. The template itself (.env.business.example) stays
|
||||||
|
# tracked, same as .env.local.example above.
|
||||||
|
.env.infra
|
||||||
|
.env.business.*
|
||||||
|
!.env.business.example
|
||||||
|
.env.caddy
|
||||||
|
stacks/*/.env
|
||||||
|
stacks/registry.json
|
||||||
|
stacks/caddy/sites/*.caddy
|
||||||
|
|
||||||
# Laravel build output
|
# prisma
|
||||||
/public/build
|
prisma/dev.db
|
||||||
/public/hot
|
prisma/dev.db-journal
|
||||||
/storage/framework/views/
|
|
||||||
_ide_helper.php
|
# misc
|
||||||
Homestead.json
|
.DS_Store
|
||||||
Homestead.yaml
|
*.pem
|
||||||
Thumbs.db
|
.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 +1 @@
|
|||||||
ignore-scripts=true
|
cache=/private/tmp/claude-501/-Users-stefanlazic-Documents-GitHub-NextChair/11cd8d75-4d20-4339-a2c5-329dd2e59132/scratchpad/npm-cache
|
||||||
audit=true
|
|
||||||
File renamed without changes.
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
File renamed without changes.
@@ -1,34 +1,383 @@
|
|||||||
# NextChair
|
+# NextChair
|
||||||
|
|
||||||
NextChair is a multi-tenant, white-label booking platform for barbershops.
|
NextChair is a white-label, mobile-first booking platform for barbershops. It combines customer booking, staff scheduling, owner administration, notifications, loyalty, media, reviews, and optional Google integrations in one Next.js application.
|
||||||
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)
|
## Technology
|
||||||
as the functional and visual migration reference. Deployment-specific files
|
|
||||||
from the previous implementation are retained there for reference only; the
|
|
||||||
Laravel application does not manage Docker, Caddy, Nginx, or server
|
|
||||||
provisioning.
|
|
||||||
|
|
||||||
## Local development
|
- Next.js 16 with the App Router and React 19
|
||||||
|
- TypeScript 6
|
||||||
|
- MySQL 8.0, accessed through Prisma 7 and the MariaDB driver adapter
|
||||||
|
- Auth.js / NextAuth v5 for authentication
|
||||||
|
- Tailwind CSS 4, Radix UI, Lucide, and Font Awesome
|
||||||
|
- Vitest for unit and integration tests
|
||||||
|
- Playwright for browser tests
|
||||||
|
- Docker Compose for a production-style local deployment
|
||||||
|
|
||||||
|
The application is intentionally configured with discrete database settings: `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USER`, and `DATABASE_PASSWORD`. It does not use `DATABASE_URL` at runtime.
|
||||||
|
|
||||||
|
## Deployment Model
|
||||||
|
|
||||||
|
The intended deployment model is one repeatable Docker deployment for both a
|
||||||
|
local/self-hosted Debian server and production:
|
||||||
|
|
||||||
|
- One Dockge-managed Caddy stack owns ports 80 and 443 and routes each domain.
|
||||||
|
- One Dockge-managed MySQL 8 stack hosts a separate database and user per business.
|
||||||
|
- Each business is a separate Dockge stack with its own media volume and env file.
|
||||||
|
- Business containers publish no host ports and share only the proxy and database networks.
|
||||||
|
- Each business is installed independently through `/install` after its stack starts.
|
||||||
|
|
||||||
|
`deploy/business-compose.yaml.template` and `scripts/create-business.sh` are the
|
||||||
|
canonical business deployment path. The local Docker file remains useful for a
|
||||||
|
single isolated development instance.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
For local Node development:
|
||||||
|
|
||||||
|
- Node.js 22 or later
|
||||||
|
- npm
|
||||||
|
- A reachable MySQL 8.0 database
|
||||||
|
|
||||||
|
For the primary VPS deployment:
|
||||||
|
|
||||||
|
- A Linux VPS with Docker Engine and Docker Compose
|
||||||
|
- A domain per business, with DNS pointing at the VPS
|
||||||
|
- Ports 80 and 443 available for Caddy and Let's Encrypt
|
||||||
|
- Dockge configured to scan the directory containing the generated stacks
|
||||||
|
|
||||||
|
For local development:
|
||||||
|
|
||||||
|
- Docker Desktop with Docker Compose
|
||||||
|
|
||||||
|
## Local Docker Desktop (Isolated Business Instance)
|
||||||
|
|
||||||
|
This is a disposable local approximation of one business from the VPS deployment. It starts one MySQL container and one NextChair container, applies migrations automatically, and leaves the database empty until you either complete the installer or explicitly run the demo seed. It is not the shared multi-business topology; use the VPS setup above for the canonical deployment.
|
||||||
|
|
||||||
|
1. Install dependencies if you also plan to run commands on the host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer install
|
npm ci
|
||||||
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
|
2. Create the Docker environment file:
|
||||||
application resolves a business from the request hostname through the
|
|
||||||
`business_domains` table. Local development can use a configured local domain
|
|
||||||
or the `APP_DEFAULT_BUSINESS_SLUG` fallback.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan test
|
cp .env.local.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Replace the placeholder values in `.env.local`. At minimum, set strong values for:
|
||||||
|
|
||||||
|
- `MYSQL_PASSWORD`
|
||||||
|
- `MYSQL_ROOT_PASSWORD`
|
||||||
|
- `DATABASE_PASSWORD`
|
||||||
|
- `AUTH_SECRET`
|
||||||
|
- `INSTALLATION_TOKEN`
|
||||||
|
- `CRON_SECRET`
|
||||||
|
|
||||||
|
`DATABASE_NAME`, `DATABASE_USER`, and `DATABASE_PASSWORD` must match the corresponding MySQL container settings. Inside Docker, `DATABASE_HOST` must remain `next-chair-mysql`, not `localhost`.
|
||||||
|
|
||||||
|
Generate suitable secrets with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
npx auth secret
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Build and start the stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Open [http://localhost:3000](http://localhost:3000). If the database has not been installed, the application redirects to `/install`. Enter the `INSTALLATION_TOKEN` and complete the wizard to create the first business and owner account.
|
||||||
|
|
||||||
|
6. Follow the application logs when needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local logs -f next-chair
|
||||||
|
```
|
||||||
|
|
||||||
|
The application health endpoint is [http://localhost:3000/api/health](http://localhost:3000/api/health). It returns HTTP 200 when the database is reachable, even before the installation wizard has been completed.
|
||||||
|
|
||||||
|
### Docker lifecycle commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop containers but keep database and media volumes
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local down
|
||||||
|
|
||||||
|
# Start again without rebuilding
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local up -d
|
||||||
|
|
||||||
|
# Run the development/demo fixture explicitly
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local run --rm next-chair-seed
|
||||||
|
|
||||||
|
# Remove containers and all local database/media data
|
||||||
|
docker compose -p next-chair -f docker-compose.local.yaml --env-file .env.local down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The seed service is opt-in. Normal `up` never seeds the database. `down -v` is destructive for this local deployment: it removes the named MySQL and media volumes.
|
||||||
|
|
||||||
|
## Local Node Development
|
||||||
|
|
||||||
|
Use this path when you already have MySQL running outside Docker or want fast Next.js source reloads.
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `.env.local` from `.env.example` and configure a local MySQL 8 database. Set `DATABASE_HOST=localhost` and make sure the database and user already exist.
|
||||||
|
|
||||||
|
3. Generate the Prisma client and apply development migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run prisma:generate
|
||||||
|
npm run prisma:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Start the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
5. Choose one database bootstrap path:
|
||||||
|
|
||||||
|
- For a real, empty installation, do not seed. Use `/install` with `INSTALLATION_TOKEN`.
|
||||||
|
- For development fixtures, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run prisma:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
The seed is idempotent and creates demo business, staff, services, customers, appointments, and related records. It marks the database as installed so the application can be used immediately. Do not run it alongside a real first-run installation.
|
||||||
|
|
||||||
|
The direct Node path and the local Docker path should use different `.env.local` values: Docker connects to `next-chair-mysql`, while a host-run Next.js process normally connects to `localhost`. Business stacks use `DATABASE_HOST=nextchair-mysql` on the shared database network.
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
The available templates are:
|
||||||
|
|
||||||
|
- `.env.example`: host-run development configuration
|
||||||
|
- `.env.local.example`: Docker Desktop configuration
|
||||||
|
- `.env.infra.example`: shared MySQL root settings
|
||||||
|
- `stacks/caddy/.env.example`: shared Caddy ACME email
|
||||||
|
- `.env.business.example`: generated business environment reference
|
||||||
|
|
||||||
|
Required for a basic running installation:
|
||||||
|
|
||||||
|
- MySQL settings: `DATABASE_*`
|
||||||
|
- `AUTH_SECRET`
|
||||||
|
- `NEXT_PUBLIC_APP_URL`
|
||||||
|
- `INSTALLATION_TOKEN`
|
||||||
|
- `CRON_SECRET`
|
||||||
|
- `AUTH_TRUST_HOST=true` when serving through a non-localhost host or reverse proxy
|
||||||
|
|
||||||
|
Optional integrations are disabled safely when left unconfigured:
|
||||||
|
|
||||||
|
- Google sign-in: `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET`
|
||||||
|
- Google Calendar sync: `GOOGLE_CALENDAR_*`, `CALENDAR_TOKEN_ENCRYPTION_KEY`
|
||||||
|
- Google Reviews: `GOOGLE_PLACES_API_KEY`
|
||||||
|
- SMTP email: set `EMAIL_PROVIDER=brevo` and provide the `EMAIL_SMTP_*` and `EMAIL_FROM_ADDRESS` values. The default `console` provider logs notifications instead of sending them.
|
||||||
|
- Web Push: set `PUSH_PROVIDER=web-push` and provide the VAPID keys. Generate them with `npx web-push generate-vapid-keys`.
|
||||||
|
- Platform super administrator: `SUPER_ADMIN_EMAIL`, `SUPER_ADMIN_PASSWORD`, `SUPER_ADMIN_FIRST_NAME`, and `SUPER_ADMIN_LAST_NAME`
|
||||||
|
|
||||||
|
Do not commit `.env.local`, production environment files, OAuth secrets, SMTP credentials, VAPID private keys, or encryption keys.
|
||||||
|
|
||||||
|
## Application Flow
|
||||||
|
|
||||||
|
The application has a deliberate first-run gate:
|
||||||
|
|
||||||
|
1. A fresh database is migrated by Prisma.
|
||||||
|
2. Requests are redirected to `/install`, except for the installer and required static/PWA assets.
|
||||||
|
3. The installer validates `INSTALLATION_TOKEN`, then creates the business, primary location, settings, hours, booking rules, loyalty configuration, starter services, owner role, and first administrator in one transaction.
|
||||||
|
4. After installation, `/staff` and `/owner` require authentication and permissions. Customer browsing and booking routes remain available according to the business booking rules.
|
||||||
|
|
||||||
|
The installation token is checked server-side and rate-limited. It is never stored in the database. The installer is one-way for a deployment: to start over locally, remove the local database volume or use a separate database rather than attempting to reinstall over existing data.
|
||||||
|
|
||||||
|
## Database and Prisma
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run prisma:generate # Generate the client in generated/prisma
|
||||||
|
npm run prisma:migrate # Create/apply a development migration
|
||||||
|
npm run prisma:studio # Open Prisma Studio
|
||||||
|
npm run prisma:seed # Load idempotent development fixtures
|
||||||
|
```
|
||||||
|
|
||||||
|
For a deployed database, the container entrypoint runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx tsx scripts/ensure-super-admin.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations are applied on every application container start and are not destructive. The optional seed is separate because demo data is not an appropriate production bootstrap mechanism.
|
||||||
|
|
||||||
|
## Quality Checks and Tests
|
||||||
|
|
||||||
|
Run the fast checks first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:watch
|
||||||
|
npm run test:integration
|
||||||
|
npm run test:e2e
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Unit tests do not require a database. Integration tests require a disposable MySQL 8 database configured through the `DATABASE_*` variables. They apply migrations but the caller is responsible for providing the database.
|
||||||
|
|
||||||
|
The Playwright suite starts a development server on port `3100`, applies migrations, loads the demo seed, and provisions test credentials. Point it at a throwaway database, never a database containing real business data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_HOST=localhost \
|
||||||
|
DATABASE_PORT=3306 \
|
||||||
|
DATABASE_NAME=nextchair_e2e \
|
||||||
|
DATABASE_USER=nextchair \
|
||||||
|
DATABASE_PASSWORD='replace-me' \
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Playwright uses one Chromium project, one worker, a 45-second test timeout, screenshots on failure, and retains traces on failure. The database should be reset or recreated between isolated test runs when fixtures from previous runs could affect results.
|
||||||
|
|
||||||
|
## Docker Server Deployment
|
||||||
|
|
||||||
|
This is the same topology for a Debian self-hosted server and a production
|
||||||
|
server. The only intentional deployment difference is the image tag: build
|
||||||
|
`nextchair:latest` locally, or use a registry tag such as
|
||||||
|
`registry.example.com/nextchair:2026-09-04` in production.
|
||||||
|
|
||||||
|
### 1. Prepare the server
|
||||||
|
|
||||||
|
Copy this repository's deployment files to a stable directory, for example
|
||||||
|
`/opt/nextchair`, and configure Dockge to use `/opt/nextchair/stacks` as its
|
||||||
|
stack directory. The tracked `infra` and `caddy` directories are shared
|
||||||
|
stacks; every generated business directory sits beside them.
|
||||||
|
|
||||||
|
Build or pull the application image once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Self-hosted/local image
|
||||||
|
docker build -t nextchair:latest .
|
||||||
|
|
||||||
|
# Production image example
|
||||||
|
docker pull registry.example.com/nextchair:2026-09-04
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the shared networks and start the two infrastructure stacks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.infra.example .env.infra
|
||||||
|
cp stacks/caddy/.env.example stacks/caddy/.env
|
||||||
|
# Edit both env files before starting them.
|
||||||
|
docker network create nextchair-db
|
||||||
|
docker network create nextchair-proxy
|
||||||
|
docker compose -f stacks/infra/compose.yaml up -d
|
||||||
|
docker compose -f stacks/caddy/compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the Manager stack as well, after copying its environment template and
|
||||||
|
setting a strong platform-admin password and auth secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp stacks/manager/.env.example stacks/manager/.env
|
||||||
|
docker compose -f stacks/manager/compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a Manager-only Caddy route in `stacks/caddy/sites/manager.caddy`, replacing
|
||||||
|
the example hostname with the private/admin hostname you control:
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
manager.example.com {
|
||||||
|
reverse_proxy nextchair-manager:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload Caddy after adding the route, then open `/manager`. The Manager has its
|
||||||
|
own administrator session and does not use any business customer, staff, or
|
||||||
|
owner account.
|
||||||
|
|
||||||
|
The MySQL port is loopback-only by default. Use an SSH tunnel for database
|
||||||
|
administration rather than opening port 3306 publicly.
|
||||||
|
|
||||||
|
### 2. Provision a business
|
||||||
|
|
||||||
|
Run this after MySQL is healthy. The script creates the database and least
|
||||||
|
scope database user, generates secrets, writes a Dockge-ready stack directory,
|
||||||
|
and writes one Caddy site file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXTCHAIR_STACK_ROOT=/opt/nextchair/stacks \
|
||||||
|
NEXTCHAIR_CADDY_SITES_DIR=/opt/nextchair/stacks/caddy/sites \
|
||||||
|
./scripts/create-business.sh blake-heritage blakeheritage.com nextchair:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
For production, pass the exact image tag to use. The script never overwrites
|
||||||
|
an existing business slug. In Dockge, the new directory appears as its own
|
||||||
|
stack, so `start`, `stop`, `restart`, `recreate`, `update`, and logs are scoped
|
||||||
|
to that business. The generated `.env` is mode `600` and is ignored by git.
|
||||||
|
|
||||||
|
After DNS points at the server, start the generated stack from Dockge. Caddy
|
||||||
|
will obtain the certificate and route the domain to the app container over the
|
||||||
|
private `nextchair-proxy` network. Reload Caddy after adding or removing a
|
||||||
|
route:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f stacks/caddy/compose.yaml exec nextchair-caddy caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat the provisioning command for each business. A business's database,
|
||||||
|
media volume, env file, app container, and Caddy route are all named from its
|
||||||
|
slug. The application migration entrypoint runs on every start, while the
|
||||||
|
first-run business setup remains the app-level `/install` flow.
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/app/ Next.js routes, layouts, and API handlers
|
||||||
|
src/features/ Feature workflows, server actions, schemas, components
|
||||||
|
src/services/ Domain services and external-provider integrations
|
||||||
|
src/lib/ Shared infrastructure, auth, database, security, and utilities
|
||||||
|
prisma/ Schema, migrations, and development seed
|
||||||
|
e2e/ Playwright browser journeys and test setup
|
||||||
|
docker/ Container entrypoint
|
||||||
|
docs/ Phase implementation notes and historical decisions
|
||||||
|
deploy/ Reusable business stack template
|
||||||
|
stacks/ Dockge stacks: infra, caddy, and businesses
|
||||||
|
scripts/ Database/business provisioning utilities
|
||||||
|
templates/ UI reference templates
|
||||||
|
```
|
||||||
|
|
||||||
|
The phase documents in `docs/` contain implementation history and deeper design rationale. They are useful when changing installation, scheduling, notification, calendar, media, or deployment behavior, but this README is the intended starting point for running the project.
|
||||||
|
|
||||||
|
## Common Troubleshooting
|
||||||
|
|
||||||
|
**The app redirects to `/install`.** This is expected for a fresh or unseeded database. Set `INSTALLATION_TOKEN` and complete the wizard, or explicitly run the development seed.
|
||||||
|
|
||||||
|
**The app cannot connect to MySQL in the local stack.** Use `DATABASE_HOST=next-chair-mysql`, not `localhost`, and ensure the local credentials match the local MySQL container.
|
||||||
|
|
||||||
|
**The app cannot connect to MySQL in a business stack.** Use `DATABASE_HOST=nextchair-mysql`, confirm `nextchair-mysql` is attached to `nextchair-db`, and confirm the generated `DATABASE_NAME`, `DATABASE_USER`, and `DATABASE_PASSWORD` match the database created by `scripts/create-business.sh`.
|
||||||
|
|
||||||
|
**The app cannot connect to MySQL when run with `npm run dev`.** Use `DATABASE_HOST=localhost` or the hostname of your external MySQL server, and verify that the database and user already exist.
|
||||||
|
|
||||||
|
**Port 3000 is already in use.** Set `APP_HOST_PORT` in `.env.local` for Docker, or start Next.js with `npm run dev -- --port 3001` for host development. Update `NEXT_PUBLIC_APP_URL` when callback URLs need to match the new port.
|
||||||
|
|
||||||
|
**Emails or push notifications are not delivered.** The default providers are console-only. Configure the relevant provider and credentials before expecting external delivery.
|
||||||
|
|
||||||
|
**A reset is needed during local development.** Use a separate database or `docker compose ... down -v` for the local Docker stack. This removes local database and media data and should never be used against production volumes.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The package currently declares the ISC license in `package.json`. Confirm the intended distribution license before publishing the application or its assets.
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum CalendarProvider: string { case Google = 'GOOGLE'; }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum CalendarSyncStatus: string { case Synced = 'SYNCED'; case Pending = 'PENDING'; case Failed = 'FAILED'; case Cancelled = 'CANCELLED'; }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum GalleryPlacement: string { case Hero = 'HERO'; case Portfolio = 'PORTFOLIO'; }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum InstallationStatus: string { case NotInstalled = 'NOT_INSTALLED'; case Installing = 'INSTALLING'; case Installed = 'INSTALLED'; case Failed = 'FAILED'; }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum LoyaltyCardFont: string { case Sans = 'SANS'; case Serif = 'SERIF'; case Mono = 'MONO'; }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum LoyaltyCardTextSize: string { case Sm = 'SM'; case Md = 'MD'; case Lg = 'LG'; }
|
|
||||||
@@ -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'; }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum NotificationChannel: string { case Email = 'EMAIL'; case Push = 'PUSH'; case InApp = 'IN_APP'; }
|
|
||||||
@@ -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'; }
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum RewardPositionMode: string { case AfterAllStamps = 'AFTER_ALL_STAMPS'; case FinalSlotIsReward = 'FINAL_SLOT_IS_REWARD'; }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum RewardStatus: string { case Available = 'AVAILABLE'; case Redeemed = 'REDEEMED'; case Expired = 'EXPIRED'; }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum RewardType: string { case Percentage = 'PERCENTAGE'; case FixedAmount = 'FIXED_AMOUNT'; case FreeService = 'FREE_SERVICE'; }
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum StaffStatus: string
|
|
||||||
{
|
|
||||||
case Active = 'ACTIVE';
|
|
||||||
case OnBreak = 'ON_BREAK';
|
|
||||||
case Inactive = 'INACTIVE';
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Enums;
|
|
||||||
|
|
||||||
enum TimeOffStatus: string { case Pending = 'PENDING'; case Approved = 'APPROVED'; case Declined = 'DECLINED'; }
|
|
||||||
@@ -1,109 +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();
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
Auth::logout();
|
|
||||||
$request->session()->invalidate();
|
|
||||||
$request->session()->regenerateToken();
|
|
||||||
|
|
||||||
return redirect()->route('public.home');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Enums\Permission;
|
|
||||||
use App\Enums\InstallationStatus;
|
|
||||||
use App\Models\{ApplicationInstallation, BookingRule, Business, BusinessDomain, BusinessSettings, Role, RolePermission, StaffProfile, User};
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class InstallController extends Controller
|
|
||||||
{
|
|
||||||
public function create(): View|RedirectResponse
|
|
||||||
{
|
|
||||||
if (ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) return redirect()->route('public.home');
|
|
||||||
return view('install');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
if (ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) return redirect()->route('public.home');
|
|
||||||
$data = $request->validate(['businessName' => ['required', 'string', 'max:190'], 'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug'], 'adminName' => ['required', 'string', 'max:190'], 'adminEmail' => ['required', 'email', 'max:190', 'unique:User,email'], 'password' => ['required', 'string', 'min:8', 'confirmed'], 'domain' => ['nullable', 'string', 'max:190']]);
|
|
||||||
|
|
||||||
DB::transaction(function () use ($data, $request): void {
|
|
||||||
$business = Business::create(['slug' => strtolower($data['slug']), 'name' => $data['businessName'], 'shortName' => $data['businessName'], 'timezone' => config('app.timezone', 'Europe/London'), 'currency' => 'GBP', 'locale' => 'en-GB']);
|
|
||||||
BusinessDomain::create(['businessId' => $business->id, 'hostname' => strtolower($data['domain'] ?: $request->getHost()), 'isPrimary' => true, 'isActive' => true]);
|
|
||||||
BusinessSettings::create(['businessId' => $business->id]);
|
|
||||||
BookingRule::create(['businessId' => $business->id, 'allowGuestBooking' => true]);
|
|
||||||
$role = Role::create(['businessId' => $business->id, 'name' => 'Owner', 'isSystem' => true]);
|
|
||||||
foreach (Permission::cases() as $permission) RolePermission::create(['roleId' => $role->id, 'permission' => $permission]);
|
|
||||||
$user = User::create(['name' => $data['adminName'], 'email' => strtolower($data['adminEmail']), 'passwordHash' => Hash::make($data['password']), 'emailVerified' => now()]);
|
|
||||||
$staff = StaffProfile::create(['userId' => $user->id, 'businessId' => $business->id, 'roleId' => $role->id, 'status' => 'ACTIVE', 'isBookableOnline' => false]);
|
|
||||||
ApplicationInstallation::create(['id' => 'singleton', 'status' => InstallationStatus::Installed, 'installedAt' => now(), 'installedVersion' => 'laravel-1', 'initialAdminUserId' => $user->id, 'primaryBusinessId' => $business->id, 'metadata' => ['starter' => true]]);
|
|
||||||
});
|
|
||||||
return redirect()->route('install')->with('status', 'Installation complete. You can now sign in.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,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,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,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Models\Business;
|
|
||||||
use App\Models\BusinessDomain;
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class ResolveTenant
|
|
||||||
{
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
$hostname = strtolower(rtrim($request->getHost(), '.'));
|
|
||||||
try {
|
|
||||||
$business = BusinessDomain::query()
|
|
||||||
->where('hostname', $hostname)
|
|
||||||
->where('isActive', true)
|
|
||||||
->with('business')
|
|
||||||
->first()?->business;
|
|
||||||
|
|
||||||
if (! $business && app()->environment('local')) {
|
|
||||||
$slug = config('app.default_business_slug');
|
|
||||||
$business = $slug
|
|
||||||
? Business::query()->where('slug', $slug)->first()
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
} 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)->set($business);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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']; }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -1,49 +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 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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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']; }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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'); }
|
|
||||||
}
|
|
||||||
@@ -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']; }
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Loyalty;
|
|
||||||
|
|
||||||
use App\Enums\{LoyaltyTransactionType, RewardPositionMode, RewardStatus, RewardType};
|
|
||||||
use App\Models\{Appointment, Business, CustomerProfile, LoyaltyAccount, LoyaltyProgramme, LoyaltyReward, LoyaltyTransaction, StaffProfile};
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class LoyaltyService
|
|
||||||
{
|
|
||||||
public function summary(Business $business, CustomerProfile $customer): ?array
|
|
||||||
{
|
|
||||||
$programme = $business->loyaltyProgramme;
|
|
||||||
if (! $programme) return null;
|
|
||||||
|
|
||||||
$account = LoyaltyAccount::query()
|
|
||||||
->where('customerId', $customer->id)
|
|
||||||
->where('loyaltyProgrammeId', $programme->id)
|
|
||||||
->with(['rewards' => fn ($query) => $query->where('status', RewardStatus::Available->value)->latest('createdAt')])
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'programme' => $programme,
|
|
||||||
'account' => $account,
|
|
||||||
'currentStamps' => $account?->currentStamps ?? 0,
|
|
||||||
'threshold' => $this->threshold($programme),
|
|
||||||
'availableReward' => $account?->rewards->first(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addStamp(Business $business, CustomerProfile $customer, StaffProfile $staff, int $amount = 1, ?Appointment $appointment = null, ?string $reason = null): LoyaltyAccount
|
|
||||||
{
|
|
||||||
if ($amount < 1) throw new RuntimeException('Stamp amount must be positive.');
|
|
||||||
$programme = $business->loyaltyProgramme;
|
|
||||||
if (! $programme?->isEnabled) throw new RuntimeException('Loyalty is not enabled for this business.');
|
|
||||||
$this->assertEligible($programme, $appointment);
|
|
||||||
|
|
||||||
return DB::transaction(function () use ($programme, $customer, $staff, $amount, $appointment, $reason): LoyaltyAccount {
|
|
||||||
$account = LoyaltyAccount::query()->firstOrCreate(['customerId' => $customer->id, 'loyaltyProgrammeId' => $programme->id], ['currentStamps' => 0]);
|
|
||||||
$account = LoyaltyAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail();
|
|
||||||
$account->currentStamps += $amount;
|
|
||||||
$account->save();
|
|
||||||
LoyaltyTransaction::create([
|
|
||||||
'loyaltyAccountId' => $account->id,
|
|
||||||
'type' => LoyaltyTransactionType::StampAdded,
|
|
||||||
'amount' => $amount,
|
|
||||||
'reason' => $reason,
|
|
||||||
'appointmentId' => $appointment?->id,
|
|
||||||
'authoredByStaffId' => $staff->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($account->currentStamps >= $this->threshold($programme) && ! $account->rewards()->where('status', RewardStatus::Available->value)->exists()) {
|
|
||||||
LoyaltyReward::create([
|
|
||||||
'loyaltyAccountId' => $account->id,
|
|
||||||
'rewardType' => $programme->rewardType,
|
|
||||||
'value' => $programme->rewardValue,
|
|
||||||
'label' => $programme->rewardLabel ?: 'Reward',
|
|
||||||
'status' => RewardStatus::Available,
|
|
||||||
'expiresAt' => $programme->rewardExpiresAfterDays ? now()->addDays($programme->rewardExpiresAfterDays) : null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $account->fresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function correctStamp(LoyaltyAccount $account, StaffProfile $staff, int $amount, string $reason): void
|
|
||||||
{
|
|
||||||
if ($amount < 1 || trim($reason) === '') throw new RuntimeException('A positive amount and reason are required.');
|
|
||||||
DB::transaction(function () use ($account, $staff, $amount, $reason): void {
|
|
||||||
$locked = LoyaltyAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail();
|
|
||||||
$removed = min($amount, $locked->currentStamps);
|
|
||||||
$locked->currentStamps -= $removed;
|
|
||||||
$locked->save();
|
|
||||||
LoyaltyTransaction::create(['loyaltyAccountId' => $locked->id, 'type' => LoyaltyTransactionType::StampRemoved, 'amount' => -$removed, 'reason' => trim($reason), 'authoredByStaffId' => $staff->id]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function redeemReward(Business $business, LoyaltyReward $reward, Appointment $appointment, StaffProfile $staff): string
|
|
||||||
{
|
|
||||||
$reward->loadMissing('account.programme');
|
|
||||||
$account = $reward->account;
|
|
||||||
$programme = $account?->programme;
|
|
||||||
if (! $programme || $reward->status !== RewardStatus::Available || $appointment->businessId !== $business->id || $appointment->customerId !== $account->customerId) {
|
|
||||||
throw new RuntimeException('That reward is not available for this appointment.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$subtotal = (float) $appointment->services()->sum('priceAtBooking');
|
|
||||||
$discount = match ($reward->rewardType) {
|
|
||||||
RewardType::Percentage => min($subtotal, round($subtotal * ((float) $reward->value / 100), 2)),
|
|
||||||
RewardType::FixedAmount => min($subtotal, (float) $reward->value),
|
|
||||||
RewardType::FreeService => min($subtotal, (float) ($programme->freeService?->price ?? $subtotal)),
|
|
||||||
};
|
|
||||||
|
|
||||||
DB::transaction(function () use ($reward, $account, $appointment, $staff, $discount, $programme): void {
|
|
||||||
$locked = LoyaltyReward::query()->whereKey($reward->id)->lockForUpdate()->firstOrFail();
|
|
||||||
if ($locked->status !== RewardStatus::Available) throw new RuntimeException('That reward has already been redeemed.');
|
|
||||||
$locked->status = RewardStatus::Redeemed;
|
|
||||||
$locked->redeemedAt = now();
|
|
||||||
$locked->redeemedByStaffId = $staff->id;
|
|
||||||
$locked->appliedToAppointmentId = $appointment->id;
|
|
||||||
$locked->save();
|
|
||||||
$lockedAccount = LoyaltyAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail();
|
|
||||||
$lockedAccount->currentStamps = max(0, $lockedAccount->currentStamps - $programme->stampsRequired);
|
|
||||||
$lockedAccount->save();
|
|
||||||
LoyaltyTransaction::create(['loyaltyAccountId' => $lockedAccount->id, 'type' => LoyaltyTransactionType::RewardRedeemed, 'amount' => -$programme->stampsRequired, 'reason' => 'Reward redeemed', 'appointmentId' => $appointment->id, 'authoredByStaffId' => $staff->id]);
|
|
||||||
$appointment->discountAmount = $discount;
|
|
||||||
$appointment->save();
|
|
||||||
});
|
|
||||||
|
|
||||||
return number_format($discount, 2, '.', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function awardForCompletedAppointment(Appointment $appointment, StaffProfile $staff): ?LoyaltyAccount
|
|
||||||
{
|
|
||||||
if ($appointment->status->value !== 'COMPLETED') return null;
|
|
||||||
$already = LoyaltyTransaction::query()->where('appointmentId', $appointment->id)->where('type', LoyaltyTransactionType::StampAdded->value)->exists();
|
|
||||||
if ($already) return null;
|
|
||||||
$appointment->loadMissing('business', 'customer', 'services');
|
|
||||||
return $this->addStamp($appointment->business, $appointment->customer, $staff, 1, $appointment);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function threshold(LoyaltyProgramme $programme): int
|
|
||||||
{
|
|
||||||
return max(1, $programme->rewardPosition === RewardPositionMode::FinalSlotIsReward ? $programme->stampsRequired - 1 : $programme->stampsRequired);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assertEligible(LoyaltyProgramme $programme, ?Appointment $appointment): void
|
|
||||||
{
|
|
||||||
if (! $appointment) return;
|
|
||||||
$serviceIds = $appointment->services->pluck('serviceId');
|
|
||||||
$eligibleServices = $programme->eligibleServices()->pluck('serviceId');
|
|
||||||
$eligibleStaff = $programme->eligibleStaff()->pluck('staffId');
|
|
||||||
if ($eligibleStaff->isNotEmpty() && ! $eligibleStaff->contains($appointment->staffId)) throw new RuntimeException("This staff member's visits don't earn loyalty stamps.");
|
|
||||||
if ($eligibleServices->isNotEmpty() && $serviceIds->intersect($eligibleServices)->isEmpty()) throw new RuntimeException("None of the services on this visit earn a loyalty stamp.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Notifications;
|
|
||||||
|
|
||||||
use App\Enums\{NotificationChannel, NotificationType};
|
|
||||||
use App\Models\{Business, Notification};
|
|
||||||
|
|
||||||
class NotificationService
|
|
||||||
{
|
|
||||||
public function create(Business $business, NotificationType $type, string $body, string $idempotencyKey, ?string $userId = null, ?string $customerId = null, ?string $appointmentId = null, ?string $deepLinkPath = null, ?string $subject = null): Notification
|
|
||||||
{
|
|
||||||
$notification = Notification::query()->firstOrCreate(
|
|
||||||
['idempotencyKey' => $idempotencyKey],
|
|
||||||
['businessId' => $business->id, 'recipientUserId' => $userId, 'recipientCustomerId' => $customerId, 'type' => $type, 'appointmentId' => $appointmentId, 'subject' => $subject, 'bodyText' => $body, 'deepLinkPath' => $deepLinkPath],
|
|
||||||
);
|
|
||||||
if ($userId) $notification->deliveries()->firstOrCreate(['channel' => NotificationChannel::InApp], ['status' => 'SENT', 'sentAt' => now()]);
|
|
||||||
return $notification;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Tenant;
|
|
||||||
|
|
||||||
use App\Models\Business;
|
|
||||||
|
|
||||||
final class TenantContext
|
|
||||||
{
|
|
||||||
private ?Business $business = null;
|
|
||||||
|
|
||||||
public function set(Business $business): void
|
|
||||||
{
|
|
||||||
$this->business = $business;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function business(): ?Business
|
|
||||||
{
|
|
||||||
return $this->business;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function id(): ?string
|
|
||||||
{
|
|
||||||
return $this->business?->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function require(): Business
|
|
||||||
{
|
|
||||||
abort_unless($this->business, 404, 'Business not found.');
|
|
||||||
|
|
||||||
return $this->business;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
use Symfony\Component\Console\Input\ArgvInput;
|
|
||||||
|
|
||||||
define('LARAVEL_START', microtime(true));
|
|
||||||
|
|
||||||
// Register the Composer autoloader...
|
|
||||||
require __DIR__.'/vendor/autoload.php';
|
|
||||||
|
|
||||||
// Bootstrap Laravel and handle the command...
|
|
||||||
/** @var Application $app */
|
|
||||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
|
||||||
|
|
||||||
$status = $app->handleCommand(new ArgvInput);
|
|
||||||
|
|
||||||
exit($status);
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use App\Http\Middleware\ResolveTenant;
|
|
||||||
use App\Http\Middleware\EnsureStaffPermission;
|
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
|
||||||
->withRouting(
|
|
||||||
web: __DIR__.'/../routes/web.php',
|
|
||||||
commands: __DIR__.'/../routes/console.php',
|
|
||||||
health: '/up',
|
|
||||||
)
|
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
|
||||||
$middleware->alias([
|
|
||||||
'tenant' => ResolveTenant::class,
|
|
||||||
'staff.permission' => EnsureStaffPermission::class,
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
|
||||||
$exceptions->shouldRenderJsonWhen(
|
|
||||||
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
|
|
||||||
);
|
|
||||||
})->create();
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
Loaded 100 of 690 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user