Compare commits
21
Commits
5972d37425
...
9fe5b4f038
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fe5b4f038 | ||
|
|
89bce03ebb | ||
|
|
e5668b3f23 | ||
|
|
eaa0d2e513 | ||
|
|
7e70fa0583 | ||
|
|
d063918aae | ||
|
|
3e9fe60faf | ||
|
|
65d2c7322b | ||
|
|
638b851fce | ||
|
|
c237b565bd | ||
|
|
32e93c6c23 | ||
|
|
3cef279d2d | ||
|
|
bcf7fa58d3 | ||
|
|
3de53f0a67 | ||
|
|
344c08b840 | ||
|
|
2aa8405006 | ||
|
|
a59f998f2b | ||
|
|
39eeb937de | ||
|
|
33fb14b773 | ||
|
|
3e052e56b1 | ||
|
|
fa96fe0e15 |
No files matched your search
+15
-20
@@ -1,26 +1,21 @@
|
|||||||
.git
|
|
||||||
.github
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
|
|
||||||
# The previous Next.js application is intentionally outside this build.
|
|
||||||
legacy-nextjs
|
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
vendor
|
.next
|
||||||
|
generated
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
storage/logs/*
|
!.env.local.example
|
||||||
storage/framework/cache/data/*
|
test-results
|
||||||
storage/framework/sessions/*
|
playwright-report
|
||||||
storage/framework/views/*
|
blob-report
|
||||||
bootstrap/cache/*.php
|
playwright/.cache
|
||||||
public/build
|
coverage
|
||||||
docker-compose*.yml
|
|
||||||
docker-compose*.yaml
|
|
||||||
*.log
|
|
||||||
tests
|
|
||||||
e2e
|
e2e
|
||||||
docs
|
docs
|
||||||
templates
|
*.md
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# One business's config on the shared multi-business VPS setup — see
|
||||||
|
# docker-compose.infra.yaml for the one-time setup this depends on
|
||||||
|
# (creating this business's database on the shared MySQL server) and
|
||||||
|
# docker-compose.<business>.yaml for how this file is used. Copy this to
|
||||||
|
# `.env.business.<slug>` (e.g. `.env.business.blake-heritage` — kept out
|
||||||
|
# of git, see .gitignore) per business and fill in real values. Nothing
|
||||||
|
# here is a real credential.
|
||||||
|
#
|
||||||
|
# The app starts and runs a complete first-install flow with ONLY the
|
||||||
|
# "Required for basic startup" section below filled in — every other
|
||||||
|
# integration (email, push, Google sign-in, Google Calendar) shows a
|
||||||
|
# disabled state rather than crashing when its variables are left blank.
|
||||||
|
|
||||||
|
# ── Required for basic startup ─────────────────────────────────────────
|
||||||
|
# The public domain this business is served on — used by Traefik's
|
||||||
|
# routing label (see docker-compose.<business>.yaml) to decide which
|
||||||
|
# container handles requests for it, so this must be set and must match
|
||||||
|
# the domain's real DNS (pointed at the VPS) before Traefik can issue a
|
||||||
|
# certificate for it.
|
||||||
|
DOMAIN="blake-heritage.example.com"
|
||||||
|
|
||||||
|
# The app reaches MySQL via the shared infra's service hostname
|
||||||
|
# `next-chair-mysql`, never `localhost` — see docker-compose.infra.yaml.
|
||||||
|
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for
|
||||||
|
# this business on the shared server (see docker-compose.infra.yaml's
|
||||||
|
# "CREATE DATABASE ..." step) — this file doesn't create them, it only
|
||||||
|
# tells the app what to connect as.
|
||||||
|
DATABASE_HOST="next-chair-mysql"
|
||||||
|
DATABASE_PORT="3306"
|
||||||
|
DATABASE_NAME="blake_heritage"
|
||||||
|
DATABASE_USER="blake_heritage"
|
||||||
|
DATABASE_PASSWORD="replace-me"
|
||||||
|
# Generate with: npx auth secret
|
||||||
|
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
|
||||||
|
# Auth.js needs to trust the host it's served from when not on localhost:3000.
|
||||||
|
AUTH_TRUST_HOST="true"
|
||||||
|
NEXT_PUBLIC_APP_URL="https://blake-heritage.example.com"
|
||||||
|
# One-time setup token the installation wizard (/install) requires before
|
||||||
|
# it will create the first business/administrator — generate with:
|
||||||
|
# openssl rand -hex 32
|
||||||
|
INSTALLATION_TOKEN="replace-me"
|
||||||
|
# Shared secret an external scheduler presents to
|
||||||
|
# /api/cron/process-notifications — generate with: openssl rand -hex 32
|
||||||
|
CRON_SECRET="replace-me"
|
||||||
|
|
||||||
|
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
||||||
|
# Platform-level account with every Owner permission. Not run
|
||||||
|
# automatically by this compose file (that's a Docker-entrypoint-only
|
||||||
|
# behaviour, see docker/entrypoint.sh) — run it manually, once, the same
|
||||||
|
# way migrations were run for the Vercel/Hostinger deployment earlier:
|
||||||
|
# docker exec -it next-chair-blake-heritage npx tsx scripts/ensure-super-admin.ts
|
||||||
|
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install
|
||||||
|
# wizard's own administrator account instead.
|
||||||
|
SUPER_ADMIN_EMAIL=""
|
||||||
|
SUPER_ADMIN_PASSWORD=""
|
||||||
|
SUPER_ADMIN_FIRST_NAME=""
|
||||||
|
SUPER_ADMIN_LAST_NAME=""
|
||||||
|
|
||||||
|
# ── Google sign-in (optional — "Continue with Google" on Sign In) ─────
|
||||||
|
AUTH_GOOGLE_ID=""
|
||||||
|
AUTH_GOOGLE_SECRET=""
|
||||||
|
|
||||||
|
# ── Google Calendar sync (optional — staff-level, see /staff/settings/calendar) ─
|
||||||
|
# From https://console.cloud.google.com/apis/credentials — a separate
|
||||||
|
# OAuth client from AUTH_GOOGLE_ID/SECRET above (different scopes).
|
||||||
|
GOOGLE_CALENDAR_CLIENT_ID=""
|
||||||
|
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
||||||
|
GOOGLE_CALENDAR_REDIRECT_URI="https://blake-heritage.example.com/api/staff/calendar/google/callback"
|
||||||
|
# Encrypts stored refresh tokens at rest — generate with: openssl rand -hex 32
|
||||||
|
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
||||||
|
|
||||||
|
# ── Media Manager (optional — see src/lib/media/media-storage.ts) ──────
|
||||||
|
# Max upload size in bytes for the Media Manager. Defaults to 5,000,000
|
||||||
|
# (5MB) if unset.
|
||||||
|
MEDIA_MAX_UPLOAD_BYTES="5000000"
|
||||||
|
|
||||||
|
# ── Google Reviews (optional — see /owner/settings/reviews) ────────────
|
||||||
|
# From https://console.cloud.google.com/apis/credentials — needs "Places
|
||||||
|
# API (New)" enabled on that project (a different product from the older
|
||||||
|
# "Places API" — enabling the wrong one returns REQUEST_DENIED). Leave
|
||||||
|
# blank to keep review sync disabled entirely (Home shows clearly-labelled
|
||||||
|
# demo reviews instead).
|
||||||
|
GOOGLE_PLACES_API_KEY=""
|
||||||
|
|
||||||
|
# ── Email (optional — defaults to logging to the console instead of sending) ─
|
||||||
|
EMAIL_PROVIDER="console"
|
||||||
|
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
||||||
|
EMAIL_SMTP_PORT="587"
|
||||||
|
EMAIL_SMTP_USERNAME=""
|
||||||
|
EMAIL_SMTP_PASSWORD=""
|
||||||
|
EMAIL_FROM_ADDRESS=""
|
||||||
|
EMAIL_FROM_NAME=""
|
||||||
|
|
||||||
|
# ── Web Push (optional — defaults to logging to the console instead of sending) ─
|
||||||
|
# Generate with: npx web-push generate-vapid-keys
|
||||||
|
PUSH_PROVIDER="console"
|
||||||
|
VAPID_PUBLIC_KEY=""
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
||||||
|
VAPID_PRIVATE_KEY=""
|
||||||
|
VAPID_SUBJECT="mailto:owner@example.com"
|
||||||
+79
-59
@@ -1,70 +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:8085
|
DATABASE_NAME="nextchair"
|
||||||
|
DATABASE_USER="user"
|
||||||
|
DATABASE_PASSWORD="password"
|
||||||
|
# Optional: cap the connection pool (see src/lib/db/prisma.ts). Lower this
|
||||||
|
# for a hosting provider with a small max-connections limit.
|
||||||
|
DATABASE_CONNECTION_LIMIT="5"
|
||||||
|
|
||||||
APP_LOCALE=en
|
# 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=db
|
# "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=nextchair
|
# brevo-email-provider.ts for why SMTP rather than Brevo's REST API).
|
||||||
DB_PASSWORD=change-me
|
EMAIL_PROVIDER="console"
|
||||||
DB_ROOT_PASSWORD=change-me-root
|
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
||||||
|
EMAIL_SMTP_PORT="587"
|
||||||
|
EMAIL_SMTP_USERNAME=""
|
||||||
|
EMAIL_SMTP_PASSWORD=""
|
||||||
|
EMAIL_FROM_ADDRESS=""
|
||||||
|
# EMAIL_FROM_NAME is optional and is only used when no business name can
|
||||||
|
# be resolved. Normal business emails use the installed business name
|
||||||
|
# automatically (see docs/phase-8-email-sender-summary.md) — you do not
|
||||||
|
# need to set or keep this in sync with Business Settings.
|
||||||
|
EMAIL_FROM_NAME=""
|
||||||
|
|
||||||
# Docker publishes Nginx on this local bind address by default.
|
# Web Push (Phase 7) — generate with: npx web-push generate-vapid-keys
|
||||||
NEXTCHAIR_HTTP_BIND=127.0.0.1
|
# NEXT_PUBLIC_VAPID_PUBLIC_KEY must be the same value as VAPID_PUBLIC_KEY —
|
||||||
NEXTCHAIR_HTTP_PORT=8085
|
# it's duplicated because the browser needs it for pushManager.subscribe.
|
||||||
|
# PUSH_PROVIDER="web-push" to actually send; defaults to "console" (logs only).
|
||||||
|
PUSH_PROVIDER="console"
|
||||||
|
VAPID_PUBLIC_KEY=""
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
||||||
|
VAPID_PRIVATE_KEY=""
|
||||||
|
VAPID_SUBJECT="mailto:owner@example.com"
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
# Background notification processing (Phase 7) — shared secret an external
|
||||||
SESSION_LIFETIME=120
|
# scheduler presents as `Authorization: Bearer <value>` to
|
||||||
SESSION_ENCRYPT=false
|
# /api/cron/process-notifications. Generate with: openssl rand -hex 32
|
||||||
SESSION_PATH=/
|
CRON_SECRET=""
|
||||||
SESSION_DOMAIN=null
|
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
# First-run installation wizard (Phase 8) — required before /install will
|
||||||
FILESYSTEM_DISK=local
|
# create the first business/administrator. Generate with:
|
||||||
QUEUE_CONNECTION=database
|
# openssl rand -hex 32
|
||||||
|
INSTALLATION_TOKEN=""
|
||||||
|
|
||||||
CACHE_STORE=database
|
# Super Administrator (Phase 9) — platform-level account with every Owner
|
||||||
# CACHE_PREFIX=
|
# permission, provisioned from these env vars rather than the installation
|
||||||
|
# wizard (see scripts/ensure-super-admin.ts, run on every container start).
|
||||||
|
# Has no StaffProfile by design: never appears in Team, the customer-facing
|
||||||
|
# barber picker, or staff totals, and can never receive an appointment. All
|
||||||
|
# four optional — leave SUPER_ADMIN_EMAIL unset to skip provisioning one
|
||||||
|
# entirely. Re-run to rotate the password (re-hashed every run).
|
||||||
|
SUPER_ADMIN_EMAIL=""
|
||||||
|
SUPER_ADMIN_PASSWORD=""
|
||||||
|
SUPER_ADMIN_FIRST_NAME=""
|
||||||
|
SUPER_ADMIN_LAST_NAME=""
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
# Staff Google Calendar sync (Phase 8) — separate OAuth client from
|
||||||
|
# AUTH_GOOGLE_ID/SECRET above (different scopes; see
|
||||||
REDIS_CLIENT=phpredis
|
# src/services/calendar/google-calendar.provider.ts). Leave unset to keep
|
||||||
REDIS_HOST=127.0.0.1
|
# calendar sync disabled — Settings shows a "not configured" state rather
|
||||||
REDIS_PASSWORD=null
|
# than crashing.
|
||||||
REDIS_PORT=6379
|
GOOGLE_CALENDAR_CLIENT_ID=""
|
||||||
|
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
||||||
MAIL_MAILER=log
|
GOOGLE_CALENDAR_REDIRECT_URI="http://localhost:3000/api/staff/calendar/google/callback"
|
||||||
MAIL_SCHEME=null
|
# Encrypts stored refresh tokens at rest. Generate with: openssl rand -hex 32
|
||||||
MAIL_HOST=127.0.0.1
|
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
||||||
MAIL_PORT=2525
|
|
||||||
MAIL_USERNAME=null
|
|
||||||
MAIL_PASSWORD=null
|
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
|
||||||
AWS_SECRET_ACCESS_KEY=
|
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
|
||||||
AWS_BUCKET=
|
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Shared infrastructure config — see docker-compose.infra.yaml for the
|
||||||
|
# full setup sequence. Copy this to `.env.infra` (kept out of git — see
|
||||||
|
# .gitignore) and fill in real values.
|
||||||
|
#
|
||||||
|
# docker network create next-chair-edge
|
||||||
|
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d
|
||||||
|
|
||||||
|
# ── MySQL (shared server — one database per business, created manually
|
||||||
|
# per business, see docker-compose.infra.yaml's own header) ────────────
|
||||||
|
MYSQL_ROOT_PASSWORD="replace-me"
|
||||||
|
# Host-machine port MySQL is reachable on, e.g. for a GUI client over an
|
||||||
|
# SSH tunnel — every business's app container always reaches it via the
|
||||||
|
# shared Docker network and the service name `next-chair-mysql`, never
|
||||||
|
# this.
|
||||||
|
MYSQL_HOST_PORT=3306
|
||||||
|
|
||||||
|
# ── Traefik (reverse proxy + automatic TLS via Let's Encrypt) ──────────
|
||||||
|
# Let's Encrypt sends renewal/expiry notices here — use a real address
|
||||||
|
# you actually check, not a placeholder.
|
||||||
|
ACME_EMAIL="you@example.com"
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
*.blade.php diff=html
|
|
||||||
*.css diff=css
|
|
||||||
*.html diff=html
|
|
||||||
*.md diff=markdown
|
|
||||||
*.php diff=php
|
|
||||||
|
|
||||||
/.github export-ignore
|
|
||||||
CHANGELOG.md export-ignore
|
|
||||||
.styleci.yml export-ignore
|
|
||||||
+63
-37
@@ -1,41 +1,67 @@
|
|||||||
*.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
|
||||||
|
|
||||||
# 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>
|
|
||||||
+72
-73
@@ -1,83 +1,82 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# Multi-stage build. NextChair is the internal project/image name only —
|
||||||
|
# see docs/phase-8-summary.md's white-label section for why that's fine
|
||||||
FROM node:22-bookworm-slim AS frontend
|
# here (Docker/package names are explicitly "internal and acceptable")
|
||||||
|
# even though the running application never shows that name to a user.
|
||||||
WORKDIR /var/www/html
|
#
|
||||||
|
# Prisma 7 uses driver adapters (@prisma/adapter-mariadb), not a native
|
||||||
|
# Rust query-engine binary — the generated client is pure JS, so this
|
||||||
|
# image needs no special native-binary handling the way older Prisma
|
||||||
|
# versions on Alpine sometimes did.
|
||||||
|
#
|
||||||
|
# `output: "standalone"` in next.config.ts was deliberately NOT enabled:
|
||||||
|
# this image runs `prisma migrate deploy` (and, for the optional seed
|
||||||
|
# service, `tsx prisma/seed.ts`) at container start, both of which need
|
||||||
|
# the full `prisma`/`tsx` CLIs and their dependencies present — a pruned
|
||||||
|
# standalone bundle would need those re-added anyway, so a single
|
||||||
|
# `npm ci` (full dependencies, including the small set of dev tools this
|
||||||
|
# image actually runs) is simpler and no less correct. See
|
||||||
|
# docs/phase-8-summary.md, "Docker image quality" for this trade-off.
|
||||||
|
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY resources ./resources
|
FROM node:22-alpine AS builder
|
||||||
COPY public ./public
|
WORKDIR /app
|
||||||
COPY vite.config.js .
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# `npm run build` runs `prisma generate` itself (see package.json — needed
|
||||||
|
# so a plain Vercel/`npm run build` deploy generates the client too, not
|
||||||
|
# just this Docker path, which used to have its own separate `prisma
|
||||||
|
# generate` step here).
|
||||||
|
# `next build`'s page-data collection step imports every route module
|
||||||
|
# (including /api/health) to inspect it — that pulls in
|
||||||
|
# src/lib/db/prisma.ts, which requires DATABASE_HOST/NAME/USER/PASSWORD
|
||||||
|
# just to construct its connection-pool config object, even though nothing
|
||||||
|
# actually connects at build time. These placeholders only exist to
|
||||||
|
# satisfy that check; the real values are supplied at container runtime
|
||||||
|
# via env_file (see docker-compose.local.yaml) and these are never used to
|
||||||
|
# connect to anything or baked into a layer that matters.
|
||||||
|
ENV DATABASE_HOST="localhost"
|
||||||
|
ENV DATABASE_PORT="3306"
|
||||||
|
ENV DATABASE_NAME="build"
|
||||||
|
ENV DATABASE_USER="build"
|
||||||
|
ENV DATABASE_PASSWORD="build"
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
FROM composer:2 AS composer
|
# Non-root — see the brief's "Docker image quality: non-root user."
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextchair
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/.next ./.next
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/generated ./generated
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/prisma ./prisma
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/src ./src
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/scripts ./scripts
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/package.json ./package.json
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/prisma.config.ts ./prisma.config.ts
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/next.config.ts ./next.config.ts
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/tsconfig.json ./tsconfig.json
|
||||||
|
COPY --chown=nextchair:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
||||||
|
RUN chmod +x ./docker/entrypoint.sh
|
||||||
|
# Media Manager uploads (Phase 10) — storage/media (NOT public/) is a
|
||||||
|
# named volume mount point (see docker-compose.local.yaml). Served by
|
||||||
|
# src/app/api/media/[...path]/route.ts, not Next's static handling for
|
||||||
|
# public/ — verified against a real production build that runtime-added
|
||||||
|
# public/ files 404 (Next resolves public assets at build time, not from
|
||||||
|
# a live filesystem check). Creating this here, owned by the non-root
|
||||||
|
# user, before the volume first attaches means Docker initializes the
|
||||||
|
# volume with this ownership rather than root's.
|
||||||
|
RUN mkdir -p ./storage/media && chown -R nextchair:nodejs ./storage
|
||||||
|
|
||||||
FROM php:8.4-fpm-bookworm AS app
|
USER nextchair
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
ENV COMPOSER_ALLOW_SUPERUSER=1 \
|
ENTRYPOINT ["./docker/entrypoint.sh"]
|
||||||
PHP_OPCACHE_VALIDATE_TIMESTAMPS=0
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
gosu \
|
|
||||||
libfreetype6-dev \
|
|
||||||
libicu-dev \
|
|
||||||
libjpeg62-turbo-dev \
|
|
||||||
libonig-dev \
|
|
||||||
libpng-dev \
|
|
||||||
libxml2-dev \
|
|
||||||
libzip-dev \
|
|
||||||
unzip \
|
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
|
||||||
&& docker-php-ext-install -j"$(nproc)" \
|
|
||||||
bcmath \
|
|
||||||
exif \
|
|
||||||
gd \
|
|
||||||
intl \
|
|
||||||
mbstring \
|
|
||||||
opcache \
|
|
||||||
pcntl \
|
|
||||||
pdo_mysql \
|
|
||||||
xml \
|
|
||||||
zip \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=composer /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
COPY --from=frontend /var/www/html/public/build ./public/build
|
|
||||||
|
|
||||||
RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist \
|
|
||||||
&& mkdir -p \
|
|
||||||
storage/framework/cache/data \
|
|
||||||
storage/framework/sessions \
|
|
||||||
storage/framework/views \
|
|
||||||
storage/logs \
|
|
||||||
bootstrap/cache \
|
|
||||||
&& chown -R www-data:www-data storage bootstrap/cache \
|
|
||||||
&& chmod -R ug+rwX storage bootstrap/cache
|
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/nextchair-entrypoint
|
|
||||||
RUN chmod +x /usr/local/bin/nextchair-entrypoint
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/nextchair-entrypoint"]
|
|
||||||
CMD ["php-fpm", "-F"]
|
|
||||||
|
|
||||||
|
|
||||||
FROM nginx:1.27-alpine AS nginx
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
COPY public ./public
|
|
||||||
COPY --from=frontend /var/www/html/public/build ./public/build
|
|
||||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
RUN mkdir -p /var/www/html/storage/app/public \
|
|
||||||
&& ln -s /var/www/html/storage/app/public /var/www/html/public/storage
|
|
||||||
@@ -1,109 +1,3 @@
|
|||||||
# NextChair
|
# NextChair
|
||||||
|
|
||||||
NextChair is a multi-tenant, white-label booking platform for barbershops.
|
Booking app as an alternative to Booksy
|
||||||
The application is being rebuilt as a conventional Laravel web application
|
|
||||||
using PHP, MySQL, Blade, Tailwind CSS, Alpine.js, and Vite.
|
|
||||||
|
|
||||||
The audited Next.js implementation is preserved in [`legacy-nextjs`](legacy-nextjs)
|
|
||||||
as the functional and visual migration reference. The Laravel application has
|
|
||||||
its own deployment-neutral Docker option at the repository root; files inside
|
|
||||||
`legacy-nextjs` are not used by it.
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
composer install
|
|
||||||
cp .env.example .env
|
|
||||||
php artisan key:generate
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
php artisan serve
|
|
||||||
```
|
|
||||||
|
|
||||||
Configure the MySQL connection in `.env` before running migrations. The
|
|
||||||
application resolves a business only from the request hostname through the
|
|
||||||
`business_domains` table; it never guesses a tenant from the first business
|
|
||||||
in the database. For local multi-tenant work, map each configured hostname to
|
|
||||||
127.0.0.1 in `/etc/hosts`.
|
|
||||||
|
|
||||||
## Platform and tenant setup
|
|
||||||
|
|
||||||
Migrations create schema only. They do not create a demo business or a
|
|
||||||
business owner. After migrating, open `/install` once and create the platform
|
|
||||||
administrator. That account can sign in at `/manager/sign-in`, create
|
|
||||||
businesses, edit their primary domains, and share the one-time owner setup
|
|
||||||
link generated during provisioning.
|
|
||||||
|
|
||||||
For an existing installation created before this platform/tenant split, the
|
|
||||||
recorded `initialAdminUserId` account is allowed to sign in once and is
|
|
||||||
promoted to the platform administrator. No other business owner can use that
|
|
||||||
upgrade path.
|
|
||||||
|
|
||||||
Business provisioning creates the tenant domain, settings, booking rules,
|
|
||||||
opening hours, system roles, owner staff profile, and seven-day owner
|
|
||||||
invitation in one transaction. The owner chooses a password through that
|
|
||||||
invitation; platform administrators and business owners are separate account
|
|
||||||
types.
|
|
||||||
|
|
||||||
If demo data is useful for local development, opt into the existing seeder
|
|
||||||
explicitly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NEXTCHAIR_SEED_DEMO=true php artisan db:seed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker deployment
|
|
||||||
|
|
||||||
The root `compose.yaml` runs Laravel with PHP 8.4 FPM, Nginx, MySQL 8,
|
|
||||||
the database-backed queue worker, and the scheduler. It does not include or
|
|
||||||
configure Caddy; an existing reverse proxy can forward traffic to Nginx on
|
|
||||||
`127.0.0.1:8085`.
|
|
||||||
|
|
||||||
First run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Set private values in `.env`, including:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
APP_URL=http://localhost:8085
|
|
||||||
APP_KEY=
|
|
||||||
DB_HOST=db
|
|
||||||
DB_DATABASE=nextchair
|
|
||||||
DB_USERNAME=nextchair
|
|
||||||
DB_PASSWORD=replace-with-a-private-password
|
|
||||||
DB_ROOT_PASSWORD=replace-with-a-private-root-password
|
|
||||||
```
|
|
||||||
|
|
||||||
Then build and start the stack:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose build
|
|
||||||
docker compose up -d
|
|
||||||
docker compose exec app php artisan key:generate
|
|
||||||
docker compose exec app php artisan migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
Then visit `http://localhost:8085/install` to create the first platform
|
|
||||||
administrator. Do not run migrations or create tenants from container startup;
|
|
||||||
the tenant owner is provisioned manually from Manager.
|
|
||||||
|
|
||||||
Migrations are intentionally not run by container startup. To inspect the
|
|
||||||
application logs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs -f app nginx queue scheduler db
|
|
||||||
```
|
|
||||||
|
|
||||||
The default host binding is `127.0.0.1:8085:80`. Override it with
|
|
||||||
`NEXTCHAIR_HTTP_BIND` and `NEXTCHAIR_HTTP_PORT` in `.env` when needed. MySQL
|
|
||||||
has no published host port and is reachable only as `db:3306` inside Compose.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan test
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
@@ -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,114 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\CustomerProfile;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class AuthController extends Controller
|
|
||||||
{
|
|
||||||
public function showLogin(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
return view('auth.sign-in', ['business' => $tenant->require()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function showWelcome(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
return view('auth.welcome', ['business' => $tenant->require()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function showVerify(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
return view('auth.verify', ['business' => $tenant->require()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function login(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$credentials = $request->validate([
|
|
||||||
'email' => ['required', 'email', 'max:190'],
|
|
||||||
'password' => ['required', 'string'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
|
|
||||||
return back()->withErrors(['email' => 'Those details do not match our records.'])->onlyInput('email');
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->session()->regenerate();
|
|
||||||
|
|
||||||
if ($request->user()->isSuperAdmin) {
|
|
||||||
return redirect()->intended(route('manager.dashboard'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->intended(route('customer.dashboard'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function showRegister(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
return view('auth.register', ['business' => $tenant->require()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function showForgot(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
return view('auth.forgot-password', ['business' => $tenant->require()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forgot(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$request->validate(['email' => ['required', 'email']]);
|
|
||||||
return back()->with('status', 'If an account exists for that email, reset instructions will be sent shortly.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function register(Request $request, TenantContext $tenant): RedirectResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'firstName' => ['required', 'string', 'max:80'],
|
|
||||||
'lastName' => ['required', 'string', 'max:80'],
|
|
||||||
'email' => ['required', 'email', 'max:190', 'unique:User,email'],
|
|
||||||
'phone' => ['nullable', 'string', 'max:40'],
|
|
||||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = DB::transaction(function () use ($validated, $tenant): User {
|
|
||||||
$user = User::create([
|
|
||||||
'name' => trim($validated['firstName'].' '.$validated['lastName']),
|
|
||||||
'email' => strtolower(trim($validated['email'])),
|
|
||||||
'phone' => $validated['phone'] ?? null,
|
|
||||||
'passwordHash' => Hash::make($validated['password']),
|
|
||||||
]);
|
|
||||||
|
|
||||||
CustomerProfile::create([
|
|
||||||
'businessId' => $tenant->require()->id,
|
|
||||||
'userId' => $user->id,
|
|
||||||
'firstName' => trim($validated['firstName']),
|
|
||||||
'lastName' => trim($validated['lastName']),
|
|
||||||
'email' => strtolower(trim($validated['email'])),
|
|
||||||
'phone' => $validated['phone'] ?? null,
|
|
||||||
'isGuest' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
});
|
|
||||||
|
|
||||||
Auth::login($user);
|
|
||||||
$request->session()->regenerate();
|
|
||||||
|
|
||||||
return redirect()->route('customer.dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function logout(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$wasSuperAdmin = $request->user()?->isSuperAdmin === true;
|
|
||||||
Auth::logout();
|
|
||||||
$request->session()->invalidate();
|
|
||||||
$request->session()->regenerateToken();
|
|
||||||
|
|
||||||
return redirect()->route($wasSuperAdmin ? 'manager.login' : 'public.home');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,76 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Enums\InstallationStatus;
|
|
||||||
use App\Models\ApplicationInstallation;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class InstallController extends Controller
|
|
||||||
{
|
|
||||||
public function create(Request $request): View|RedirectResponse
|
|
||||||
{
|
|
||||||
if ($this->isInstalled()) {
|
|
||||||
return $request->user()?->isSuperAdmin
|
|
||||||
? redirect()->route('manager.dashboard')
|
|
||||||
: redirect()->route('manager.login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('install');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
if ($this->isInstalled()) {
|
|
||||||
return redirect()->route('manager.login');
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $request->validate([
|
|
||||||
'adminName' => ['required', 'string', 'max:160'],
|
|
||||||
'adminEmail' => ['required', 'email', 'max:190', 'unique:User,email'],
|
|
||||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$admin = DB::transaction(function () use ($data): User {
|
|
||||||
$admin = User::create([
|
|
||||||
'name' => trim($data['adminName']),
|
|
||||||
'email' => strtolower(trim($data['adminEmail'])),
|
|
||||||
'passwordHash' => Hash::make($data['password']),
|
|
||||||
'isSuperAdmin' => true,
|
|
||||||
'emailVerified' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ApplicationInstallation::create([
|
|
||||||
'id' => 'singleton',
|
|
||||||
'status' => InstallationStatus::Installed,
|
|
||||||
'installedAt' => now(),
|
|
||||||
'installedVersion' => 'laravel-1',
|
|
||||||
'initialAdminUserId' => $admin->id,
|
|
||||||
'primaryBusinessId' => null,
|
|
||||||
'metadata' => ['installer' => 'platform-admin-only'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $admin;
|
|
||||||
});
|
|
||||||
|
|
||||||
Auth::login($admin);
|
|
||||||
$request->session()->regenerate();
|
|
||||||
|
|
||||||
return redirect()->route('manager.dashboard')->with('status', 'Platform setup complete. Create your first business from Manager.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isInstalled(): bool
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Enums\{LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode, RewardType};
|
|
||||||
use App\Models\LoyaltyProgramme;
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class LoyaltySettingsController extends Controller
|
|
||||||
{
|
|
||||||
public function edit(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->require();
|
|
||||||
$programme = $business->loyaltyProgramme ?: new LoyaltyProgramme(['stampsRequired' => 10, 'rewardType' => RewardType::Percentage, 'rewardPosition' => RewardPositionMode::AfterAllStamps, 'cardFont' => LoyaltyCardFont::Sans, 'cardTitleTextSize' => LoyaltyCardTextSize::Md]);
|
|
||||||
$services = $business->services()->where('isActive', true)->orderBy('name')->get();
|
|
||||||
return view('owner.loyalty-settings', compact('business', 'programme', 'services'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request, TenantContext $tenant): RedirectResponse
|
|
||||||
{
|
|
||||||
$business = $tenant->require();
|
|
||||||
$data = $request->validate([
|
|
||||||
'isEnabled' => ['nullable', 'boolean'], 'stampsRequired' => ['required', 'integer', 'min:1', 'max:10'],
|
|
||||||
'rewardPosition' => ['required', 'in:AFTER_ALL_STAMPS,FINAL_SLOT_IS_REWARD'], 'rewardType' => ['required', 'in:PERCENTAGE,FIXED_AMOUNT,FREE_SERVICE'],
|
|
||||||
'rewardValue' => ['nullable', 'numeric', 'min:0'], 'rewardLabel' => ['nullable', 'string', 'max:100'], 'freeServiceId' => ['nullable', 'string'],
|
|
||||||
'autoApplyReward' => ['nullable', 'boolean'], 'requiresStaffApproval' => ['nullable', 'boolean'], 'rewardExpiresAfterDays' => ['nullable', 'integer', 'min:1'],
|
|
||||||
'cardTitleText' => ['nullable', 'string', 'max:80'], 'cardFont' => ['required', 'in:SANS,SERIF,MONO'], 'cardTitleTextSize' => ['required', 'in:SM,MD,LG'],
|
|
||||||
'termsLine' => ['nullable', 'string', 'max:255'],
|
|
||||||
]);
|
|
||||||
if (($data['rewardType'] ?? null) === 'FREE_SERVICE') {
|
|
||||||
abort_unless($data['freeServiceId'] && $business->services()->whereKey($data['freeServiceId'])->where('isActive', true)->exists(), 422);
|
|
||||||
}
|
|
||||||
$data['businessId'] = $business->id;
|
|
||||||
$data['isEnabled'] = $request->boolean('isEnabled');
|
|
||||||
$data['autoApplyReward'] = $request->boolean('autoApplyReward');
|
|
||||||
$data['requiresStaffApproval'] = $request->boolean('requiresStaffApproval');
|
|
||||||
LoyaltyProgramme::updateOrCreate(['businessId' => $business->id], $data);
|
|
||||||
return back()->with('status', 'Loyalty settings saved.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Business;
|
|
||||||
use App\Models\BusinessDomain;
|
|
||||||
use App\Services\Businesses\BusinessProvisioningService;
|
|
||||||
use App\Services\Tenant\DomainNormalizer;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
class ManagerBusinessController extends Controller
|
|
||||||
{
|
|
||||||
public function create(): View
|
|
||||||
{
|
|
||||||
return view('manager.businesses.create');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request, BusinessProvisioningService $provisioner, DomainNormalizer $domains): RedirectResponse
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'name' => ['required', 'string', 'max:160'],
|
|
||||||
'shortName' => ['nullable', 'string', 'max:80'],
|
|
||||||
'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug'],
|
|
||||||
'domain' => ['required', 'string', 'max:253'],
|
|
||||||
'email' => ['nullable', 'email', 'max:190'],
|
|
||||||
'phone' => ['nullable', 'string', 'max:40'],
|
|
||||||
'timezone' => ['required', 'timezone'],
|
|
||||||
'currency' => ['required', 'string', 'size:3'],
|
|
||||||
'ownerName' => ['required', 'string', 'max:160'],
|
|
||||||
'ownerEmail' => ['required', 'email', 'max:190', 'unique:User,email'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$data['domain'] = $domains->normalize($data['domain']);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
return back()->withErrors(['domain' => $exception->getMessage()])->withInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (BusinessDomain::query()->where('hostname', $data['domain'])->exists()) {
|
|
||||||
return back()->withErrors(['domain' => 'That hostname is already assigned to another business.'])->withInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$result = $provisioner->provision($data, [
|
|
||||||
'name' => $data['ownerName'],
|
|
||||||
'email' => $data['ownerEmail'],
|
|
||||||
], $request->user());
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
return back()->withErrors(['domain' => $exception->getMessage()])->withInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
$hostname = $result->business->primaryDomain()->value('hostname');
|
|
||||||
$invitationUrl = request()->getScheme().'://'.$hostname.'/invite/'.$result->ownerInvitation->token;
|
|
||||||
|
|
||||||
return redirect()->route('manager.businesses.show', $result->business)
|
|
||||||
->with('status', 'Business created. The owner can finish setup using the invitation link below.')
|
|
||||||
->with('owner_invitation_url', $invitationUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Business $business): View
|
|
||||||
{
|
|
||||||
$business->load(['domains', 'settings', 'bookingRule', 'hours', 'staff.user', 'staff.role']);
|
|
||||||
|
|
||||||
return view('manager.businesses.show', compact('business'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function edit(Business $business): View
|
|
||||||
{
|
|
||||||
$business->load('primaryDomain');
|
|
||||||
|
|
||||||
return view('manager.businesses.edit', compact('business'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request, Business $business, DomainNormalizer $domains): RedirectResponse
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'name' => ['required', 'string', 'max:160'],
|
|
||||||
'shortName' => ['nullable', 'string', 'max:80'],
|
|
||||||
'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug,'.$business->id.',id'],
|
|
||||||
'domain' => ['required', 'string', 'max:253'],
|
|
||||||
'email' => ['nullable', 'email', 'max:190'],
|
|
||||||
'phone' => ['nullable', 'string', 'max:40'],
|
|
||||||
'timezone' => ['required', 'timezone'],
|
|
||||||
'currency' => ['required', 'string', 'size:3'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$hostname = $domains->normalize($data['domain']);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
return back()->withErrors(['domain' => $exception->getMessage()])->withInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
$domainTaken = BusinessDomain::query()
|
|
||||||
->where('hostname', $hostname)
|
|
||||||
->where('businessId', '!=', $business->id)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($domainTaken) {
|
|
||||||
return back()->withErrors(['domain' => 'That hostname is already assigned to another business.'])->withInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::transaction(function () use ($business, $data, $hostname): void {
|
|
||||||
$business->update([
|
|
||||||
'name' => trim($data['name']),
|
|
||||||
'shortName' => filled($data['shortName'] ?? null) ? trim($data['shortName']) : null,
|
|
||||||
'slug' => strtolower(trim($data['slug'])),
|
|
||||||
'email' => filled($data['email'] ?? null) ? strtolower(trim($data['email'])) : null,
|
|
||||||
'phone' => filled($data['phone'] ?? null) ? trim($data['phone']) : null,
|
|
||||||
'timezone' => $data['timezone'],
|
|
||||||
'currency' => strtoupper($data['currency']),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$primary = $business->domains()->where('isPrimary', true)->first();
|
|
||||||
if ($primary) {
|
|
||||||
$primary->update(['hostname' => $hostname]);
|
|
||||||
} else {
|
|
||||||
BusinessDomain::create(['businessId' => $business->id, 'hostname' => $hostname, 'isPrimary' => true, 'isActive' => true]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return redirect()->route('manager.businesses.show', $business)->with('status', 'Business details updated.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Business;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class ManagerDashboardController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(): View
|
|
||||||
{
|
|
||||||
$businesses = Business::query()
|
|
||||||
->with(['primaryDomain', 'staff.user', 'staff.role'])
|
|
||||||
->withCount(['staff', 'domains'])
|
|
||||||
->latest('createdAt')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return view('manager.dashboard', [
|
|
||||||
'businesses' => $businesses,
|
|
||||||
'businessCount' => $businesses->count(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class NotificationController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Request $request, TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->require();
|
|
||||||
$notifications = $request->user()->receivedNotifications()->where('businessId', $business->id)->latest('createdAt')->with('appointment')->paginate(20);
|
|
||||||
return view('customer.notifications', compact('business', 'notifications'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function read(Request $request, TenantContext $tenant, string $notification): RedirectResponse
|
|
||||||
{
|
|
||||||
$tenant->require();
|
|
||||||
$item = $request->user()->receivedNotifications()->where('businessId', $tenant->id())->findOrFail($notification);
|
|
||||||
$item->readAt = now();
|
|
||||||
$item->save();
|
|
||||||
return back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\{BookingRule, BusinessSettings};
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class OwnerBusinessSettingsController extends Controller
|
|
||||||
{
|
|
||||||
public function business(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); return view('owner.business-settings', compact('business'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateBusiness(Request $request, TenantContext $tenant): RedirectResponse
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); $data = $request->validate(['name' => ['required', 'string', 'max:190'], 'shortName' => ['nullable', 'string', 'max:80'], 'description' => ['nullable', 'string', 'max:2000'], 'phone' => ['nullable', 'string', 'max:40'], 'email' => ['nullable', 'email', 'max:190'], 'website' => ['nullable', 'url', 'max:190'], 'timezone' => ['required', 'timezone'], 'currency' => ['required', 'string', 'size:3']]); $business->fill($data); $business->save(); return back()->with('status', 'Business details saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function bookingRules(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); $rules = $business->bookingRule ?: new BookingRule(['minNoticeMinutes' => 120, 'maxAdvanceDays' => 60, 'slotIntervalMinutes' => 30]); return view('owner.booking-rules', compact('business', 'rules'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateBookingRules(Request $request, TenantContext $tenant): RedirectResponse
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); $data = $request->validate(['minNoticeMinutes' => ['required', 'integer', 'min:0', 'max:10080'], 'maxAdvanceDays' => ['required', 'integer', 'min:1', 'max:365'], 'slotIntervalMinutes' => ['required', 'integer', 'in:5,10,15,20,30,60'], 'cancellationWindowHours' => ['required', 'integer', 'min:0'], 'rescheduleWindowHours' => ['required', 'integer', 'min:0'], 'bufferBeforeMinutes' => ['required', 'integer', 'min:0'], 'bufferAfterMinutes' => ['required', 'integer', 'min:0'], 'allowGuestBooking' => ['nullable', 'boolean'], 'requireApproval' => ['nullable', 'boolean'], 'customerCanChooseStaff' => ['nullable', 'boolean']]); $data['businessId'] = $business->id; foreach (['allowGuestBooking', 'requireApproval', 'customerCanChooseStaff'] as $flag) $data[$flag] = $request->boolean($flag); BookingRule::updateOrCreate(['businessId' => $business->id], $data); return back()->with('status', 'Booking rules saved.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\BusinessHours;
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class OwnerHoursController extends Controller
|
|
||||||
{
|
|
||||||
private const DAYS = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'];
|
|
||||||
|
|
||||||
public function edit(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); $hours = $business->hours()->get()->keyBy('dayOfWeek'); return view('owner.hours', ['business' => $business, 'hours' => $hours, 'days' => self::DAYS]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request, TenantContext $tenant): RedirectResponse
|
|
||||||
{
|
|
||||||
$business = $tenant->require();
|
|
||||||
foreach (self::DAYS as $day) {
|
|
||||||
$prefix = strtolower($day); $closed = $request->boolean("{$prefix}_closed");
|
|
||||||
$open = $closed ? null : $this->minutes($request->input("{$prefix}_open")); $close = $closed ? null : $this->minutes($request->input("{$prefix}_close"));
|
|
||||||
if (! $closed && ($open === null || $close === null || $close <= $open)) return back()->withErrors(['hours' => "Enter valid opening hours for {$day}."])->withInput();
|
|
||||||
BusinessHours::updateOrCreate(['businessId' => $business->id, 'dayOfWeek' => $day], ['isClosed' => $closed, 'opensAtMinutes' => $open, 'closesAtMinutes' => $close]);
|
|
||||||
}
|
|
||||||
return back()->with('status', 'Opening hours saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function minutes(?string $value): ?int
|
|
||||||
{
|
|
||||||
if (! $value || ! preg_match('/^(\d{2}):(\d{2})$/', $value, $matches)) return null;
|
|
||||||
$minutes = ((int) $matches[1] * 60) + (int) $matches[2]; return $minutes >= 0 && $minutes <= 1439 ? $minutes : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\StaffInvitation;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class OwnerInvitationController extends Controller
|
|
||||||
{
|
|
||||||
public function show(string $token): View|RedirectResponse
|
|
||||||
{
|
|
||||||
$invitation = $this->findValidInvitation($token);
|
|
||||||
|
|
||||||
return view('auth.owner-invite', [
|
|
||||||
'invitation' => $invitation,
|
|
||||||
'business' => $invitation->business,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function accept(Request $request, string $token): RedirectResponse
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$invitation = $this->findValidInvitation($token);
|
|
||||||
|
|
||||||
DB::transaction(function () use ($invitation, $data): void {
|
|
||||||
$invitation->staff->user->update([
|
|
||||||
'passwordHash' => Hash::make($data['password']),
|
|
||||||
'emailVerified' => now(),
|
|
||||||
]);
|
|
||||||
$invitation->update(['acceptedAt' => now()]);
|
|
||||||
});
|
|
||||||
|
|
||||||
Auth::login($invitation->staff->user->fresh());
|
|
||||||
$request->session()->regenerate();
|
|
||||||
|
|
||||||
$hostname = $invitation->business->primaryDomain?->hostname;
|
|
||||||
$destination = $hostname
|
|
||||||
? request()->getScheme().'://'.$hostname.'/owner'
|
|
||||||
: route('owner.dashboard');
|
|
||||||
|
|
||||||
return redirect()->to($destination)->with('status', 'Your owner workspace is ready.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findValidInvitation(string $token): StaffInvitation
|
|
||||||
{
|
|
||||||
$invitation = StaffInvitation::query()->with(['business.primaryDomain', 'staff.user'])->where('token', $token)->firstOrFail();
|
|
||||||
|
|
||||||
abort_if($invitation->acceptedAt || $invitation->expiresAt->isPast(), 410, 'This invitation has expired or has already been used.');
|
|
||||||
|
|
||||||
return $invitation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Enums\AppointmentStatus;
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class OwnerReportsController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); $month = Carbon::now($business->timezone)->startOfMonth(); $appointments = $business->appointments()->with('services')->where('startAt', '>=', $month->utc())->get(); $completed = $appointments->where('status', AppointmentStatus::Completed); $revenue = $completed->sum(fn ($appointment) => $appointment->services->sum('priceAtBooking') - (float) ($appointment->discountAmount ?? 0)); $staffCount = $business->staff()->where('status', 'ACTIVE')->count(); return view('owner.reports', compact('business', 'appointments', 'completed', 'revenue', 'staffCount'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class OwnerReviewsController extends Controller
|
|
||||||
{
|
|
||||||
public function index(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); $reviews = $business->reviews()->latest('publishedAt')->get(); return view('owner.reviews', compact('business', 'reviews'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggle(Request $request, TenantContext $tenant, string $review): RedirectResponse
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); $item = $business->reviews()->findOrFail($review); $item->isHidden = ! $item->isHidden; $item->save(); return back()->with('status', 'Review visibility updated.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\{Service, ServiceCategory};
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class OwnerServicesController extends Controller
|
|
||||||
{
|
|
||||||
public function index(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->require();
|
|
||||||
$categories = $business->serviceCategories()->with('services')->orderBy('displayOrder')->get();
|
|
||||||
return view('owner.services', compact('business', 'categories'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request, TenantContext $tenant): RedirectResponse
|
|
||||||
{
|
|
||||||
$business = $tenant->require();
|
|
||||||
$data = $request->validate(['categoryId' => ['nullable', 'string'], 'categoryName' => ['nullable', 'string', 'max:80'], 'name' => ['required', 'string', 'max:120'], 'description' => ['nullable', 'string', 'max:1000'], 'durationMinutes' => ['required', 'integer', 'min:5', 'max:480'], 'price' => ['required', 'numeric', 'min:0'], 'isActive' => ['nullable', 'boolean'], 'allowOnlineBooking' => ['nullable', 'boolean']]);
|
|
||||||
$category = $data['categoryId'] ? $business->serviceCategories()->findOrFail($data['categoryId']) : ServiceCategory::create(['businessId' => $business->id, 'name' => $data['categoryName'] ?: 'General', 'displayOrder' => $business->serviceCategories()->count()]);
|
|
||||||
Service::create(['businessId' => $business->id, 'categoryId' => $category->id, 'name' => $data['name'], 'description' => $data['description'] ?? null, 'durationMinutes' => $data['durationMinutes'], 'price' => $data['price'], 'isActive' => $request->boolean('isActive', true), 'allowOnlineBooking' => $request->boolean('allowOnlineBooking', true)]);
|
|
||||||
return back()->with('status', 'Service added.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request, TenantContext $tenant, Service $service): RedirectResponse
|
|
||||||
{
|
|
||||||
$business = $tenant->require();
|
|
||||||
abort_unless($service->businessId === $business->id, 404);
|
|
||||||
$data = $request->validate(['name' => ['required', 'string', 'max:120'], 'description' => ['nullable', 'string', 'max:1000'], 'durationMinutes' => ['required', 'integer', 'min:5', 'max:480'], 'price' => ['required', 'numeric', 'min:0'], 'isActive' => ['nullable', 'boolean'], 'allowOnlineBooking' => ['nullable', 'boolean']]);
|
|
||||||
$service->fill($data); $service->isActive = $request->boolean('isActive'); $service->allowOnlineBooking = $request->boolean('allowOnlineBooking'); $service->save();
|
|
||||||
return back()->with('status', 'Service updated.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(TenantContext $tenant, Service $service): RedirectResponse
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); abort_unless($service->businessId === $business->id, 404); $service->isActive = false; $service->allowOnlineBooking = false; $service->save();
|
|
||||||
return back()->with('status', 'Service archived.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Enums\Permission;
|
|
||||||
use App\Models\{Role, RolePermission, StaffInvitation, StaffProfile, User};
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class OwnerTeamController extends Controller
|
|
||||||
{
|
|
||||||
public function index(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); $staff = $business->staff()->with(['user', 'role', 'invitation'])->orderBy('createdAt')->get(); return view('owner.team', compact('business', 'staff'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request, TenantContext $tenant): RedirectResponse
|
|
||||||
{
|
|
||||||
$business = $tenant->require(); $data = $request->validate(['firstName' => ['required', 'string', 'max:80'], 'lastName' => ['required', 'string', 'max:80'], 'email' => ['required', 'email', 'max:190', 'unique:User,email'], 'bio' => ['nullable', 'string', 'max:1000']]);
|
|
||||||
$barberRole = Role::firstOrCreate(['businessId' => $business->id, 'name' => 'Barber'], ['isSystem' => true]);
|
|
||||||
foreach ([Permission::ManageOwnAppointments, Permission::CheckInCustomers, Permission::ManageOwnAvailability, Permission::BookOnBehalfOfCustomer, Permission::ManageLoyaltyStamps] as $permission) RolePermission::firstOrCreate(['roleId' => $barberRole->id, 'permission' => $permission]);
|
|
||||||
$user = User::create(['name' => trim($data['firstName'].' '.$data['lastName']), 'email' => strtolower($data['email']), 'passwordHash' => Hash::make(Str::random(32))]);
|
|
||||||
$profile = StaffProfile::create(['userId' => $user->id, 'businessId' => $business->id, 'roleId' => $barberRole->id, 'bio' => $data['bio'] ?? null, 'status' => 'ACTIVE', 'isBookableOnline' => true]);
|
|
||||||
StaffInvitation::create(['businessId' => $business->id, 'staffId' => $profile->id, 'email' => $user->email, 'token' => Str::random(48), 'invitedByUserId' => $request->user()->id, 'expiresAt' => now()->addDays(7)]);
|
|
||||||
return back()->with('status', 'Team member added. Share their invitation link when ready.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Enums\InstallationStatus;
|
|
||||||
use App\Models\ApplicationInstallation;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class PlatformAuthController extends Controller
|
|
||||||
{
|
|
||||||
public function showLogin(Request $request): View|RedirectResponse
|
|
||||||
{
|
|
||||||
if ($request->user()?->isSuperAdmin) {
|
|
||||||
return redirect()->route('manager.dashboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) {
|
|
||||||
return redirect()->route('install');
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('auth.platform-sign-in');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function login(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$credentials = $request->validate([
|
|
||||||
'email' => ['required', 'email', 'max:190'],
|
|
||||||
'password' => ['required', 'string'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
|
|
||||||
Auth::logout();
|
|
||||||
|
|
||||||
return back()->withErrors(['email' => 'Those platform administrator details do not match our records.'])->onlyInput('email');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $request->user();
|
|
||||||
if (! $user?->isSuperAdmin) {
|
|
||||||
$installation = ApplicationInstallation::query()
|
|
||||||
->where('status', InstallationStatus::Installed)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$canUpgradeLegacyAdmin = ! User::query()->where('isSuperAdmin', true)->exists()
|
|
||||||
&& $installation?->initialAdminUserId === $user?->id;
|
|
||||||
|
|
||||||
if (! $canUpgradeLegacyAdmin) {
|
|
||||||
Auth::logout();
|
|
||||||
|
|
||||||
return back()->withErrors(['email' => 'Those platform administrator details do not match our records.'])->onlyInput('email');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Existing installations created before platform administration
|
|
||||||
// was separated may have only an initial business owner. Promote
|
|
||||||
// that exact recorded account once so the migration is usable.
|
|
||||||
$user->forceFill(['isSuperAdmin' => true])->save();
|
|
||||||
Auth::setUser($user->fresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->session()->regenerate();
|
|
||||||
|
|
||||||
return redirect()->intended(route('manager.dashboard'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class PublicBusinessController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->business();
|
|
||||||
$business?->load(['locations', 'galleryImages' => fn ($query) => $query->where('isActive', true)->orderBy('displayOrder'), 'reviews' => fn ($query) => $query->where('isHidden', false)->latest('publishedAt')->limit(6), 'staff.user', 'hours']);
|
|
||||||
$categories = $business?->serviceCategories()
|
|
||||||
->where('isActive', true)
|
|
||||||
->with(['services' => fn ($query) => $query->where('isActive', true)->where('allowOnlineBooking', true)->orderBy('displayOrder')])
|
|
||||||
->orderBy('displayOrder')
|
|
||||||
->get() ?? collect();
|
|
||||||
|
|
||||||
return view('public.business', compact('business', 'categories'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Enums\{AppointmentStatus, NotificationType, Permission};
|
|
||||||
use App\Models\Appointment;
|
|
||||||
use App\Services\Loyalty\LoyaltyService;
|
|
||||||
use App\Services\Notifications\NotificationService;
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class StaffAppointmentController extends Controller
|
|
||||||
{
|
|
||||||
public function status(Request $request, TenantContext $tenant, Appointment $appointment, LoyaltyService $loyalty, NotificationService $notifications): RedirectResponse
|
|
||||||
{
|
|
||||||
$business = $tenant->require();
|
|
||||||
abort_unless($appointment->businessId === $business->id, 404);
|
|
||||||
$staff = $request->user()->staffProfiles()->where('businessId', $business->id)->with('role.permissions')->firstOrFail();
|
|
||||||
abort_unless($appointment->staffId === $staff->id || $staff->hasPermission(Permission::ManageAllAppointments), 403);
|
|
||||||
|
|
||||||
$validated = $request->validate(['status' => ['required', 'in:PENDING,CONFIRMED,CHECKED_IN,IN_PROGRESS,COMPLETED,CANCELLED,NO_SHOW'], 'reason' => ['nullable', 'string', 'max:255']]);
|
|
||||||
$next = AppointmentStatus::from($validated['status']);
|
|
||||||
$this->assertTransition($appointment->status, $next);
|
|
||||||
$appointment->loadMissing(['customer', 'services', 'business']);
|
|
||||||
|
|
||||||
$appointment->status = $next;
|
|
||||||
if ($next === AppointmentStatus::Cancelled) {
|
|
||||||
$appointment->cancellationReason = $validated['reason'] ?? null;
|
|
||||||
$appointment->cancelledAt = now();
|
|
||||||
$appointment->cancelledByUserId = $request->user()->id;
|
|
||||||
}
|
|
||||||
$appointment->save();
|
|
||||||
|
|
||||||
if ($next === AppointmentStatus::Completed) {
|
|
||||||
try { $loyalty->awardForCompletedAppointment($appointment, $staff); } catch (RuntimeException) { /* loyalty is optional and must not block completion */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
$type = match ($next) {
|
|
||||||
AppointmentStatus::Cancelled => NotificationType::BookingCancelled,
|
|
||||||
AppointmentStatus::Completed => NotificationType::BookingCompleted,
|
|
||||||
AppointmentStatus::Confirmed => NotificationType::BookingApproved,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
if ($type) $notifications->create($business, $type, "Your appointment status is now ".strtolower(str_replace('_', ' ', $next->value)).'.', "appointment-status:{$appointment->id}:{$next->value}", $appointment->customer->userId, $appointment->customer->id, $appointment->id, '/account', $business->name);
|
|
||||||
|
|
||||||
return back()->with('status', 'Appointment updated.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assertTransition(AppointmentStatus $current, AppointmentStatus $next): void
|
|
||||||
{
|
|
||||||
$allowed = [
|
|
||||||
AppointmentStatus::Pending->value => [AppointmentStatus::Confirmed, AppointmentStatus::Cancelled],
|
|
||||||
AppointmentStatus::Confirmed->value => [AppointmentStatus::CheckedIn, AppointmentStatus::Cancelled, AppointmentStatus::NoShow],
|
|
||||||
AppointmentStatus::CheckedIn->value => [AppointmentStatus::InProgress, AppointmentStatus::Cancelled],
|
|
||||||
AppointmentStatus::InProgress->value => [AppointmentStatus::Completed],
|
|
||||||
];
|
|
||||||
if (! in_array($next, $allowed[$current->value] ?? [], true)) throw new RuntimeException('That appointment status transition is not allowed.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Enums\Permission;
|
|
||||||
use App\Models\Appointment;
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class StaffDashboardController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request, TenantContext $tenant): View
|
|
||||||
{
|
|
||||||
$business = $tenant->require();
|
|
||||||
$staff = $request->user()->staffProfiles()
|
|
||||||
->where('businessId', $business->id)
|
|
||||||
->with('role.permissions')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
abort_unless($staff, 403);
|
|
||||||
|
|
||||||
$isOwner = $staff->hasPermission(Permission::ViewAllStaffAppointments);
|
|
||||||
$today = Carbon::now($business->timezone);
|
|
||||||
$appointments = Appointment::query()
|
|
||||||
->where('businessId', $business->id)
|
|
||||||
->when(! $isOwner, fn ($query) => $query->where('staffId', $staff->id))
|
|
||||||
->whereBetween('startAt', [$today->copy()->startOfDay()->utc(), $today->copy()->endOfDay()->utc()])
|
|
||||||
->with(['customer', 'staff.user', 'services.service'])
|
|
||||||
->orderBy('startAt')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return view('staff.dashboard', compact('business', 'staff', 'appointments', 'isOwner'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Enums\Permission;
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class EnsureStaffPermission
|
|
||||||
{
|
|
||||||
public function handle(Request $request, Closure $next, string $permission): Response
|
|
||||||
{
|
|
||||||
$permissionCase = Permission::tryFrom($permission);
|
|
||||||
$business = app(TenantContext::class)->require();
|
|
||||||
$staff = $request->user()?->staffProfiles()
|
|
||||||
->where('businessId', $business->id)
|
|
||||||
->with('role.permissions')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
abort_unless($permissionCase && $staff?->hasPermission($permissionCase), 403);
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class EnsureSuperAdmin
|
|
||||||
{
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
if (! $request->user()) {
|
|
||||||
return redirect()->route('manager.login');
|
|
||||||
}
|
|
||||||
|
|
||||||
abort_unless($request->user()->isSuperAdmin === true, 403);
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Models\BusinessDomain;
|
|
||||||
use App\Services\Tenant\TenantContext;
|
|
||||||
use App\Services\Tenant\DomainNormalizer;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class ResolveTenant
|
|
||||||
{
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
$hostname = null;
|
|
||||||
try {
|
|
||||||
$hostname = app(DomainNormalizer::class)->normalize($request->getHost());
|
|
||||||
$business = BusinessDomain::query()
|
|
||||||
->where('hostname', $hostname)
|
|
||||||
->where('isActive', true)
|
|
||||||
->with('business')
|
|
||||||
->first()?->business;
|
|
||||||
|
|
||||||
} catch (Throwable) {
|
|
||||||
// The install page and framework smoke tests must remain
|
|
||||||
// reachable before the tenant schema has been created.
|
|
||||||
$business = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $business) {
|
|
||||||
app(TenantContext::class)->forget();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($business) {
|
|
||||||
app(TenantContext::class)->set($business);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Models\Concerns\UsesStringIds;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
|
|
||||||
class Business extends Model
|
|
||||||
{
|
|
||||||
use UsesStringIds;
|
|
||||||
|
|
||||||
public const CREATED_AT = 'createdAt';
|
|
||||||
public const UPDATED_AT = 'updatedAt';
|
|
||||||
|
|
||||||
protected $table = 'Business';
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'slug', 'name', 'shortName', 'description', 'logoUrl', 'coverImageUrl',
|
|
||||||
'faviconUrl', 'appIconUrl', 'themeColor', 'backgroundColor', 'phone',
|
|
||||||
'email', 'website', 'instagramUrl', 'facebookUrl', 'timezone', 'currency',
|
|
||||||
'locale', 'bottomNavShowLabels', 'icon192Url', 'icon512Url', 'maskableIcon192Url',
|
|
||||||
'maskableIcon512Url', 'splashScreens', 'welcomeBackgroundDesktopUrl',
|
|
||||||
'welcomeBackgroundMobileUrl', 'googlePlaceId', 'googleRating', 'googleRatingCount',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return ['bottomNavShowLabels' => 'boolean', 'splashScreens' => 'array', 'googleRating' => 'decimal:1'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function domains(): HasMany { return $this->hasMany(BusinessDomain::class, 'businessId'); }
|
|
||||||
public function primaryDomain(): HasOne { return $this->hasOne(BusinessDomain::class, 'businessId')->where('isPrimary', true); }
|
|
||||||
public function locations(): HasMany { return $this->hasMany(BusinessLocation::class, 'businessId'); }
|
|
||||||
public function hours(): HasMany { return $this->hasMany(BusinessHours::class, 'businessId'); }
|
|
||||||
public function settings(): HasOne { return $this->hasOne(BusinessSettings::class, 'businessId'); }
|
|
||||||
public function bookingRule(): HasOne { return $this->hasOne(BookingRule::class, 'businessId'); }
|
|
||||||
public function staff(): HasMany { return $this->hasMany(StaffProfile::class, 'businessId'); }
|
|
||||||
public function serviceCategories(): HasMany { return $this->hasMany(ServiceCategory::class, 'businessId'); }
|
|
||||||
public function services(): HasMany { return $this->hasMany(Service::class, 'businessId'); }
|
|
||||||
public function appointments(): HasMany { return $this->hasMany(Appointment::class, 'businessId'); }
|
|
||||||
public function loyaltyProgramme(): HasOne { return $this->hasOne(LoyaltyProgramme::class, 'businessId'); }
|
|
||||||
public function staffInvitations(): HasMany { return $this->hasMany(StaffInvitation::class, 'businessId'); }
|
|
||||||
public function notifications(): HasMany { return $this->hasMany(Notification::class, 'businessId'); }
|
|
||||||
public function pushSubscriptions(): HasMany { return $this->hasMany(PushSubscription::class, 'businessId'); }
|
|
||||||
public function mediaAssets(): HasMany { return $this->hasMany(MediaAsset::class, 'businessId'); }
|
|
||||||
public function galleryImages(): HasMany { return $this->hasMany(BusinessGalleryImage::class, 'businessId'); }
|
|
||||||
public function reviews(): HasMany { return $this->hasMany(Review::class, 'businessId'); }
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loaded 100 of 717 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user