Compare commits
68
Commits
5972d37425
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70cb874ff5 | ||
|
|
21299f0287 | ||
|
|
96d5533638 | ||
|
|
95ff9bd70c | ||
|
|
68a62e02a4 | ||
|
|
17eee5b932 | ||
|
|
9f981173f4 | ||
|
|
31c48296d9 | ||
|
|
766a0808ab | ||
|
|
05ea83e059 | ||
|
|
2ed9c5ecca | ||
|
|
04d6192d51 | ||
|
|
a8a8d1e045 | ||
|
|
8c267eff25 | ||
|
|
a530d00aa2 | ||
|
|
1442d33d55 | ||
|
|
7338c9d9e2 | ||
|
|
410aa07596 | ||
|
|
0d821ce1f8 | ||
|
|
91f3fd77d7 | ||
|
|
4573587b78 | ||
|
|
7224c87cef | ||
|
|
1932cf7774 | ||
|
|
de32175916 | ||
|
|
65e141d527 | ||
|
|
0e39ca7cdc | ||
|
|
b1fce9c002 | ||
|
|
f7fad3d1a0 | ||
|
|
f24d22fdf1 | ||
|
|
bfa13c5f15 | ||
|
|
1a76d1cacb | ||
|
|
cbeccc6c1f | ||
|
|
e9e28a243f | ||
|
|
d7a8b15482 | ||
|
|
217c0b0101 | ||
|
|
4763b50e62 | ||
|
|
973cb9efc6 | ||
|
|
479cefd085 | ||
|
|
106f89a6da | ||
|
|
2d4f9c5bf0 | ||
|
|
973187dcc7 | ||
|
|
94aa1b2f26 | ||
|
|
ba46334179 | ||
|
|
2e739c9a26 | ||
|
|
3b07124d7e | ||
|
|
c92ee46d76 | ||
|
|
a0d39026c0 | ||
|
|
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
@@ -6,6 +6,12 @@
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
},
|
||||
{
|
||||
"name": "nextchair-manager-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "manager", "run", "dev", "--", "-p", "3100"],
|
||||
"port": 3100
|
||||
}
|
||||
]
|
||||
}
|
||||
+22
-20
@@ -1,26 +1,28 @@
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# The previous Next.js application is intentionally outside this build.
|
||||
legacy-nextjs
|
||||
|
||||
node_modules
|
||||
vendor
|
||||
.next
|
||||
generated
|
||||
.git
|
||||
.gitea
|
||||
# A fully separate app (own package.json, own Dockerfile) — has no
|
||||
# business being copied into the main app's build context at all, and
|
||||
# its own @/* import alias resolving against the main app's tsconfig
|
||||
# (via the include glob's **/*.ts) broke a plain `docker build` here
|
||||
# before this line existed.
|
||||
manager
|
||||
stacks
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
storage/logs/*
|
||||
storage/framework/cache/data/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
bootstrap/cache/*.php
|
||||
public/build
|
||||
docker-compose*.yml
|
||||
docker-compose*.yaml
|
||||
*.log
|
||||
tests
|
||||
!.env.local.example
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
playwright/.cache
|
||||
coverage
|
||||
e2e
|
||||
docs
|
||||
templates
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
@@ -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,129 @@
|
||||
# One business's config on the shared multi-business VPS setup — see
|
||||
# stacks/infra/compose.yaml for the one-time setup this depends on
|
||||
# (creating this business's database on the shared MySQL server) and
|
||||
# stacks/<slug>/compose.yaml for how this file is used. Copy this to
|
||||
# `stacks/<slug>/.env` (kept out of git, see .gitignore) per business and
|
||||
# fill in real values. Nothing here is a real credential. Normally the
|
||||
# management UI (manager/) generates this file for you — this template is
|
||||
# for the by-hand setup path.
|
||||
#
|
||||
# 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 ─────────────────────────────────────────
|
||||
# Manager-only bookkeeping (shown on the dashboard/edit screen, editable
|
||||
# there) — the app itself never reads this; its own business name lives
|
||||
# in the database, set via /install.
|
||||
BUSINESS_NAME="Example Business"
|
||||
# The public domain this business is served on — must match the .caddy
|
||||
# site file's own domain (see stacks/infra/sites/.gitkeep) and that
|
||||
# domain's real DNS (pointed at the VPS) before Caddy can issue a
|
||||
# certificate for it.
|
||||
DOMAIN="example-business.example.com"
|
||||
|
||||
# The app reaches MySQL via the shared infra's service hostname
|
||||
# `next-chair-mysql`, never `localhost` — see stacks/infra/compose.yaml.
|
||||
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for
|
||||
# this business on the shared server (see stacks/infra/compose.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="example_business"
|
||||
DATABASE_USER="example_business"
|
||||
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"
|
||||
# Explicit, not just inferred from AUTH_TRUST_HOST + request headers —
|
||||
# header-based detection (Host/X-Forwarded-Host) behaves inconsistently
|
||||
# across reverse-proxy setups, and a wrong guess sends every post-sign-in
|
||||
# redirect to the wrong origin. Must match DOMAIN above exactly.
|
||||
AUTH_URL="https://example-business.example.com"
|
||||
NEXT_PUBLIC_APP_URL="https://example-business.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 and /api/cron/sync-google-reviews —
|
||||
# generate with: openssl rand -hex 32. Without something actually calling
|
||||
# those on a schedule, queued notifications (staff invites, reminders,
|
||||
# etc.) never get sent — see the root README's "Notification delivery"
|
||||
# section: the Manager's built-in scheduler normally does this (the host
|
||||
# crontab script is only a fallback).
|
||||
CRON_SECRET="replace-me"
|
||||
|
||||
# Where the app emails unhandled server errors (one email per distinct error per hour, max 20 a day).
|
||||
# The Manager fills this in from Settings > Monitoring. Blank = no error emails.
|
||||
OPERATOR_ALERT_EMAIL=""
|
||||
|
||||
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
||||
# Platform-level account with every Owner permission for THIS business.
|
||||
# Runs automatically on every container start (see docker/entrypoint.sh)
|
||||
# — filling these in and recreating the container is enough, no manual
|
||||
# `docker exec` needed. No password — signs in the same OTP way as
|
||||
# everyone else, via a one-time code emailed to SUPER_ADMIN_EMAIL. Leave
|
||||
# SUPER_ADMIN_EMAIL blank to skip entirely and use the /install wizard's
|
||||
# own administrator account instead.
|
||||
SUPER_ADMIN_EMAIL=""
|
||||
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://example-business.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) ─
|
||||
# Managed centrally by the Manager's Settings > Email page and pushed
|
||||
# into every business's .env automatically — see manager/src/lib/
|
||||
# global-settings.ts. There's normally no reason to hand-edit these for
|
||||
# one business only; this by-hand template still needs them set if
|
||||
# you're not using the Manager.
|
||||
EMAIL_PROVIDER="console"
|
||||
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
||||
EMAIL_SMTP_PORT="587"
|
||||
EMAIL_SMTP_USERNAME=""
|
||||
EMAIL_SMTP_PASSWORD=""
|
||||
EMAIL_SMTP_ENCRYPTION="starttls"
|
||||
EMAIL_FROM_ADDRESS=""
|
||||
EMAIL_FROM_NAME=""
|
||||
EMAIL_REPLY_TO=""
|
||||
|
||||
# ── Web Push (optional — defaults to logging to the console instead of sending) ─
|
||||
# Managed centrally by the Manager's Settings > Push Notifications page and
|
||||
# pushed into every business's .env automatically — see manager/src/lib/
|
||||
# global-settings.ts. There's normally no reason to hand-edit these for
|
||||
# one business only; this by-hand template still needs them set if you're
|
||||
# not using the Manager. No NEXT_PUBLIC_VAPID_PUBLIC_KEY here — the app
|
||||
# serves its public key at runtime via GET /api/push/vapid-public-key
|
||||
# instead (see that route's own header for why).
|
||||
PUSH_PROVIDER="console"
|
||||
VAPID_PUBLIC_KEY=""
|
||||
VAPID_PRIVATE_KEY=""
|
||||
VAPID_SUBJECT="mailto:owner@example.com"
|
||||
+92
-59
@@ -1,70 +1,103 @@
|
||||
APP_NAME=NextChair
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8085
|
||||
# Database (MySQL 8.0 — migrated from PostgreSQL in Phase 7, see
|
||||
# docs/phase-7-summary.md; the production host only supports MySQL).
|
||||
DATABASE_HOST="localhost"
|
||||
DATABASE_PORT="3306"
|
||||
DATABASE_NAME="nextchair"
|
||||
DATABASE_USER="user"
|
||||
DATABASE_PASSWORD="password"
|
||||
# Optional: cap the connection pool (see src/lib/db/prisma.ts). Lower this
|
||||
# for a hosting provider with a small max-connections limit.
|
||||
DATABASE_CONNECTION_LIMIT="5"
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
# Auth.js (NextAuth v5). Generate with: npx auth secret
|
||||
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
# Google OAuth — https://console.cloud.google.com/apis/credentials
|
||||
AUTH_GOOGLE_ID=""
|
||||
AUTH_GOOGLE_SECRET=""
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
# Base URL of the app (used for callback URLs, email links, etc.)
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
# Media Manager (Phase 9) — max upload size in bytes. Defaults to
|
||||
# 5,000,000 (5MB) if unset. See src/lib/media/media-storage.ts.
|
||||
MEDIA_MAX_UPLOAD_BYTES="5000000"
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
# Google Reviews (Phase 9) — from https://console.cloud.google.com/apis/credentials,
|
||||
# needs the Places API enabled. Leave unset to keep review sync disabled
|
||||
# (Home shows clearly-labelled demo reviews instead). See
|
||||
# src/services/reviews/review-sync.service.ts and /owner/settings/reviews.
|
||||
GOOGLE_PLACES_API_KEY=""
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=nextchair
|
||||
DB_USERNAME=nextchair
|
||||
DB_PASSWORD=change-me
|
||||
DB_ROOT_PASSWORD=change-me-root
|
||||
# Email (Phase 7) — see src/services/notifications/providers.
|
||||
# "console" logs would-be emails to the server console instead of sending
|
||||
# (safe default — never send real email from an unconfigured environment).
|
||||
# "brevo" sends for real over SMTP via nodemailer (see brevo-email-
|
||||
# provider.ts — it's genuinely generic SMTP, not Brevo-API-specific,
|
||||
# despite the name). On a multi-business deployment these are normally
|
||||
# managed centrally by the Manager's Settings > Email page and pushed into
|
||||
# every business's .env automatically — you don't need to fill these in
|
||||
# by hand there. This single-business template still needs them set.
|
||||
EMAIL_PROVIDER="console"
|
||||
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
||||
EMAIL_SMTP_PORT="587"
|
||||
EMAIL_SMTP_USERNAME=""
|
||||
EMAIL_SMTP_PASSWORD=""
|
||||
# "none" | "starttls" | "tls" — unset infers from port (465 = tls, else
|
||||
# starttls) for backward compatibility; set explicitly to represent "none".
|
||||
EMAIL_SMTP_ENCRYPTION="starttls"
|
||||
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=""
|
||||
# Optional — a distinct reply-to address, separate from the verified sender address.
|
||||
EMAIL_REPLY_TO=""
|
||||
|
||||
# Docker publishes Nginx on this local bind address by default.
|
||||
NEXTCHAIR_HTTP_BIND=127.0.0.1
|
||||
NEXTCHAIR_HTTP_PORT=8085
|
||||
# Web Push — generate with: npx web-push generate-vapid-keys
|
||||
# On a multi-business deployment these are normally managed centrally by
|
||||
# the Manager's Settings > Push Notifications page and pushed into every
|
||||
# business's .env automatically — you don't need to fill these in by hand
|
||||
# there. This single-business template still needs them set. The public
|
||||
# key reaches the browser via GET /api/push/vapid-public-key at runtime,
|
||||
# not a NEXT_PUBLIC_* build-time variable — see that route's own header
|
||||
# for why (every business shares one prebuilt image).
|
||||
# PUSH_PROVIDER="web-push" to actually send; defaults to "console" (logs only).
|
||||
PUSH_PROVIDER="console"
|
||||
VAPID_PUBLIC_KEY=""
|
||||
VAPID_PRIVATE_KEY=""
|
||||
VAPID_SUBJECT="mailto:owner@example.com"
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
# Background notification processing (Phase 7) — shared secret an external
|
||||
# scheduler presents as `Authorization: Bearer <value>` to
|
||||
# /api/cron/process-notifications. Generate with: openssl rand -hex 32
|
||||
CRON_SECRET=""
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
# First-run installation wizard (Phase 8) — required before /install will
|
||||
# create the first business/administrator. Generate with:
|
||||
# openssl rand -hex 32
|
||||
INSTALLATION_TOKEN=""
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
# Super Administrator (Phase 9) — platform-level account with every Owner
|
||||
# permission, provisioned from these env vars rather than the installation
|
||||
# wizard (see scripts/ensure-super-admin.ts, run on every container start).
|
||||
# No password — signs in the same OTP way as everyone else, via a
|
||||
# one-time code emailed to SUPER_ADMIN_EMAIL. Has no StaffProfile by
|
||||
# design: never appears in Team, the customer-facing barber picker, or
|
||||
# staff totals, and can never receive an appointment. All three optional
|
||||
# — leave SUPER_ADMIN_EMAIL unset to skip provisioning one entirely.
|
||||
SUPER_ADMIN_EMAIL=""
|
||||
SUPER_ADMIN_FIRST_NAME=""
|
||||
SUPER_ADMIN_LAST_NAME=""
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
# Staff Google Calendar sync (Phase 8) — separate OAuth client from
|
||||
# AUTH_GOOGLE_ID/SECRET above (different scopes; see
|
||||
# src/services/calendar/google-calendar.provider.ts). Leave unset to keep
|
||||
# calendar sync disabled — Settings shows a "not configured" state rather
|
||||
# than crashing.
|
||||
GOOGLE_CALENDAR_CLIENT_ID=""
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
||||
GOOGLE_CALENDAR_REDIRECT_URI="http://localhost:3000/api/staff/calendar/google/callback"
|
||||
# Encrypts stored refresh tokens at rest. Generate with: openssl rand -hex 32
|
||||
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
||||
@@ -0,0 +1,24 @@
|
||||
# Shared infrastructure config — see stacks/infra/compose.yaml for the
|
||||
# full setup sequence. Copy this to `stacks/infra/.env` (kept out of git
|
||||
# — see .gitignore) and fill in real values.
|
||||
#
|
||||
# docker network create next-chair-edge
|
||||
# docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
|
||||
|
||||
# ── MySQL (shared server — one database per business, created manually
|
||||
# per business, see stacks/infra/compose.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
|
||||
|
||||
# ── Caddy (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"
|
||||
# Leave these at 80/443 on a real deployment — only override for local
|
||||
# testing on a machine already using those ports for something else.
|
||||
CADDY_HTTP_PORT=80
|
||||
CADDY_HTTPS_PORT=443
|
||||
@@ -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
|
||||
+70
-37
@@ -1,41 +1,74 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
# dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
coverage/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
next-env.d.ts
|
||||
|
||||
# production
|
||||
build/
|
||||
dist/
|
||||
|
||||
# env files
|
||||
.env
|
||||
.env.backup
|
||||
.env*.local
|
||||
.env.development
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/.phpunit.result.cache
|
||||
/.phpunit.cache
|
||||
/tsconfig.tsbuildinfo
|
||||
|
||||
# preserved legacy application dependencies and build output
|
||||
/legacy-nextjs/node_modules/
|
||||
/legacy-nextjs/.next/
|
||||
/legacy-nextjs/generated/
|
||||
/legacy-nextjs/tsconfig.tsbuildinfo
|
||||
# per-deployment production env files (multi-business VPS setup — see
|
||||
# stacks/infra/compose.yaml / stacks/<business>/compose.yaml). Every real
|
||||
# stack's .env lives at stacks/<name>/.env (one directory per stack,
|
||||
# same convention NextChair Manager's provisioning uses) — this one
|
||||
# wildcard rule covers every current and future stack without another
|
||||
# .gitignore edit. .env.infra.example and .env.business.example (the
|
||||
# templates you copy into each stacks/<name>/ directory) stay tracked,
|
||||
# same as .env.local.example above.
|
||||
stacks/*/.env
|
||||
|
||||
# Laravel build output
|
||||
/public/build
|
||||
/public/hot
|
||||
/storage/framework/views/
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
# Real per-business Caddy site files (deployment-specific domains, not
|
||||
# something to hand-author into the repo) — only the placeholder stays
|
||||
# tracked.
|
||||
stacks/infra/sites/*.caddy
|
||||
!stacks/infra/sites/.gitkeep
|
||||
|
||||
# Manager's global platform settings (Settings > Email, etc. — see
|
||||
# manager/src/lib/global-settings.ts) — contains an encrypted SMTP
|
||||
# password, generated at runtime, never hand-authored into the repo.
|
||||
stacks/manager/global-settings.json
|
||||
|
||||
# prisma
|
||||
prisma/dev.db
|
||||
prisma/dev.db-journal
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.vercel
|
||||
*.tsbuildinfo
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
|
||||
/generated/prisma
|
||||
|
||||
.agents/
|
||||
.claude/
|
||||
.windsurf/
|
||||
@@ -1,2 +1 @@
|
||||
ignore-scripts=true
|
||||
audit=true
|
||||
cache=/private/tmp/claude-501/-Users-stefanlazic-Documents-GitHub-NextChair/11cd8d75-4d20-4339-a2c5-329dd2e59132/scratchpad/npm-cache
|
||||
File renamed without changes.
@@ -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>
|
||||
+80
-73
@@ -1,83 +1,90 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-bookworm-slim AS frontend
|
||||
|
||||
WORKDIR /var/www/html
|
||||
# Multi-stage build. NextChair is the internal project/image name only —
|
||||
# see docs/phase-8-summary.md's white-label section for why that's fine
|
||||
# here (Docker/package names are explicitly "internal and acceptable")
|
||||
# even though the running application never shows that name to a user.
|
||||
#
|
||||
# Prisma 7 uses driver adapters (@prisma/adapter-mariadb), not a native
|
||||
# Rust query-engine binary — the generated client is pure JS, so this
|
||||
# image needs no special native-binary handling the way older Prisma
|
||||
# versions on Alpine sometimes did.
|
||||
#
|
||||
# `output: "standalone"` in next.config.ts was deliberately NOT enabled:
|
||||
# this image runs `prisma migrate deploy` (and, for the optional seed
|
||||
# service, `tsx prisma/seed.ts`) at container start, both of which need
|
||||
# the full `prisma`/`tsx` CLIs and their dependencies present — a pruned
|
||||
# standalone bundle would need those re-added anyway, so a single
|
||||
# `npm ci` (full dependencies, including the small set of dev tools this
|
||||
# image actually runs) is simpler and no less correct. See
|
||||
# docs/phase-8-summary.md, "Docker image quality" for this trade-off.
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY resources ./resources
|
||||
COPY public ./public
|
||||
COPY vite.config.js .
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# `npm run build` runs `prisma generate` itself (see package.json — needed
|
||||
# so a plain Vercel/`npm run build` deploy generates the client too, not
|
||||
# just this Docker path, which used to have its own separate `prisma
|
||||
# generate` step here).
|
||||
# `next build`'s page-data collection step imports every route module
|
||||
# (including /api/health) to inspect it — that pulls in
|
||||
# src/lib/db/prisma.ts, which requires DATABASE_HOST/NAME/USER/PASSWORD
|
||||
# just to construct its connection-pool config object, even though nothing
|
||||
# actually connects at build time. These placeholders only exist to
|
||||
# satisfy that check; the real values are supplied at container runtime
|
||||
# via env_file (see docker-compose.local.yaml) and these are never used to
|
||||
# connect to anything or baked into a layer that matters.
|
||||
ENV DATABASE_HOST="localhost"
|
||||
ENV DATABASE_PORT="3306"
|
||||
ENV DATABASE_NAME="build"
|
||||
ENV DATABASE_USER="build"
|
||||
ENV DATABASE_PASSWORD="build"
|
||||
# Skip `next build`'s TypeScript pass in the image. It's a redundant second
|
||||
# check — `npx tsc --noEmit` already gates every change before it's built —
|
||||
# and it's by far the heaviest step of the build: on a low-memory VPS it can
|
||||
# swap for many minutes or get OOM-killed (exit 137). Type errors are still
|
||||
# caught everywhere else; to type-check inside the image anyway, build with
|
||||
# `docker build --build-arg SKIP_TYPECHECK=false .`
|
||||
ARG SKIP_TYPECHECK=true
|
||||
ENV SKIP_TYPECHECK=$SKIP_TYPECHECK
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
FROM composer:2 AS composer
|
||||
# Non-root — see the brief's "Docker image quality: non-root user."
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextchair
|
||||
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/generated ./generated
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/prisma ./prisma
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/src ./src
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/scripts ./scripts
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/package.json ./package.json
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/prisma.config.ts ./prisma.config.ts
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/next.config.ts ./next.config.ts
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/tsconfig.json ./tsconfig.json
|
||||
COPY --chown=nextchair:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
||||
RUN chmod +x ./docker/entrypoint.sh
|
||||
# Media Manager uploads (Phase 10) — storage/media (NOT public/) is a
|
||||
# named volume mount point (see docker-compose.local.yaml). Served by
|
||||
# src/app/api/media/[...path]/route.ts, not Next's static handling for
|
||||
# public/ — verified against a real production build that runtime-added
|
||||
# public/ files 404 (Next resolves public assets at build time, not from
|
||||
# a live filesystem check). Creating this here, owned by the non-root
|
||||
# user, before the volume first attaches means Docker initializes the
|
||||
# volume with this ownership rather than root's.
|
||||
RUN mkdir -p ./storage/media && chown -R nextchair:nodejs ./storage
|
||||
|
||||
FROM php:8.4-fpm-bookworm AS app
|
||||
USER nextchair
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
ENV COMPOSER_ALLOW_SUPERUSER=1 \
|
||||
PHP_OPCACHE_VALIDATE_TIMESTAMPS=0
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
libfreetype6-dev \
|
||||
libicu-dev \
|
||||
libjpeg62-turbo-dev \
|
||||
libonig-dev \
|
||||
libpng-dev \
|
||||
libxml2-dev \
|
||||
libzip-dev \
|
||||
unzip \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install -j"$(nproc)" \
|
||||
bcmath \
|
||||
exif \
|
||||
gd \
|
||||
intl \
|
||||
mbstring \
|
||||
opcache \
|
||||
pcntl \
|
||||
pdo_mysql \
|
||||
xml \
|
||||
zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer /usr/bin/composer /usr/bin/composer
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /var/www/html/public/build ./public/build
|
||||
|
||||
RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist \
|
||||
&& mkdir -p \
|
||||
storage/framework/cache/data \
|
||||
storage/framework/sessions \
|
||||
storage/framework/views \
|
||||
storage/logs \
|
||||
bootstrap/cache \
|
||||
&& chown -R www-data:www-data storage bootstrap/cache \
|
||||
&& chmod -R ug+rwX storage bootstrap/cache
|
||||
|
||||
COPY docker/entrypoint.sh /usr/local/bin/nextchair-entrypoint
|
||||
RUN chmod +x /usr/local/bin/nextchair-entrypoint
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/nextchair-entrypoint"]
|
||||
CMD ["php-fpm", "-F"]
|
||||
|
||||
|
||||
FROM nginx:1.27-alpine AS nginx
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
COPY public ./public
|
||||
COPY --from=frontend /var/www/html/public/build ./public/build
|
||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
RUN mkdir -p /var/www/html/storage/app/public \
|
||||
&& ln -s /var/www/html/storage/app/public /var/www/html/public/storage
|
||||
ENTRYPOINT ["./docker/entrypoint.sh"]
|
||||
@@ -1,109 +1,290 @@
|
||||
# NextChair
|
||||
|
||||
NextChair is a multi-tenant, white-label booking platform for barbershops.
|
||||
The application is being rebuilt as a conventional Laravel web application
|
||||
using PHP, MySQL, Blade, Tailwind CSS, Alpine.js, and Vite.
|
||||
Booking app as an alternative to Booksy
|
||||
|
||||
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.
|
||||
## VPS deployment (multi-business)
|
||||
|
||||
## Local development
|
||||
Everything below runs on a fresh VPS with Docker and the Compose plugin
|
||||
already installed — no other prerequisite (no host Node.js/npm needed;
|
||||
every command either uses Docker itself or plain shell tools). Every code
|
||||
block is meant to be pasted as-is, in order.
|
||||
|
||||
### 0. One-time: clone the repo and generate your secrets
|
||||
|
||||
```bash
|
||||
composer install
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
npm install
|
||||
npm run build
|
||||
php artisan serve
|
||||
# STACKS_DIR later on must be this exact absolute path — every stack
|
||||
# shells `docker compose` back out to the HOST's dockerd via a mounted
|
||||
# socket, so the path only resolves to the same thing on both sides if
|
||||
# it's cloned here and never moved.
|
||||
git clone <your-repo-url> /opt/nextchair
|
||||
cd /opt/nextchair
|
||||
```
|
||||
|
||||
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:
|
||||
Generate the secrets you'll paste into the `.env` files in the steps
|
||||
below. Run these now and keep the output somewhere safe (a password
|
||||
manager) — you'll need each value again in a moment.
|
||||
|
||||
```bash
|
||||
NEXTCHAIR_SEED_DEMO=true php artisan db:seed
|
||||
# MYSQL_ROOT_PASSWORD — goes into BOTH stacks/infra/.env and manager/.env
|
||||
# (the same value in both places).
|
||||
echo "MYSQL_ROOT_PASSWORD=$(openssl rand -hex 24)"
|
||||
|
||||
# MANAGER_SESSION_SECRET — manager/.env only.
|
||||
echo "MANAGER_SESSION_SECRET=$(openssl rand -hex 32)"
|
||||
|
||||
# MANAGER_SETTINGS_ENCRYPTION_KEY — manager/.env only. Encrypts the
|
||||
# SMTP/VAPID secrets you'll enter in the Manager's Settings page later.
|
||||
# Losing/rotating this makes any already-saved secret there undecryptable,
|
||||
# so store it as carefully as the other secrets on this page.
|
||||
echo "MANAGER_SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
## 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
|
||||
# MANAGER_PASSWORD_HASH — manager/.env only. Replace "your-password" with
|
||||
# your chosen Manager login password first. Run via a throwaway container
|
||||
# so this works with nothing but Docker installed on the host.
|
||||
docker run --rm node:22-alpine sh -c \
|
||||
"mkdir -p /tmp/gen && cd /tmp/gen && npm install bcryptjs --no-audit --no-fund >/dev/null 2>&1 && \
|
||||
node -e \"const h=require('/tmp/gen/node_modules/bcryptjs').hashSync('your-password',12); console.log('MANAGER_PASSWORD_HASH=\\\"'+h.replace(/\\$/g,'\\$\\$')+'\\\"')\""
|
||||
```
|
||||
|
||||
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:
|
||||
### 1. Shared infrastructure — network, MySQL and Caddy
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose exec app php artisan key:generate
|
||||
docker compose exec app php artisan migrate
|
||||
docker network create next-chair-edge
|
||||
|
||||
cp .env.infra.example stacks/infra/.env
|
||||
```
|
||||
|
||||
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:
|
||||
Edit `stacks/infra/.env`:
|
||||
- `MYSQL_ROOT_PASSWORD` — the first secret you generated above.
|
||||
- `MYSQL_HOST_PORT` — set this to `127.0.0.1:3306` so MySQL is only exposed on the VPS loopback interface, not publicly. You can still connect to it remotely through an SSH tunnel.
|
||||
- `ACME_EMAIL` — a real address you check (Let's Encrypt renewal notices).
|
||||
|
||||
```bash
|
||||
docker compose logs -f app nginx queue scheduler db
|
||||
docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
|
||||
```
|
||||
|
||||
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.
|
||||
### 2. Build the shared application image
|
||||
|
||||
## Verification
|
||||
Every business (and no other step in this guide) runs this same image —
|
||||
you only rebuild it here, once, and again whenever you ship a code
|
||||
update (see "Updating" below).
|
||||
|
||||
```bash
|
||||
php artisan test
|
||||
npm run build
|
||||
docker build -t nextchair-app:latest .
|
||||
```
|
||||
|
||||
### 3. The management UI (Manager)
|
||||
|
||||
This is what you'll use to add every business from here on.
|
||||
|
||||
```bash
|
||||
cp manager/.env.example stacks/manager/.env
|
||||
```
|
||||
|
||||
Edit `stacks/manager/.env`:
|
||||
- `MANAGER_EMAIL` — the email you'll log in with.
|
||||
- `MANAGER_PASSWORD_HASH` — the bcrypt hash generated in step 0.
|
||||
- `MANAGER_SESSION_SECRET` and `MANAGER_SETTINGS_ENCRYPTION_KEY` — the two
|
||||
other values generated in step 0.
|
||||
- `MANAGER_PUBLIC_URL` — `https://` + the domain you'll point at this app
|
||||
in the next step.
|
||||
- `MYSQL_ROOT_PASSWORD` — same value as `stacks/infra/.env`.
|
||||
- `STACKS_DIR` — same absolute path as step 1.
|
||||
|
||||
```bash
|
||||
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
|
||||
```
|
||||
|
||||
Point your chosen Manager domain's DNS (A/AAAA record) at the VPS, then
|
||||
give it a Caddy site and reload:
|
||||
|
||||
```bash
|
||||
cat > stacks/infra/sites/manager.caddy << 'EOF'
|
||||
manager.example.com {
|
||||
reverse_proxy next-chair-manager:3000
|
||||
}
|
||||
EOF
|
||||
docker exec next-chair-caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
(Replace `manager.example.com` with your real domain — Caddy issues its
|
||||
TLS certificate automatically on first request.)
|
||||
|
||||
### 4. Configure platform-wide settings (once)
|
||||
|
||||
Log into the Manager at `https://<your-manager-domain>` and open
|
||||
**Settings**. Both sections are optional to fill in now, but doing it
|
||||
before adding your first business means that business never needs its
|
||||
own SMTP/push setup:
|
||||
|
||||
- **Email / SMTP** — your SMTP relay's host/port/username/password and a
|
||||
from-address. Use **Send Test Email** to confirm it before saving for
|
||||
real, then **Save Changes** to apply it to every business.
|
||||
- **Push Notifications** — click **Generate VAPID Keys** once (a contact
|
||||
email is enough to start). This is a one-time, platform-wide key pair —
|
||||
see that section's own copy for what happens if you ever regenerate it.
|
||||
|
||||
### 5. Add your first business
|
||||
|
||||
Still in the Manager: **Add Business** → name, slug, domain. This creates
|
||||
its database, generates its secrets, starts its container, and runs its
|
||||
migrations for you — no manual per-business `.env` editing needed.
|
||||
|
||||
Point that business's own domain's DNS at the VPS (the Manager doesn't do
|
||||
this for you), then finish its setup by visiting
|
||||
`https://<business-domain>/install`.
|
||||
|
||||
Repeat this step for each additional business, whenever you want one.
|
||||
|
||||
### 6. Notification delivery (handled by the Manager)
|
||||
|
||||
Staff invitation emails, appointment reminders, push notifications,
|
||||
loyalty reward emails, WhatsApp-reminder nudges and Google Reviews sync are
|
||||
**not sent by the app itself** — it only queues them; something has to
|
||||
trigger delivery on a schedule by calling two `CRON_SECRET`-gated
|
||||
endpoints (`/api/cron/process-notifications`,
|
||||
`/api/cron/sync-google-reviews` — see their route files for the reasoning).
|
||||
Without a trigger, every business's queued notifications sit as `PENDING`
|
||||
forever, even though the UI says "sent" the moment something is queued.
|
||||
|
||||
**The Manager's built-in scheduler does this for you** — nothing to
|
||||
install on the host. Once the Manager is running it calls every
|
||||
business's notification cron every 30 seconds (adjustable at Settings →
|
||||
Monitoring & scheduler, 10 s – 1 hour) and the reviews sync once a day,
|
||||
including for businesses added later. That page lists the last run of each
|
||||
task per business, so you can confirm it's working.
|
||||
|
||||
Each business's `/api/health` also reports whether notifications are
|
||||
actually going out (`status: "degraded"` plus the reasons, when the
|
||||
processor hasn't run for 10 minutes, deliveries are stuck, or every delivery
|
||||
in the last day failed), and the Manager emails you when that happens.
|
||||
|
||||
#### Removing the old crontab
|
||||
|
||||
If you set up the host crontab from an earlier version of this guide, remove
|
||||
it once the Manager scheduler is confirmed working — running both is safe
|
||||
(deliveries are claimed one at a time, so nothing is sent twice) but
|
||||
pointless:
|
||||
|
||||
1. Open Settings → Monitoring & scheduler in the Manager and check that
|
||||
"Process notifications" shows a recent successful run for each business.
|
||||
2. On the VPS, as the user that owns `/opt/nextchair`, run `crontab -e` and
|
||||
delete the two `process-cron.sh` lines (notifications and reviews).
|
||||
3. Optionally delete `/opt/nextchair/nextchair-cron.log`.
|
||||
|
||||
#### Fallback: host crontab
|
||||
|
||||
Only if you run with `MANAGER_SCHEDULER_DISABLED=1`, or want an independent
|
||||
trigger. [stacks/infra/scripts/process-cron.sh](stacks/infra/scripts/process-cron.sh)
|
||||
calls both endpoints for **every** business under `STACKS_DIR` in one run.
|
||||
Many minimal VPS images don't ship `cron` — install it first
|
||||
(`sudo apt update && sudo apt install -y cron && sudo systemctl enable --now cron`),
|
||||
then add this with `crontab -e` as the user that owns `/opt/nextchair`
|
||||
(**not** `sudo crontab -e`, so the log file is writable):
|
||||
|
||||
```bash
|
||||
# Deliver queued notifications every minute (the finest a crontab can do).
|
||||
* * * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh notifications >> /opt/nextchair/nextchair-cron.log 2>&1
|
||||
|
||||
# Refresh Google Reviews once a day.
|
||||
0 3 * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh reviews >> /opt/nextchair/nextchair-cron.log 2>&1
|
||||
```
|
||||
|
||||
A healthy run logs `<slug> process-notifications ok` for every business. Use
|
||||
only one of the two mechanisms at a time as your normal setup.
|
||||
|
||||
## Updating after a code change
|
||||
|
||||
For an existing NextChair server, **do not re-run the fresh VPS setup above** and do not overwrite your production `.env` files.
|
||||
|
||||
First pull the latest code:
|
||||
|
||||
```bash
|
||||
cd /opt/nextchair
|
||||
git pull
|
||||
```
|
||||
|
||||
Before rebuilding, check whether either example environment file has gained new settings since your last deployment:
|
||||
|
||||
```bash
|
||||
# Review only — do not copy these files over your existing production .env files.
|
||||
diff -u stacks/infra/.env .env.infra.example || true
|
||||
diff -u stacks/manager/.env manager/.env.example || true
|
||||
```
|
||||
|
||||
If a new variable has been added, copy **only that variable** into the corresponding production `.env` and give it an appropriate value. Keep all existing passwords, secrets and generated values unchanged.
|
||||
|
||||
Then rebuild the one shared image every business uses:
|
||||
|
||||
```bash
|
||||
cd /opt/nextchair
|
||||
docker build -t nextchair-app:latest .
|
||||
```
|
||||
|
||||
Then, for **each** business: use its **Update** button in the Manager UI,
|
||||
or run this directly:
|
||||
|
||||
```bash
|
||||
cd /opt/nextchair/stacks/<slug>
|
||||
docker compose --env-file .env up -d --force-recreate
|
||||
```
|
||||
|
||||
Either way this recreates that business's container against the freshly
|
||||
built image — and its own startup automatically runs
|
||||
`prisma migrate deploy`, so any new database migration is applied on the
|
||||
same step. No separate migration command needed.
|
||||
|
||||
If the code change touched the **Manager app itself** (anything under
|
||||
`manager/`), rebuild/recreate the Manager too:
|
||||
|
||||
```bash
|
||||
cd /opt/nextchair
|
||||
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
|
||||
```
|
||||
|
||||
If both the business app and Manager changed, do both update steps.
|
||||
|
||||
## Upgrading from a version that included Dockge (one-time)
|
||||
|
||||
Dockge used to run alongside NextChair Manager as a separate stack
|
||||
management UI. It's no longer part of the shared infrastructure stack —
|
||||
Manager is the only tool needed to provision and manage business stacks,
|
||||
so the two were redundant. If your server was set up before this change,
|
||||
existing tenant databases, tenant containers, business Compose files, and
|
||||
the global SMTP/VAPID settings are all unaffected — this only removes the
|
||||
old Dockge container.
|
||||
|
||||
Pull the change, then re-apply the infra stack with `--remove-orphans` so
|
||||
Docker removes the container that's no longer defined in
|
||||
`stacks/infra/compose.yaml`:
|
||||
|
||||
```bash
|
||||
cd /opt/nextchair
|
||||
git pull
|
||||
|
||||
docker compose \
|
||||
-f stacks/infra/compose.yaml \
|
||||
--env-file stacks/infra/.env \
|
||||
up -d --remove-orphans
|
||||
```
|
||||
|
||||
`--remove-orphans` only removes containers Compose no longer recognizes
|
||||
(the old `next-chair-dockge` container) — it does not touch Caddy, MySQL,
|
||||
or their volumes.
|
||||
|
||||
This does **not** delete Dockge's own data volume
|
||||
(`next-chair-dockge-data`) — Docker never removes volumes on its own, and
|
||||
this command has no reason to either. Once you've confirmed Caddy, MySQL
|
||||
and every business are still healthy, you can remove that now-unused
|
||||
volume by hand:
|
||||
|
||||
```bash
|
||||
docker volume rm next-chair-dockge-data
|
||||
```
|
||||
|
||||
Finally, `STACKS_DIR` and `DOCKGE_HOST_PORT` in `stacks/infra/.env` were
|
||||
only ever used by Dockge and are safe to delete from that file — nothing
|
||||
else in the infra stack reads them. (Manager keeps its own separate
|
||||
`STACKS_DIR` in `stacks/manager/.env`; leave that one alone.)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 972 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user