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",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"port": 3000
|
"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
|
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.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
storage/logs/*
|
!.env.local.example
|
||||||
storage/framework/cache/data/*
|
test-results
|
||||||
storage/framework/sessions/*
|
playwright-report
|
||||||
storage/framework/views/*
|
blob-report
|
||||||
bootstrap/cache/*.php
|
playwright/.cache
|
||||||
public/build
|
coverage
|
||||||
docker-compose*.yml
|
|
||||||
docker-compose*.yaml
|
|
||||||
*.log
|
|
||||||
tests
|
|
||||||
e2e
|
e2e
|
||||||
docs
|
docs
|
||||||
templates
|
*.md
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
end_of_line = lf
|
|
||||||
indent_size = 4
|
|
||||||
indent_style = space
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
|
|
||||||
[*.{yml,yaml}]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[{compose,docker-compose}.{yml,yaml}]
|
|
||||||
indent_size = 4
|
|
||||||
@@ -0,0 +1,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
|
# Database (MySQL 8.0 — migrated from PostgreSQL in Phase 7, see
|
||||||
APP_ENV=local
|
# docs/phase-7-summary.md; the production host only supports MySQL).
|
||||||
APP_KEY=
|
DATABASE_HOST="localhost"
|
||||||
APP_DEBUG=true
|
DATABASE_PORT="3306"
|
||||||
APP_URL=http://localhost:8085
|
DATABASE_NAME="nextchair"
|
||||||
|
DATABASE_USER="user"
|
||||||
|
DATABASE_PASSWORD="password"
|
||||||
|
# Optional: cap the connection pool (see src/lib/db/prisma.ts). Lower this
|
||||||
|
# for a hosting provider with a small max-connections limit.
|
||||||
|
DATABASE_CONNECTION_LIMIT="5"
|
||||||
|
|
||||||
APP_LOCALE=en
|
# Auth.js (NextAuth v5). Generate with: npx auth secret
|
||||||
APP_FALLBACK_LOCALE=en
|
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
|
||||||
APP_FAKER_LOCALE=en_US
|
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
# Google OAuth — https://console.cloud.google.com/apis/credentials
|
||||||
# APP_MAINTENANCE_STORE=database
|
AUTH_GOOGLE_ID=""
|
||||||
|
AUTH_GOOGLE_SECRET=""
|
||||||
|
|
||||||
# PHP_CLI_SERVER_WORKERS=4
|
# Base URL of the app (used for callback URLs, email links, etc.)
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
# Media Manager (Phase 9) — max upload size in bytes. Defaults to
|
||||||
|
# 5,000,000 (5MB) if unset. See src/lib/media/media-storage.ts.
|
||||||
|
MEDIA_MAX_UPLOAD_BYTES="5000000"
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
# Google Reviews (Phase 9) — from https://console.cloud.google.com/apis/credentials,
|
||||||
LOG_STACK=single
|
# needs the Places API enabled. Leave unset to keep review sync disabled
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
# (Home shows clearly-labelled demo reviews instead). See
|
||||||
LOG_LEVEL=debug
|
# src/services/reviews/review-sync.service.ts and /owner/settings/reviews.
|
||||||
|
GOOGLE_PLACES_API_KEY=""
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
# Email (Phase 7) — see src/services/notifications/providers.
|
||||||
DB_HOST=db
|
# "console" logs would-be emails to the server console instead of sending
|
||||||
DB_PORT=3306
|
# (safe default — never send real email from an unconfigured environment).
|
||||||
DB_DATABASE=nextchair
|
# "brevo" sends for real over SMTP via nodemailer (see brevo-email-
|
||||||
DB_USERNAME=nextchair
|
# provider.ts — it's genuinely generic SMTP, not Brevo-API-specific,
|
||||||
DB_PASSWORD=change-me
|
# despite the name). On a multi-business deployment these are normally
|
||||||
DB_ROOT_PASSWORD=change-me-root
|
# 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.
|
# Web Push — generate with: npx web-push generate-vapid-keys
|
||||||
NEXTCHAIR_HTTP_BIND=127.0.0.1
|
# On a multi-business deployment these are normally managed centrally by
|
||||||
NEXTCHAIR_HTTP_PORT=8085
|
# 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
|
# Background notification processing (Phase 7) — shared secret an external
|
||||||
SESSION_LIFETIME=120
|
# scheduler presents as `Authorization: Bearer <value>` to
|
||||||
SESSION_ENCRYPT=false
|
# /api/cron/process-notifications. Generate with: openssl rand -hex 32
|
||||||
SESSION_PATH=/
|
CRON_SECRET=""
|
||||||
SESSION_DOMAIN=null
|
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
# First-run installation wizard (Phase 8) — required before /install will
|
||||||
FILESYSTEM_DISK=local
|
# create the first business/administrator. Generate with:
|
||||||
QUEUE_CONNECTION=database
|
# openssl rand -hex 32
|
||||||
|
INSTALLATION_TOKEN=""
|
||||||
|
|
||||||
CACHE_STORE=database
|
# Super Administrator (Phase 9) — platform-level account with every Owner
|
||||||
# CACHE_PREFIX=
|
# permission, provisioned from these env vars rather than the installation
|
||||||
|
# wizard (see scripts/ensure-super-admin.ts, run on every container start).
|
||||||
|
# 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
|
# Staff Google Calendar sync (Phase 8) — separate OAuth client from
|
||||||
|
# AUTH_GOOGLE_ID/SECRET above (different scopes; see
|
||||||
REDIS_CLIENT=phpredis
|
# src/services/calendar/google-calendar.provider.ts). Leave unset to keep
|
||||||
REDIS_HOST=127.0.0.1
|
# calendar sync disabled — Settings shows a "not configured" state rather
|
||||||
REDIS_PASSWORD=null
|
# than crashing.
|
||||||
REDIS_PORT=6379
|
GOOGLE_CALENDAR_CLIENT_ID=""
|
||||||
|
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
||||||
MAIL_MAILER=log
|
GOOGLE_CALENDAR_REDIRECT_URI="http://localhost:3000/api/staff/calendar/google/callback"
|
||||||
MAIL_SCHEME=null
|
# Encrypts stored refresh tokens at rest. Generate with: openssl rand -hex 32
|
||||||
MAIL_HOST=127.0.0.1
|
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
||||||
MAIL_PORT=2525
|
|
||||||
MAIL_USERNAME=null
|
|
||||||
MAIL_PASSWORD=null
|
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
|
||||||
AWS_SECRET_ACCESS_KEY=
|
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
|
||||||
AWS_BUCKET=
|
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
@@ -0,0 +1,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
|
# dependencies
|
||||||
.DS_Store
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# production
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# env files
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env*.local
|
||||||
|
.env.development
|
||||||
.env.production
|
.env.production
|
||||||
.phpactor.json
|
|
||||||
.phpunit.result.cache
|
|
||||||
/.codex
|
|
||||||
/.cursor/
|
|
||||||
/.idea
|
|
||||||
/.nova
|
|
||||||
/.phpunit.cache
|
|
||||||
/.vscode
|
|
||||||
/.zed
|
|
||||||
/auth.json
|
|
||||||
/node_modules
|
|
||||||
/public/build
|
|
||||||
/public/fonts-manifest.dev.json
|
|
||||||
/public/hot
|
|
||||||
/public/storage
|
|
||||||
/storage/*.key
|
|
||||||
/storage/pail
|
|
||||||
/vendor
|
|
||||||
/.phpunit.result.cache
|
|
||||||
/.phpunit.cache
|
|
||||||
/tsconfig.tsbuildinfo
|
|
||||||
|
|
||||||
# preserved legacy application dependencies and build output
|
# per-deployment production env files (multi-business VPS setup — see
|
||||||
/legacy-nextjs/node_modules/
|
# stacks/infra/compose.yaml / stacks/<business>/compose.yaml). Every real
|
||||||
/legacy-nextjs/.next/
|
# stack's .env lives at stacks/<name>/.env (one directory per stack,
|
||||||
/legacy-nextjs/generated/
|
# same convention NextChair Manager's provisioning uses) — this one
|
||||||
/legacy-nextjs/tsconfig.tsbuildinfo
|
# 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
|
# Real per-business Caddy site files (deployment-specific domains, not
|
||||||
/public/build
|
# something to hand-author into the repo) — only the placeholder stays
|
||||||
/public/hot
|
# tracked.
|
||||||
/storage/framework/views/
|
stacks/infra/sites/*.caddy
|
||||||
_ide_helper.php
|
!stacks/infra/sites/.gitkeep
|
||||||
Homestead.json
|
|
||||||
Homestead.yaml
|
# Manager's global platform settings (Settings > Email, etc. — see
|
||||||
Thumbs.db
|
# 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
|
cache=/private/tmp/claude-501/-Users-stefanlazic-Documents-GitHub-NextChair/11cd8d75-4d20-4339-a2c5-329dd2e59132/scratchpad/npm-cache
|
||||||
audit=true
|
|
||||||
File renamed without changes.
@@ -1,47 +0,0 @@
|
|||||||
<laravel-boost-guidelines>
|
|
||||||
# Laravel Application
|
|
||||||
|
|
||||||
This repository contains a Laravel application. Complete the following setup before working on the user's request.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Verify that PHP and Composer are available:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
php -v
|
|
||||||
composer -V
|
|
||||||
```
|
|
||||||
|
|
||||||
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
|
|
||||||
|
|
||||||
macOS:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Windows PowerShell:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
|
|
||||||
```
|
|
||||||
|
|
||||||
Linux:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
|
|
||||||
```
|
|
||||||
|
|
||||||
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
|
|
||||||
|
|
||||||
## Agent Setup
|
|
||||||
|
|
||||||
Install Laravel Boost from the application root before making application changes:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
composer require laravel/boost --dev
|
|
||||||
php artisan boost:install
|
|
||||||
```
|
|
||||||
|
|
||||||
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
|
|
||||||
</laravel-boost-guidelines>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<laravel-boost-guidelines>
|
|
||||||
# Laravel Application
|
|
||||||
|
|
||||||
This repository contains a Laravel application. Complete the following setup before working on the user's request.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Verify that PHP and Composer are available:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
php -v
|
|
||||||
composer -V
|
|
||||||
```
|
|
||||||
|
|
||||||
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
|
|
||||||
|
|
||||||
macOS:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Windows PowerShell:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
|
|
||||||
```
|
|
||||||
|
|
||||||
Linux:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
|
|
||||||
```
|
|
||||||
|
|
||||||
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
|
|
||||||
|
|
||||||
## Agent Setup
|
|
||||||
|
|
||||||
Install Laravel Boost from the application root before making application changes:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
composer require laravel/boost --dev
|
|
||||||
php artisan boost:install
|
|
||||||
```
|
|
||||||
|
|
||||||
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
|
|
||||||
</laravel-boost-guidelines>
|
|
||||||
+80
-73
@@ -1,83 +1,90 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# Multi-stage build. NextChair is the internal project/image name only —
|
||||||
|
# see docs/phase-8-summary.md's white-label section for why that's fine
|
||||||
FROM node:22-bookworm-slim AS frontend
|
# here (Docker/package names are explicitly "internal and acceptable")
|
||||||
|
# even though the running application never shows that name to a user.
|
||||||
WORKDIR /var/www/html
|
#
|
||||||
|
# Prisma 7 uses driver adapters (@prisma/adapter-mariadb), not a native
|
||||||
|
# Rust query-engine binary — the generated client is pure JS, so this
|
||||||
|
# image needs no special native-binary handling the way older Prisma
|
||||||
|
# versions on Alpine sometimes did.
|
||||||
|
#
|
||||||
|
# `output: "standalone"` in next.config.ts was deliberately NOT enabled:
|
||||||
|
# this image runs `prisma migrate deploy` (and, for the optional seed
|
||||||
|
# service, `tsx prisma/seed.ts`) at container start, both of which need
|
||||||
|
# the full `prisma`/`tsx` CLIs and their dependencies present — a pruned
|
||||||
|
# standalone bundle would need those re-added anyway, so a single
|
||||||
|
# `npm ci` (full dependencies, including the small set of dev tools this
|
||||||
|
# image actually runs) is simpler and no less correct. See
|
||||||
|
# docs/phase-8-summary.md, "Docker image quality" for this trade-off.
|
||||||
|
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY resources ./resources
|
FROM node:22-alpine AS builder
|
||||||
COPY public ./public
|
WORKDIR /app
|
||||||
COPY vite.config.js .
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# `npm run build` runs `prisma generate` itself (see package.json — needed
|
||||||
|
# so a plain Vercel/`npm run build` deploy generates the client too, not
|
||||||
|
# just this Docker path, which used to have its own separate `prisma
|
||||||
|
# generate` step here).
|
||||||
|
# `next build`'s page-data collection step imports every route module
|
||||||
|
# (including /api/health) to inspect it — that pulls in
|
||||||
|
# src/lib/db/prisma.ts, which requires DATABASE_HOST/NAME/USER/PASSWORD
|
||||||
|
# just to construct its connection-pool config object, even though nothing
|
||||||
|
# actually connects at build time. These placeholders only exist to
|
||||||
|
# satisfy that check; the real values are supplied at container runtime
|
||||||
|
# via env_file (see docker-compose.local.yaml) and these are never used to
|
||||||
|
# connect to anything or baked into a layer that matters.
|
||||||
|
ENV DATABASE_HOST="localhost"
|
||||||
|
ENV DATABASE_PORT="3306"
|
||||||
|
ENV DATABASE_NAME="build"
|
||||||
|
ENV DATABASE_USER="build"
|
||||||
|
ENV DATABASE_PASSWORD="build"
|
||||||
|
# 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
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
FROM composer:2 AS composer
|
# Non-root — see the brief's "Docker image quality: non-root user."
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextchair
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/.next ./.next
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/generated ./generated
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/prisma ./prisma
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/src ./src
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/scripts ./scripts
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/package.json ./package.json
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/prisma.config.ts ./prisma.config.ts
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/next.config.ts ./next.config.ts
|
||||||
|
COPY --from=builder --chown=nextchair:nodejs /app/tsconfig.json ./tsconfig.json
|
||||||
|
COPY --chown=nextchair:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
||||||
|
RUN chmod +x ./docker/entrypoint.sh
|
||||||
|
# Media Manager uploads (Phase 10) — storage/media (NOT public/) is a
|
||||||
|
# named volume mount point (see docker-compose.local.yaml). Served by
|
||||||
|
# src/app/api/media/[...path]/route.ts, not Next's static handling for
|
||||||
|
# public/ — verified against a real production build that runtime-added
|
||||||
|
# public/ files 404 (Next resolves public assets at build time, not from
|
||||||
|
# a live filesystem check). Creating this here, owned by the non-root
|
||||||
|
# user, before the volume first attaches means Docker initializes the
|
||||||
|
# volume with this ownership rather than root's.
|
||||||
|
RUN mkdir -p ./storage/media && chown -R nextchair:nodejs ./storage
|
||||||
|
|
||||||
FROM php:8.4-fpm-bookworm AS app
|
USER nextchair
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
ENV COMPOSER_ALLOW_SUPERUSER=1 \
|
ENTRYPOINT ["./docker/entrypoint.sh"]
|
||||||
PHP_OPCACHE_VALIDATE_TIMESTAMPS=0
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
gosu \
|
|
||||||
libfreetype6-dev \
|
|
||||||
libicu-dev \
|
|
||||||
libjpeg62-turbo-dev \
|
|
||||||
libonig-dev \
|
|
||||||
libpng-dev \
|
|
||||||
libxml2-dev \
|
|
||||||
libzip-dev \
|
|
||||||
unzip \
|
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
|
||||||
&& docker-php-ext-install -j"$(nproc)" \
|
|
||||||
bcmath \
|
|
||||||
exif \
|
|
||||||
gd \
|
|
||||||
intl \
|
|
||||||
mbstring \
|
|
||||||
opcache \
|
|
||||||
pcntl \
|
|
||||||
pdo_mysql \
|
|
||||||
xml \
|
|
||||||
zip \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=composer /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
COPY --from=frontend /var/www/html/public/build ./public/build
|
|
||||||
|
|
||||||
RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist \
|
|
||||||
&& mkdir -p \
|
|
||||||
storage/framework/cache/data \
|
|
||||||
storage/framework/sessions \
|
|
||||||
storage/framework/views \
|
|
||||||
storage/logs \
|
|
||||||
bootstrap/cache \
|
|
||||||
&& chown -R www-data:www-data storage bootstrap/cache \
|
|
||||||
&& chmod -R ug+rwX storage bootstrap/cache
|
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/nextchair-entrypoint
|
|
||||||
RUN chmod +x /usr/local/bin/nextchair-entrypoint
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/nextchair-entrypoint"]
|
|
||||||
CMD ["php-fpm", "-F"]
|
|
||||||
|
|
||||||
|
|
||||||
FROM nginx:1.27-alpine AS nginx
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
COPY public ./public
|
|
||||||
COPY --from=frontend /var/www/html/public/build ./public/build
|
|
||||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
RUN mkdir -p /var/www/html/storage/app/public \
|
|
||||||
&& ln -s /var/www/html/storage/app/public /var/www/html/public/storage
|
|
||||||
@@ -1,109 +1,290 @@
|
|||||||
# NextChair
|
# NextChair
|
||||||
|
|
||||||
NextChair is a multi-tenant, white-label booking platform for barbershops.
|
Booking app as an alternative to Booksy
|
||||||
The application is being rebuilt as a conventional Laravel web application
|
|
||||||
using PHP, MySQL, Blade, Tailwind CSS, Alpine.js, and Vite.
|
|
||||||
|
|
||||||
The audited Next.js implementation is preserved in [`legacy-nextjs`](legacy-nextjs)
|
## VPS deployment (multi-business)
|
||||||
as the functional and visual migration reference. The Laravel application has
|
|
||||||
its own deployment-neutral Docker option at the repository root; files inside
|
|
||||||
`legacy-nextjs` are not used by it.
|
|
||||||
|
|
||||||
## Local development
|
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
|
```bash
|
||||||
composer install
|
# STACKS_DIR later on must be this exact absolute path — every stack
|
||||||
cp .env.example .env
|
# shells `docker compose` back out to the HOST's dockerd via a mounted
|
||||||
php artisan key:generate
|
# socket, so the path only resolves to the same thing on both sides if
|
||||||
npm install
|
# it's cloned here and never moved.
|
||||||
npm run build
|
git clone <your-repo-url> /opt/nextchair
|
||||||
php artisan serve
|
cd /opt/nextchair
|
||||||
```
|
```
|
||||||
|
|
||||||
Configure the MySQL connection in `.env` before running migrations. The
|
Generate the secrets you'll paste into the `.env` files in the steps
|
||||||
application resolves a business only from the request hostname through the
|
below. Run these now and keep the output somewhere safe (a password
|
||||||
`business_domains` table; it never guesses a tenant from the first business
|
manager) — you'll need each value again in a moment.
|
||||||
in the database. For local multi-tenant work, map each configured hostname to
|
|
||||||
127.0.0.1 in `/etc/hosts`.
|
|
||||||
|
|
||||||
## Platform and tenant setup
|
|
||||||
|
|
||||||
Migrations create schema only. They do not create a demo business or a
|
|
||||||
business owner. After migrating, open `/install` once and create the platform
|
|
||||||
administrator. That account can sign in at `/manager/sign-in`, create
|
|
||||||
businesses, edit their primary domains, and share the one-time owner setup
|
|
||||||
link generated during provisioning.
|
|
||||||
|
|
||||||
For an existing installation created before this platform/tenant split, the
|
|
||||||
recorded `initialAdminUserId` account is allowed to sign in once and is
|
|
||||||
promoted to the platform administrator. No other business owner can use that
|
|
||||||
upgrade path.
|
|
||||||
|
|
||||||
Business provisioning creates the tenant domain, settings, booking rules,
|
|
||||||
opening hours, system roles, owner staff profile, and seven-day owner
|
|
||||||
invitation in one transaction. The owner chooses a password through that
|
|
||||||
invitation; platform administrators and business owners are separate account
|
|
||||||
types.
|
|
||||||
|
|
||||||
If demo data is useful for local development, opt into the existing seeder
|
|
||||||
explicitly:
|
|
||||||
|
|
||||||
```bash
|
```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
|
```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:
|
### 1. Shared infrastructure — network, MySQL and Caddy
|
||||||
|
|
||||||
```dotenv
|
|
||||||
APP_URL=http://localhost:8085
|
|
||||||
APP_KEY=
|
|
||||||
DB_HOST=db
|
|
||||||
DB_DATABASE=nextchair
|
|
||||||
DB_USERNAME=nextchair
|
|
||||||
DB_PASSWORD=replace-with-a-private-password
|
|
||||||
DB_ROOT_PASSWORD=replace-with-a-private-root-password
|
|
||||||
```
|
|
||||||
|
|
||||||
Then build and start the stack:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose build
|
docker network create next-chair-edge
|
||||||
docker compose up -d
|
|
||||||
docker compose exec app php artisan key:generate
|
cp .env.infra.example stacks/infra/.env
|
||||||
docker compose exec app php artisan migrate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then visit `http://localhost:8085/install` to create the first platform
|
Edit `stacks/infra/.env`:
|
||||||
administrator. Do not run migrations or create tenants from container startup;
|
- `MYSQL_ROOT_PASSWORD` — the first secret you generated above.
|
||||||
the tenant owner is provisioned manually from Manager.
|
- `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).
|
||||||
Migrations are intentionally not run by container startup. To inspect the
|
|
||||||
application logs:
|
|
||||||
|
|
||||||
```bash
|
```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
|
### 2. Build the shared application image
|
||||||
`NEXTCHAIR_HTTP_BIND` and `NEXTCHAIR_HTTP_PORT` in `.env` when needed. MySQL
|
|
||||||
has no published host port and is reachable only as `db:3306` inside Compose.
|
|
||||||
|
|
||||||
## Verification
|
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
|
```bash
|
||||||
php artisan test
|
docker build -t nextchair-app:latest .
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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