Compare commits

..
68 Commits
Author SHA1 Message Date
Stefan Lazic 70cb874ff5 Update proxy.ts
CI / ci (push) Canceled after 0s
2026-10-18 11:40:19 +01:00
Stefan Lazic 21299f0287 gallery changes
CI / ci (push) Canceled after 0s
Hero gallery: the slideshow on Home doesn’t open full screen. Only portfolio images do, as you asked.
Lightbox: it has no pinch-zoom.
Delete: removing an image still has no confirm step, same as before.
New dependency: drag-and-drop uses @dnd-kit, which I added to package.json.
Touch dragging: I couldn’t test it on a real phone. Mouse and keyboard dragging work.
Health test: src/app/api/health/__tests__/route.test.ts passes, but it prints unhandled rejections. Its prisma mock has no notificationDelivery, so getQueueHealth throws. That comes from the earlier “Remaining features” commit and I left it alone. It’s a quick fix if you want it.
2026-10-18 11:23:17 +01:00
Stefan Lazic 96d5533638 Update page.tsx
CI / ci (push) Canceled after 0s
2026-10-18 10:29:36 +01:00
Stefan Lazic 95ff9bd70c Package Updates
CI / ci (push) Canceled after 0s
Changed files: only package.json and package-lock.json in the app and the manager.
Main versions:
Next.js: 16.2.12 (app) and 16.3.4 (manager) → 16.4.2 in both.
React and React DOM: 19.3.1.
Prisma: 7.10.1, including the client and the MariaDB adapter.
Smaller bumps: mariadb 3.5.4, image-size 2.0.4, postcss, zod, lucide-react, react-hook-form, the Radix packages and Playwright.
Verified: typecheck and lint are clean. All 420 app unit tests, 198 manager tests and 345 integration tests on MySQL 8 pass. The Prisma migrations show no drift. Both production builds succeed, and I clicked through the calendar, business settings, reminders and booking screens with no errors from the upgrade.
2026-10-18 10:05:59 +01:00
Stefan Lazic 68a62e02a4 Remaining features
CI / ci (push) Canceled after 0s
Built
No-show flag and block:
Two new numbers under Booking Rules (blank means off).
A symbol appears on the clients list, client page, calendar entries and appointment pages.
Online booking is refused at the block number, and customers are told at the details step.
Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button.
The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile.
Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning.
Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email.
WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting.
Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business.
Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
2026-10-17 23:10:18 +01:00
Stefan Lazic 17eee5b932 Updates 1
CI / ci (push) Canceled after 0s
The session built:

Appointments page: a single list, newest first.
Block Time: edit and delete.
Customer CSV export: with barber notes excluded.
Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs.

It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
2026-10-17 22:29:00 +01:00
Stefan Lazic 9f981173f4 holidays feature
CI / ci (push) Canceled after 0s
The owner page

Where: Manage → Business → Holidays, at /owner/settings/holidays. It needs the same permission as Business Details.
Adding: you enter a name, a start date and an optional end date. Leaving the end empty gives a single day.
Upcoming and past: upcoming holidays are listed by date, with a trash icon to remove each one. Past ones sit in a collapsed “Past” list.
Existing bookings: if appointments are already booked on those dates, you see them and confirm with “Add Anyway”, the same pattern as Opening Hours. Nothing is cancelled automatically.

What it does to booking

Customer calendar: holiday dates are greyed out, for every staff member and every service.
Time slots: no slots are offered on those dates.
Direct bookings: staff and owner bookings and reschedules onto a holiday are refused, with a message naming the holiday. This is deliberate, but if you want owners to be able to override it, that’s a small change.
2026-10-17 12:23:53 +01:00
Stefan Lazic 31c48296d9 In App Notifications
CI / ci (push) Canceled after 0s
Policies section (business-details-form.tsx)

Late Arrival Policy, Booking Terms and Privacy Policy URL are gone. The section now has Cancellation Policy plus the Terms & Privacy name and email dropdowns.
Their database columns stay, so nothing is lost. Saving no longer touches them.

Email and push only

One switch, IN_APP_NOTIFICATIONS_VISIBLE in in-app.ts, is set to false and controls everything below.

Bell icon: gone for customers, staff and owners, including the unread badge.
Notification pages: /notifications, /staff/notifications and /owner/notifications now return 404.

Settings: Settings → Notifications shows only Email and Push toggles.
Testing: I checked all of this in a local test app as the owner.
2026-10-17 11:34:30 +01:00
Stefan Lazic 766a0808ab Terms 2
CI / ci (push) Canceled after 0s
Policies section

New settings: two new dropdowns are under Policies in Manage Business:
Name shown on Terms & Privacy: NextChair, My business name, or NextChair and my business name.
Contact email on Terms & Privacy: NextChair (info@next-chair.co.uk) or My business email.
Fallbacks: both default to NextChair. If you pick the business email but haven’t set one, the form warns you and the pages fall back to the NextChair address.
Where the name appears: the choice changes the name in the header link, footer, “we/us” definition, privacy controller line and intro on both pages.
Business email: it replaces the address in the complaints section and the privacy contact lines.
Database change: this needed a new migration, 20261017120000_add_legal_display_settings. Existing businesses default to NextChair, so nothing changes for them. Run prisma migrate deploy on your server.

Terms and placeholders

Sections 15 (Governing law) and 16 (Contact us) are removed from terms/page.tsx, so the Terms now have 14 sections.
The [LEGAL ENTITY NAME] and [REGISTERED ADDRESS] placeholders are gone, along with the unused governing-law setting. The contact email is now info@next-chair.co.uk in legal-config.ts.

Login page

sign-in-form.tsx now shows “By continuing, you agree to our Terms of Service and Privacy Policy”, with both as links.
2026-10-17 10:38:07 +01:00
Stefan Lazic 05ea83e059 Fixes
CI / ci (push) Canceled after 0s
fixed super admin showing in client list

added a reconnect button to google calendar syncying

fixed ui issue with whatsapp undo link
2026-10-17 09:35:52 +01:00
Stefan Lazic 2ed9c5ecca security cleanup, client import, WhatsApp reminders
CI / ci (push) Canceled after 0s
1. Pre-launch review (no code)
Listed what’s left before going live: testing, backups, monitoring, legal/GDPR, and a dry run on the VPS.
Listed the features a barbershop system usually has and which of them you’re missing.
Researched WhatsApp pricing, which led to the free route below.
2. Security cleanup

This came out of that review.

Deleted: /dev/scheduling-test (a test page that shipped), /api/debug/db-connect-test (a leftover debug route), and two files of unauthenticated booking and appointment actions (11 actions). One of them listed appointments with customer details to anyone.
Audited: all 127 server actions. The only unguarded ones left are the intended public ones: availability lookups, cookie-based booking drafts, and the token-gated one-time installer.
Tidied: removed the four now-dead booking schemas from schemas.ts.
3. Client import from a spreadsheet
Entry points: an upload icon next to “+” on the Clients page, and a link on the Add Client page. It’s owner-only, checked again on the server.
Flow: /staff/clients/import.
Upload a CSV and check the guessed column mapping.
Review the preview, then import in chunks with progress.
A report lists problem rows by spreadsheet row number, with a download.
CSV template: a visible “Download CSV template” button (headers only: First name, Last name, Email, Phone).
Behavior:
Each client gets a sign-in-ready account, the same as adding one by hand.
Nobody is emailed.
Existing emails are left untouched.
Earlier guest bookings under the same email attach automatically.
“Surname, Firstname” names are read the right way round.
People sharing a phone number still import.
Limits: an email is required for every row, CSV only (no .xlsx), up to 5,000 rows per file, and notes aren’t imported.
4. WhatsApp reminders (free, manual route)
Button: a WhatsApp button next to Call and Email on four pages: the owner and barber appointment pages, “Current appointment”, and the client page. It opens WhatsApp with a ready-written reminder, including a “change it” link.
Phone numbers: read as the business’s own country when typed without a country code. This uses the new libphonenumber-js dependency. A number WhatsApp can’t use means no button.
Reminders list: a new /staff/reminders page lists tomorrow’s confirmed bookings in time order.
Each row has a one-tap Send on WhatsApp.
Tapping marks it “Sent 14:46”, with an Undo.
The mark shows on the appointment pages too.
Barbers see their own list; owners see the whole shop with barber names.
Calendar link: “WhatsApp reminders · 3 to send tomorrow” on both calendar pages.
Database: one additive migration, Appointment.whatsappReminderSentAt. Rescheduling clears it, since a reminder for the old time says nothing about the new one.
Audit log: who marked or undid each reminder is recorded.
Tests and verification
	Before today	Now
Unit tests	297	331
Integration tests	273	285
New unit tests: the CSV parser, import row logic, and WhatsApp link and phone handling.
New integration tests: the import service and the reminders service.
Checks: typecheck and lint are clean (the one old branding warning remains).
Browser: I exercised each feature in a real browser against a throwaway MySQL 8, including the permission split between Leo and Marcus.
2026-10-15 21:25:09 +01:00
Stefan Lazic 04d6192d51 Update day-calendar.css
CI / ci (push) Canceled after 0s
Fixed. On a phone, only the calendar scrolls sideways now, and the rest of the page stays put. I checked it on a phone-sized screen and on desktop. The change is uncommitted, in day-calendar.css.

On a 375px screen the page measured 822px wide. After the fix it measures 375px. The cause was my own style. I'd told the calendar grid not to scroll vertically, so the page could own that scrollbar. That also switched off the grid's sideways scrolling, and the grid pushed the whole page wider instead. The grid now scrolls sideways inside itself and doesn't scroll vertically.

I checked a real swipe on the phone-sized screen:

Header and hour labels: the barber names move with their columns, and the hour labels stay pinned on the left.
Top bar: the "Calendar / NEW" bar doesn't move.
Desktop: nothing changed. The page isn't wider than the screen, and the grid has no inner scrollbars.
Tooling: typecheck and lint pass. This was a CSS-only change, so I didn't rerun the unit tests.
2026-10-08 11:20:12 +01:00
Stefan Lazic a8a8d1e045 Updated Calendard
CI / ci (push) Canceled after 0s
The calendar is now react-big-calendar.
2026-10-08 11:01:32 +01:00
Stefan Lazic 8c267eff25 desktop layout
CI / ci (push) Canceled after 0s
The shell around every page sets no content width, so each page chose its own. Business Details was centered, Services and Team were pinned left with a dead right half, and Reports, the dashboard, Current Appointment and Settings stretched edge to edge. I added a shared PageContainer with three widths (narrow, default, wide), always centered, and used it on those pages and on the settings pages.

Two form layouts had their own bugs:

Business Details (your screenshot): six cards in a two-column grid, so a short card next to a tall one left a big gap. It's now two independent columns of similar height.
Loyalty: "Reward Rules" and "Rules & Expiry" each sat alone in the left column with an empty right side. They're now side by side.
On phones, Business Details now stacks its cards in a slightly different order (Branding moves down). Tablets get a slightly wider column than before.

Two things I didn't touch:

Customer pages: Home and Book are a phone-width column floating in the middle of a desktop screen, with the bottom tab bar still showing. That's a bigger design decision, so tell me if you want it.
Narrow pages: about 30 pages already used a centered narrow column, which looks fine.
2026-10-08 10:37:44 +01:00
Stefan Lazic a530d00aa2 Typecheck
CI / ci (push) Canceled after 0s
2026-10-07 16:54:08 +01:00
Stefan Lazic 1442d33d55 Fixes
CI / ci (push) Canceled after 0s
Guest merge

Guest history now joins the account when the email is proven, in three places: OTP sign-in, Google sign-in, and staff adding a client. Unverified emails are never matched.

What moves: appointments, notes, push subscriptions and notifications go onto the account's profile, and the guest profile is deleted.
One email, several guests: if the email has several guest profiles (one per phone number), all are merged.
No profile yet: the oldest guest profile becomes the account's profile.
Account wins: it keeps its own name and phone, and only gaps get filled.
Stamps: summed, with the ledger entries moved across. If the combined total reaches the threshold, the reward is earned and the customer is notified.
Safety: it runs in one transaction and is audit-logged. A failure never blocks sign-in; the next sign-in retries.

Reminders
Staff: one reminder, 2h before; the 24h one is gone. Push and in-app are on by default and email is opt-in. Defaults are now per channel, and the settings page shows email off, push and in-app on.
Customer: one reminder at 24h. If booked too late for that, they get one at 2h instead, and none if the appointment is under 2h away.
In-app reminders: held back and released by the worker at the reminder time, so they no longer appear the moment you book. Cancelling or rescheduling clears them, and "mark all read" no longer marks unseen ones.
Guest links: every guest email now carries a working link. The booking-time token is unchanged, and later emails use one derived from the appointment id and AUTH_SECRET. Revoking a guest appointment's access kills both.
Reward expiry: two emails per reward at most, at 7 days and 1 day before.
Stale guard: the worker drops a reminder, without counting a failure, if the appointment has started or isn't confirmed.

OTP rate-limit tests (2): the failures came from my scratch environment, not the app. I had E2E_TEST_MODE=true in the scratch .env, which turns off the OTP rate limits, so the tests had nothing to assert on. I'd also used MariaDB, which throws error 1020 on concurrent upserts, while your real stack runs mysql:8.0. On MySQL 8 without the flag they pass every time. I also pinned that flag off in the integration config, so a .env with it set can't break them again. I tested that by turning it on in .env, and the OTP tests still passed.
Authorization and nav-role suites (2): this was a real bug. Both files import @/lib/auth/permissions, which builds the database connection when it's imported, before the test fixtures load .env. The suites crashed before running a single test, so the owner-permission checks (barbers can't do owner actions, nothing leaks across businesses) hadn't been running at all. I now load dotenv once in the integration config, so no test depends on import order. They run and pass (13 tests).
2026-10-07 16:39:12 +01:00
Stefan Lazic 7338c9d9e2 Update page.tsx
CI / ci (push) Canceled after 0s
2026-09-18 21:07:37 +01:00
Stefan Lazic 410aa07596 add client 2
CI / ci (push) Canceled after 0s
now auto shows after being added.
2026-09-14 13:17:07 +01:00
Stefan Lazic 0d821ce1f8 add client
CI / ci (push) Canceled after 0s
adds a button to add a new client from the barber menu
2026-09-14 12:25:45 +01:00
Stefan Lazic 91f3fd77d7 Update install-wizard.tsx
CI / ci (push) Canceled after 0s
2026-09-13 19:59:42 +01:00
Stefan Lazic 4573587b78 Email Notifictions
CI / ci (push) Canceled after 0s
Core logic: types.ts (defaults), notification.service.ts (skipEmail + dedup), booking-notifications.ts, loyalty-notifications.ts, reminder-scheduling.ts, booking.service.ts + scheduling.service.ts (staff-selection capture), features/owner/actions/appointments.ts.
Schema: prisma/schema.prisma + new migration 20260912165903_add_staff_selected_by_customer.
UI: both notification-preferences pages (customer, staff) now use the same default logic as the actual send decision.
Tests: notification.service.integration.test.ts, booking-notifications.integration.test.ts, reminder-scheduling.integration.test.ts, loyalty-notifications.integration.test.ts, plus a new createTestAppointment fixture.
Docs: docs/notifications.md rewritten to match.

Default changes

Exactly as specified — full table is in docs/notifications.md. In short: STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, STAFF_APPOINTMENT_REMINDER, OWNER_NEW_BOOKING, OWNER_NO_SHOW now default off; everything else that was already toggleable is untouched.

No longer email (fixed, not a toggle)

BOOKING_COMPLETED, LOYALTY_STAMP_ADDED, LOYALTY_REWARD_REDEEMED — always. BOOKING_BARBER_CHANGED — only when the customer originally booked "any available barber." The customer's 2-hour reminder is removed entirely (24h stays); staff keeps both, just off by default. All via a new skipEmail flag on notify(), checked in one place — no new preference rows for these.

Deduplication

One new EQUIVALENCE_GROUPS map in notification.service.ts (STAFF_NEW_APPOINTMENT>OWNER_NEW_BOOKING, STAFF_APPOINTMENT_CANCELLED>OWNER_BOOKING_CANCELLED). Inside notify(), before creating deliveries, it checks for an already-created equivalent notification for the same (businessId, appointmentId, recipientUserId); if found and at-least-as-relevant, the new one gets zero deliveries on every channel (row still created for audit trail). Only listed types participate, so OWNER_NO_SHOW and any future actionable owner type are never touched.
2026-09-12 18:27:11 +01:00
Stefan Lazic 7224c87cef Header Update
CI / ci (push) Canceled after 0s
Updated the header of these pages to match the rest of the pages.
2026-09-12 17:24:47 +01:00
Stefan Lazic 1932cf7774 Update page.tsx
CI / ci (push) Canceled after 0s
2026-09-12 15:49:28 +01:00
Stefan Lazic de32175916 Google Calendar
CI / ci (push) Canceled after 0s
Made google calendar auth not business specific.

global-settings.ts — new GoogleCalendarSettings section, encrypted at rest the same way as the SMTP password/VAPID private key.

google-calendar-rollout.ts — pushes the shared client ID/secret into every business's .env and recreates its container. GOOGLE_CALENDAR_REDIRECT_URI is left alone (it must keep tracking each business's own domain).

provisioning.ts / compose-template.ts — new businesses now pick up the global client automatically at creation, same as Email/Push already do.

Settings UI — new "Google Calendar" card at the bottom of Manager's Settings page (google-calendar-settings-form.tsx), with Save + "Reapply to all businesses."
2026-09-12 15:09:48 +01:00
Stefan Lazic 65e141d527 Update page.tsx
CI / ci (push) Canceled after 0s
2026-09-12 11:35:09 +01:00
Stefan Lazic 0e39ca7cdc Owner appointment details
CI / ci (push) Canceled after 0s
same changes applied to this route
2026-09-12 10:52:48 +01:00
Stefan Lazic b1fce9c002 Appointment details layout 2
CI / ci (push) Canceled after 0s
"Add to Calendar" removed from the staff appointment details page — it never showed for owner/customer views anyway, only here.
Buttons now stack in one column, in exactly the order you asked: Check In → Reschedule → Mark No-Show → Cancel. Reschedule moved from a separate top row into appointment-status-actions.tsx itself (new rescheduleUrl prop) so it can sit in the right spot relative to the status-specific buttons — this also means it correctly disappears for statuses that were never reschedulable (e.g. once In Progress), same as before.
Add Loyalty Stamp is now an icon-only button (add-stamp-button.tsx gained an iconOnly prop) sitting directly opposite "X/Y stamps" inside the card — same click-to-open-modal behavior, just compact.
2026-09-12 10:21:29 +01:00
Stefan Lazic f7fad3d1a0 Appointment details layout
CI / ci (push) Canceled after 0s
fixed button layout on appointment details page.

added an explainer for all the notifications
2026-09-12 10:01:55 +01:00
Stefan Lazic f24d22fdf1 Notification Settings
CI / ci (push) Canceled after 0s
src/services/notifications/types.ts — every STAFF_*/OWNER_* type is now preference-controlled (customer-facing types are untouched except the existing BOOKING_REMINDER, per your scope call).
src/services/notifications/notification.service.ts — IN_APP now respects the same preference check as EMAIL/PUSH. For every type outside the controlled set (the vast majority) this changes nothing — isChannelEnabled still short-circuits to true.
notification-preferences-form.tsx — generalized from one hardcoded toggle to a real per-type grid (groups prop), each row with Email/Push/In-app columns.
Staff settings → Notifications (page.tsx) — now shows 6 staff-facing toggle groups always, plus 5 owner-facing ones only for whoever actually holds VIEW_REPORTING (the same permission that defines "owner" elsewhere in the app) — so a plain barber never sees owner-only switches that could never apply to them.
Customer settings → Notifications — unchanged behavior, just updated to the new shared component's shape.
2026-09-12 09:41:37 +01:00
Stefan Lazic bfa13c5f15 Update README.md
CI / ci (push) Canceled after 0s
Fix notification-delivery cron docs: cron may not be installed, /var/log/ needs root
2026-09-11 18:16:17 +01:00
Stefan Lazic 1a76d1cacb Notifications
CI / ci (push) Canceled after 0s
Added a cron job processor so emails and notifications can get processed.

Added super admin settings with a gui to edit.
2026-09-11 17:46:28 +01:00
Stefan Lazic cbeccc6c1f Auth Fix
CI / ci (push) Canceled after 0s
Fixed an issue where the user would be redirected to a localhost address because an auth url was not set in the env.
2026-09-06 21:01:54 +01:00
Stefan Lazic e9e28a243f Installation
CI / ci (push) Canceled after 0s
install token now shown in dashboard until install completes.
2026-09-06 20:14:25 +01:00
Stefan Lazic d7a8b15482 Demo business removal
CI / ci (push) Canceled after 0s
Removed the demo business as its easier to just provision one manually.
2026-09-06 19:01:41 +01:00
Stefan Lazic 217c0b0101 Demo Business
CI / ci (push) Canceled after 0s
Updated references to a demo business.
2026-09-06 18:53:02 +01:00
Stefan Lazic 4763b50e62 Dockge Removal
CI / ci (push) Canceled after 0s
Removed dockge from the app as it is not required due to the manager page picking up most of its abilities.
2026-09-06 18:33:01 +01:00
Stefan Lazic 973cb9efc6 Update README.md
CI / ci (push) Canceled after 0s
2026-09-06 15:54:44 +01:00
Stefan Lazic 479cefd085 Update README.md
CI / ci (push) Canceled after 0s
2026-09-06 13:02:20 +01:00
Stefan Lazic 106f89a6da VAPID Settings
CI / ci (push) Canceled after 0s
1. Summary of changes

The request assumed a push-notification subsystem needed to be built — it already existed in full: the PushSubscription model, subscribe/unsubscribe service and Server Actions, the PushSubscribeToggle opt-in UI, WebPushProvider, and public/sw.js's push/notificationclick handlers were all already implemented and tested. The one real gap: NEXT_PUBLIC_VAPID_PUBLIC_KEY is a Next.js build-time-inlined variable, but every business runs the same prebuilt shared image — so on the actual deployment architecture, every business's browser bundle has always shipped with this key baked in empty, regardless of that business's .env. This was a genuine pre-existing bug, and centralizing VAPID globally is the fix for it, not just a tidiness improvement.

Added a Push Notifications section to the Manager's Settings page, mirroring the SMTP architecture exactly: same JSON storage, same MANAGER_SETTINGS_ENCRYPTION_KEY encryption, same rollout mechanism.
Keys are generate-only (web-push.generateVAPIDKeys()) — no hand-pasted key fields, avoiding a mismatched pair that would silently break every push send.
Regeneration requires explicit confirmation with a clear warning about the impact, shown live and verified in-browser.
Replaced the broken NEXT_PUBLIC_VAPID_PUBLIC_KEY mechanism with a new runtime API route, GET /api/push/vapid-public-key — this is what actually makes centralized VAPID config reach the browser, verified live end-to-end.
Fixed a related bug in web-push-provider.ts: a 401/403 (VAPID signature mismatch — exactly what happens to every subscription after a key rotation) wasn't being treated as an invalid subscription, so rotated-out subscriptions would have failed forever without ever being cleaned up.
Two additional real bugs found and fixed during live verification (not just written correctly on paper): the settings forms weren't refreshing their server-fetched data after a successful save, so the Public/Private Key fields and the "Reapply" button stayed stale-looking immediately after a successful Generate.
No Prisma/schema changes — the existing model already satisfied every field in the request.
2. Architecture used for sharing VAPID configuration

Identical to SMTP: Manager is the one place a human ever generates the key pair; it's stored in stacks/manager/global-settings.json, then pushed into every business's .env (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, PUSH_PROVIDER="web-push") via the same upsertEnvValues + composeUp(dir, {forceRecreate:true}) mechanism, verified live to actually rewrite a scratch business's .env and recreate its container. New businesses get it baked in at provisioning time automatically.

3. How and where the VAPID private key is stored

AES-256-GCM encrypted (same key and mechanism as the SMTP password) inside stacks/manager/global-settings.json. Verified directly: the file contains an {iv, authTag, ciphertext} envelope, never plaintext. Never returned to the Manager UI after generation — only a boolean hasPrivateKey.

4. Prisma/database changes

None. The existing PushSubscription model already covers every field requested.

5. Service worker/PWA changes

None to the service worker itself — public/sw.js's push/notificationclick handlers already worked correctly. The only client change is push-subscribe-toggle.tsx, which now fetches the public key from the new API route instead of a dead build-time constant, and gained a new "unconfigured" state shown up front (before a wasted permission prompt) when push genuinely isn't configured.

6. How tenant push subscriptions are stored

Unchanged, and already correct: each business has its own separate database (confirmed throughout this session's work), so tenant isolation is enforced at the infrastructure level, not just a businessId column — there's no query path from one business's container into another's subscriptions at all.

7. How multiple devices are handled

Unchanged — already correct and already tested (endpoint unique, upsert-on-resubscribe, per-user userId/customerId fan-out in reminder-worker.ts).

8. What happens when a subscription becomes invalid

Unchanged for 404/410. Newly fixed: 401/403 (VAPID mismatch, e.g. after a key rotation) is now also treated as subscriptionInvalid — the stale subscription gets revoked automatically on the next delivery attempt rather than failing silently forever.

9. What happens if VAPID keys are regenerated

Documented in the Manager UI's own confirmation warning (verified live): every existing browser subscription across every business stops receiving pushes, and each device needs to click "Enable Push Notifications" again — but nothing needs to be manually cleaned up, since the app now detects and revokes the broken subscriptions automatically (see #8).

10. Files changed

New: manager/src/lib/push-rollout.ts, manager/src/app/dashboard/settings/push-settings-form.tsx, src/app/api/push/vapid-public-key/route.ts + its test.

Modified: manager/src/lib/global-settings.ts (push section + a fix ensuring email/push sections never clobber each other), manager/src/lib/provisioning.ts, manager/src/lib/compose-template.ts, manager/src/app/dashboard/settings/{actions.ts,page.tsx,email-settings-form.tsx} (the last one gained the same router.refresh() fix), push-subscribe-toggle.tsx, web-push-provider.ts + its test, .env.example, .env.business.example, manager/package.json/package-lock.json (added web-push).

11. New environment variables

None new. MANAGER_SETTINGS_ENCRYPTION_KEY already exists from the SMTP work and is reused. NEXT_PUBLIC_VAPID_PUBLIC_KEY is removed (dead — superseded by the API route).

12. VPS deployment/update steps

Rebuild/redeploy both images as usual (no build-arg needed, unlike a naive fix would have required). Open Manager → Settings → Push Notifications, set a contact email, click "Generate VAPID Keys" — this automatically rolls out to every existing business. Nothing else needs to run by hand.

13. Manual testing steps
EMAIL_PROVIDER=console PUSH_PROVIDER=console npm run dev

Generate keys in Manager, confirm the overwrite-warning on a second attempt, confirm a business's .env gains the 4 keys. Hit curl localhost:PORT/api/push/vapid-public-key — confirmed live to return exactly the configured public key. Sign in to a business and visit Settings → Notifications — confirmed live the toggle correctly reaches the real permission-check step (proving the key delivery works) rather than showing "not configured."

The global VAPID / Web Push feature is already fully designed, implemented, and verified — all three research agents finished, I synthesized their findings, wrote the plan, got it approved, built it, found and fixed three real bugs along the way (the broken build-time public-key delivery, the missing 401/403 invalidation handling, and the stale-form-data-after-save issue in both settings forms), and delivered the full report above. That work is done.
2026-09-06 12:17:10 +01:00
Stefan Lazic 2d4f9c5bf0 Email Settings
CI / ci (push) Canceled after 0s
1. Architecture chosen
Manager pushes environment variables into every business's .env, rather than businesses fetching config at runtime. Manager has no database of its own today (confirmed via code — no Prisma, no ORM dependency anywhere in manager/); every piece of its state is either an env var or a flat file under STACKS_DIR. A "runtime shared-config source" design would make every single email send (including OTP login) depend on Manager being reachable — a new failure mode this app has never had, for no real benefit. Instead:

Storage: one JSON file, stacks/manager/global-settings.json (gitignored), shaped for future sections (SMS, storage, backups, branding...) to slot in as sibling keys.
Secret at rest: the SMTP password is AES-256-GCM encrypted with a new MANAGER_SETTINGS_ENCRYPTION_KEY, never returned to the browser.
Rollout: saving complete settings automatically pushes the 7 EMAIL_* keys into every business's .env via the exact same surgical upsertEnvValues helper updateBusiness() already uses for name/domain edits, then force-recreates each container (composeUp(dir, {forceRecreate:true})) — the identical mechanism that already applies domain changes today.
New businesses get the current global settings baked into their .env at provisioning time — no manual step, ever.

2. Files changed
New (Manager): global-settings.ts (storage+encryption), email-rollout.ts (push-to-all-businesses), test-email.ts (standalone nodemailer test-send), dashboard/settings/ (page.tsx, actions.ts, email-settings-form.tsx).

Modified (Manager): compose-template.ts (optional email block in generated .env), provisioning.ts (reads global settings at creation), dashboard/layout.tsx (Settings nav link), package.json/package-lock.json (added nodemailer).

Modified (main app): brevo-email-provider.ts (added encryption mode + replyTo, backward-compatible), its test file (8 new tests).

Modified (docs): root .gitignore, .env.example, .env.business.example, manager/.env.example.

3. New volumes/databases/config files
None — no new Docker volume, no database. One new gitignored file, stacks/manager/global-settings.json, living inside the STACKS_DIR bind-mount Manager already has.

4. New environment variables
MANAGER_SETTINGS_ENCRYPTION_KEY (Manager only, required to use Settings) — 32-byte hex, generate with openssl rand -hex 32.
EMAIL_SMTP_ENCRYPTION and EMAIL_REPLY_TO (main app, both optional) — unset EMAIL_SMTP_ENCRYPTION preserves today's exact port-based inference, so existing deployments are unaffected.
Reply-to is now business-specific with global fallback,

5. How settings are persisted
stacks/manager/global-settings.json, written by saveEmailSettings(). The password is encrypted before writing; every other field is plain text (host, port, username, from-address, from-name, reply-to, encryption mode, last-test result) since none of those are secrets.

6. How business containers receive global settings
Via their own .env file (Docker Compose's env_file:, unchanged mechanism) — Manager writes the 7 EMAIL_* keys into it and recreates the container. Nothing in the business app itself needed a new runtime dependency; getEmailProvider() still just reads process.env once at process start, exactly as before.

7. What happens to existing businesses when settings change
Saving complete settings (host+username+password+from-address all present) automatically loops every business, updates its .env, and force-recreates its container in one action — the UI reports "applied to N of M businesses" with per-business failure reasons. A separate "Reapply to all businesses" button retries this without re-entering anything, for recovering from a business that was down during the last save. Saving incomplete settings persists them as a draft and touches no business (verified live — this is what stops a business from being flipped into a broken "brevo with blank credentials" state).

8. Manual deployment/update steps on the VPS
Generate a key: openssl rand -hex 32, add it as MANAGER_SETTINGS_ENCRYPTION_KEY to stacks/manager/.env.
Rebuild/redeploy the Manager image and the main app image (both changed) as usual.
Open Manager → Settings, fill in real SMTP details, send a test email, then Save — this rolls out to every existing business automatically. Nothing else needs to run by hand.

9. Security considerations
Password never logged, never returned to the browser after save (only a hasPassword boolean) — confirmed live: the form re-shows a masked placeholder, not the real value.
Test-email errors are categorized (auth/connection/timeout) before display — the raw SMTP error is never forwarded.
Encrypted at rest with AES-256-GCM; losing/rotating the key makes the stored password undecryptable (documented in manager/.env.example).
Settings routes sit under manager/src/proxy.ts's existing auth gate — no new auth surface.
Validated before rollout: malformed addresses/ports are rejected before Save, and rollout only fires when configuration is actually complete.

On JSON vs. database for Manager: JSON is the right call here. Reasoning:

Manager has zero DB dependencies today, deliberately — its own package.json description says an earlier attempt at giving it business-data access "repeatedly broke." Introducing SQLite/Prisma just for one settings blob breaks that design principle for not much gain.

The access pattern is a poor fit for a DB anyway: read on page-load/provisioning/rollout, written by a human clicking Save occasionally. No queries, no joins, no need for transactions beyond "read-modify-write one small file."

Reconsider if: multiple admins need to edit concurrently with conflict detection, you want an audit history of who-changed-what-when beyond "last test result," or the settings graph grows relational (e.g., per-business overrides tied by foreign key to businesses) rather than a flat global blob. None of that applies yet.
2026-09-06 10:38:21 +01:00
Stefan Lazic 973187dcc7 Multi-administrator support
CI / ci (push) Canceled after 0s
1. Summary of changes
The installer-created account is no longer special. "Owner" is now literally renamed Administrator and behaves exactly like the existing role/permission system already allowed — the codebase turned out to already define "administrator" structurally (a StaffProfile whose role holds MANAGE_STAFF), not as a name or a business.ownerId-style field, and last-administrator protection already existed. The real work was: closing the gap between that and a full admin-management UI, fixing two OTP-migration bugs the new spec's requirements exposed, and making the existing protection transactional.

Businesses can now have any number of Administrators, added via Invite Administrator (name + email → OTP-verified before access), promoted/demoted via the existing staff role dropdown (now intercepted with a confirmation dialog when it crosses the Administrator boundary), or removed via the existing deactivate flow.
Two real bugs found and fixed, both required for the spec's "unverified email must not get access":
Invitation acceptance (/invite/accept) never actually verified an OTP — it silently relied on a sign-in path that would have failed for every real invitee. It now sends and verifies a real code, reusing the shared OtpCodeStep UI.
A newly-invited (not-yet-accepted) account could sign in via plain email OTP before ever touching the invite link, because sign-in only checked "does a User row exist," not whether it was verified. Fixed in the shared OTP service, benefiting every invitation, not just administrators'.
Last-administrator protection (already existed) is now transactional — a SELECT ... FOR UPDATE lock on the business row closes the race where two concurrent removals of two different administrators could both succeed.
Administrators (any staff member, in fact) can now change their own email — new address must be OTP-verified before it replaces the login email.
No hidden installer privilege: confirmed via code audit and a live install → the account is a plain Administrator-role staff member, provable identically via the Team UI as any later-added one.
NextChair Manager and platform Super Administrator are untouched.
2. Prisma/database changes
No schema changes. One data-only migration: prisma/migrations/20260905214944_administrator_role_rename — UPDATE Role SET name = 'Administrator' WHERE name = 'Owner' AND isSystem = true. Runs through the normal migration-deploy process; not something to run by hand.

3. How Administrator roles are represented
A per-business Role row named "Administrator" (was "Owner"), isSystem: true, holding every Permission — created identically at install and in dev seed data. This is what "Invite"/"Make Administrator" assign.
Whether a StaffProfile currently counts as an Administrator is permission-based (holds MANAGE_STAFF), not a name check — the same definition the codebase already used for last-owner protection (isStaffAdministrator in staff.service.ts). This is deliberately reused rather than introduced, and stays correct if a business ever gets a custom role that also grants MANAGE_STAFF.
No Business.adminId-style field anywhere — confirmed none existed before this work either.
4. How administrator invitations work
Reuses the existing StaffInvitation model and createStaffProfile/createInvitation machinery verbatim — a new inviteAdministratorAction just pre-selects the Administrator role and uses a minimal Name+Email form (invite-administrator-form.tsx). Acceptance (accept-invitation.ts) now requires a real OTP code sent to the invited email before the account activates — expiry (7 days), resend, and revoke are all the pre-existing invitation mechanics (revoke = the existing deactivate action, relabeled "Revoke Invitation" while pending).

5. How last-administrator protection is enforced
Server-side only, inside staff.service.ts's updateStaffAsOwner (role change) and deactivateStaff (remove), each wrapped in prisma.$transaction that first locks the business row (SELECT ... FOR UPDATE), then counts other active Administrators before allowing the change. Verified with a genuine concurrency test (two simultaneous removals against a 2-admin business) and manually in-browser (see below).

6. Installation flow changes
Only the role name and one line of wizard copy changed ("This becomes the first Administrator..."). No structural UX change — still collects name/email, verifies via OTP, creates/activates the account, assigns the role, exactly as before.

7. Files changed
New: the migration; email-change.service.ts + change-email.ts action + change-email-section.tsx; invite-administrator-form.tsx + add-administrator/page.tsx; 3 new integration test files.

Modified — core: staff.service.ts (transactional protection, isStaffAdministrator), staff-invitation.service.ts, otp.service.ts (onlyIfUserExists fix, email-change purpose), default-permissions.ts, permissions.ts, auth.ts (JWT email-refresh, simplified finalize path).

Modified — actions/UI: owner/actions/staff.ts, staff-detail-panel.tsx, team/page.tsx, add-staff-form.tsx, accept-invitation.ts/form/page, otp-code-step.tsx, profile-form.tsx, installer files, seed.ts, plus a terminology sweep ("owner" → "administrator" copy in breaks.ts/time-off.ts/staff-emails.ts/profile page).

8. Manual testing steps
Sign in as the seeded owner → /owner/team shows an Administrator badge and an "Invite Administrator" icon next to "+". Invite someone, accept via /invite/accept?token=... printed to the terminal (code also prints there) — they land signed in with full Manage access. Promote/demote any staff member via the role dropdown on their detail page — crossing the Administrator boundary shows a confirmation dialog. With only one Administrator left, attempting to demote or deactivate them shows "You can't remove the only administrator. Add another administrator first." Change your own email from Profile settings — the new address must be OTP-verified before it replaces the old one.

9. Deployment steps on the VPS
None beyond the normal deploy process — the migration is a standard Prisma migration that prisma migrate deploy (whatever already runs it there) will pick up automatically. No new environment variables. No changes to NextChair Manager or its authentication.

Verification performed: tsc/lint/unit — all clean; full test:integration — 219 passed (up from 207, all new); full test:e2e — same 36/37 as before this work (the 1 failure is a pre-existing, auth-unrelated flake unrelated to any of this); a full live manual pass covering every scenario above, including the last-administrator block and the email-change JWT-refresh behavior.
2026-09-05 23:32:54 +01:00
Stefan Lazic 94aa1b2f26 OPT and Improved Installation Wizzard
CI / ci (push) Canceled after 0s
1. Summary of changes
Password auth → email OTP, everywhere it existed for the tenant/business app: sign-in, customer self-registration (/register), staff-invitation acceptance, and the installer's administrator step. Flow is always: email → 6-digit code emailed → code entered → session created, via one shared service (otp.service.ts) reused by every entry point.
NextChair Manager's own auth was not touched — this only affects tenant-side (business admin/staff/customer) authentication, per the spec's explicit instruction.
Installer improvements: Step 2 (Administrator) now asks only Name + Email + optional Phone, verified inline via the same OTP widget (no redirect). Step 3 (Business Details) pre-fills Business Name from the Manager-provisioned BUSINESS_NAME, shows the booking URL (NEXT_PUBLIC_APP_URL) as a read-only info box instead of asking for it, and has a distinctly-labeled "Business Website (optional)" field with copy clarifying it's not the booking URL.
Installation cannot complete with an unverified admin email — enforced server-side (finalizeOtp runs inside the same DB transaction as user/business creation), not just trusted from client state. A mid-wizard refresh still can't create duplicate admins or partial records — that property was preserved by design, not incidentally.
2. Prisma/database changes
New EmailOtp model (email as PK — one row per address, so a new request naturally invalidates the previous code): codeHash, purpose, expiresAt, attempts, verifiedAt, consumedAt, timestamps. Only a bcrypt hash of the code is ever stored.
User.passwordHash — unchanged schema (already nullable), just annotated as deprecated. No destructive migration; historical rows are untouched.
Migration: prisma/migrations/20260905190022_email_otp_authentication — adds EmailOtp only. Generated and applied against throwaway containers only; the real next-chair-mysql production database was never touched (confirmed not even running on this host during this session).
3. New environment variables
None required for production. One new variable, E2E_TEST_MODE, exists purely for the Playwright suite (set only in playwright.config.ts's webServer.env) — it unlocks a /api/test/otp-code debug endpoint and bypasses OTP rate limiting so the same seeded users can sign in dozens of times across one test run. It must never be set on a real deployment; nothing in the app sets it anywhere else.

4. Email delivery configuration
Nothing new to configure. OTP emails reuse the existing EmailProvider abstraction and EMAIL_PROVIDER env var exactly as configured today — console in dev (prints to server logs), brevo in production via the same SMTP credentials already documented. The OTP email is a new template (otp-email.ts) sent directly (bypassing the async notification/cron pipeline, since a 10-minute-expiry code can't wait for a delivery retry loop).

5. Migration considerations for existing users/installations
Existing users lose password sign-in — the only way to authenticate now is email OTP. There is no in-app fallback; this is an intentional, sharp cutover. If existing customers/staff/admins need advance notice, that's a business/communications decision outside this change.
No account data is deleted or migrated — passwordHash rows are simply never read for auth anymore.
prisma/seed.ts / scripts/ensure-super-admin.ts still set a passwordHash for seeded dev users — harmless and vestigial now, not cleaned up (flagging it rather than touching seed data silently).
Installations already completed are unaffected; only new installer runs go through the OTP admin-verification step.
6. Files changed
New:

src/lib/auth/otp.service.ts — the core requestOtp/verifyOtp/finalizeOtp service
src/lib/auth/__tests__/otp.service.integration.test.ts
src/services/notifications/templates/otp-email.ts
src/features/auth/components/otp-code-step.tsx — shared code-entry UI
src/features/auth/actions/request-sign-in-otp.ts
src/app/api/test/otp-code/route.ts — e2e-only, gated on E2E_TEST_MODE
prisma/migrations/20260905190022_email_otp_authentication/
Modified — auth core: prisma/schema.prisma, src/auth.ts, src/features/auth/schemas.ts

Modified — sign-in/register/invitations: sign-in-form.tsx, register.ts, register-form.tsx, accept-invitation.ts, accept-invitation-form.tsx, staff-invitation.service.ts

Modified — installer: installer.service.ts, schema.ts, install.ts, install-wizard.tsx, install/page.tsx, installer.service.integration.test.ts

Modified — email / e2e infra: console-email-provider.ts, playwright.config.ts, e2e/helpers.ts, and the four e2e/*.spec.ts files (dropped the now-unused password argument from every signIn() call).

7. Verification performed
npx tsc --noEmit, npm run lint, npm test (276 unit) — all clean.
npm run test:integration (207 tests, throwaway DB) — includes 11 new OTP-service tests and a rewritten installer suite; a genuine race condition in requestOtp (concurrent requests for a brand-new email double-hitting the upsert's create branch) was caught and fixed here.
Full npm run test:e2e against a fresh throwaway database: 36/37 passed. The one failure (owner-journeys "opening hours conflict" test) is a pre-existing, auth-unrelated ordering flake — appointment-count ambiguity from earlier tests' bookings — not something this change touches or introduced.
Live manual verification in-browser: existing seeded customer sign-in via OTP; new customer registration via OTP (session created, landed on Home); installer wizard end-to-end through Step 3, confirming email-verification gating, Business Name pre-population from BUSINESS_NAME, and the read-only Booking URL / distinct Business Website fields.
All throwaway Docker containers/DBs used for this work have been removed; the real production database was never touched.
2026-09-05 21:03:36 +01:00
Stefan Lazic ba46334179 Delete Business
CI / ci (push) Canceled after 0s
A Delete button now sits next to Edit on each business's detail page, styled in red to distinguish it from the safe actions.
It opens a dedicated confirmation page (matching the Add/Edit page pattern) that clearly lists exactly what gets destroyed — container + media volume, the database by name, the Caddy site/domain, and the stack directory — and requires typing the business's exact slug before the Delete button even becomes clickable. This is a stronger guard than a plain "are you sure?" popup, given what we just learned about how easy it is to lose data here.
deleteBusiness() in provisioning.ts reuses the same primitives as create/edit (composeDown, reloadCaddy, the shared MySQL root connection), and is deliberately best-effort past the first step — if a piece is already gone (matching exactly the kind of half-broken state we just fixed for blake-heritage), it still finishes removing the rest and reports what it couldn't clean up automatically, rather than getting stuck.
Verified live: provisioned a real test business, confirmed its container/database/user/volume/Caddy site all existed, deleted it, and confirmed every single one was actually gone — including checking Caddy's running config (not just the file on disk) to prove the reload took effect. Also confirmed the confirm-button correctly stays disabled for a wrong slug and only enables on an exact match. Your real production database was never touched throughout.
2026-09-05 17:41:38 +01:00
Stefan Lazic 2e739c9a26 Update provisioning.ts
CI / ci (push) Canceled after 0s
2026-09-05 17:14:39 +01:00
Stefan Lazic 3b07124d7e Edit Business flow
CI / ci (push) Canceled after 0s
New files:

manager/src/lib/env-file.ts — a shared, surgical .env reader/updater. It only ever touches the specific keys you tell it to (never regenerates the file), so anything hand-edited into a business's .env since provisioning is preserved exactly.
manager/src/app/dashboard/[slug]/edit/ — the edit page, its form component, and its server action.

Modified:

provisioning.ts gained updateBusiness() alongside the existing provisionBusiness(), reusing the same Caddy-site-writing and container-recreation logic (refactored into a shared writeCaddySiteAndReload helper).
stacks.ts/compose-template.ts — extended to track a BUSINESS_NAME field (previously collected on the Add Business form but silently discarded — that gap is now fixed too, so newly created businesses get their name persisted from the start).
The detail page and dashboard list now show the business name, and the detail page has an Edit button.

Per the requirements:

Slug is read-only in the edit form (disabled input, never accepted by the server action — it's bound in from the route, not form data).
Domain uniqueness is validated against every other business (and correctly self-excludes the business being edited).
A domain change rewrites the Caddy site file, reloads Caddy, and force-recreates the container; a name-only change does neither.
No secrets (DATABASE_PASSWORD, AUTH_SECRET, etc.) are ever read into the edit UI — only name/domain/databaseName/status are exposed.
Clear success banners ("Changes saved" vs. "...the container was restarted...") and inline error display, matching the existing Add Business pattern.
2026-09-05 17:05:33 +01:00
Stefan Lazic c92ee46d76 Delete compose.yaml
CI / ci (push) Canceled after 0s
2026-09-05 14:03:15 +01:00
Stefan Lazic a0d39026c0 Management UI
CI / ci (push) Canceled after 0s
Attempt 2 of a management ui.
2026-09-05 13:39:55 +01:00
Stefan Lazic 9fe5b4f038 Revert "Update README.md"
CI / ci (push) Canceled after 0s
This reverts commit 5472db320e.
2026-09-05 09:32:37 +01:00
Stefan Lazic 89bce03ebb Revert "Deployment Redesign"
This reverts commit 764d087eac.
2026-09-05 09:32:34 +01:00
Stefan Lazic e5668b3f23 Revert "initial NextChair Manager."
This reverts commit b219fb4519.
2026-09-05 09:32:32 +01:00
Stefan Lazic eaa0d2e513 Revert "Build failure fix"
This reverts commit 7f774e95bb.
2026-09-05 09:32:30 +01:00
Stefan Lazic 7e70fa0583 Revert "Fixed the Manager deployment database dependency."
This reverts commit a8cdc2671e.
2026-09-05 09:32:28 +01:00
Stefan Lazic d063918aae Revert "Build faliure fix 2"
This reverts commit 69bc0adf29.
2026-09-05 09:32:26 +01:00
Stefan Lazic 3e9fe60faf Revert "Fixed the remaining Manager runtime coupling."
This reverts commit bc45608023.
2026-09-05 09:32:23 +01:00
Stefan Lazic 65d2c7322b Revert "Request fix"
This reverts commit d9a253a974.
2026-09-05 09:32:21 +01:00
Stefan Lazic 638b851fce Revert "root-route separation."
This reverts commit 2da916a4c6.
2026-09-05 09:32:20 +01:00
Stefan Lazic c237b565bd Revert "Auth fix"
This reverts commit 1c281c9f5d.
2026-09-05 09:32:18 +01:00
Stefan Lazic 32e93c6c23 Revert "Update management-auth.ts"
This reverts commit 0dd50c0375.
2026-09-05 09:32:16 +01:00
Stefan Lazic 3cef279d2d Revert "Redirect loop fix"
This reverts commit 8fddde6d4b.
2026-09-05 09:32:14 +01:00
Stefan Lazic bcf7fa58d3 Revert "cookie handling"
This reverts commit ac9791c8ef.
2026-09-05 09:32:12 +01:00
Stefan Lazic 3de53f0a67 Revert "Update management-auth.ts"
This reverts commit a65360b82d.
2026-09-05 09:32:10 +01:00
Stefan Lazic 344c08b840 Revert "SSR Leak fix"
This reverts commit 8a2b211069.
2026-09-05 09:32:08 +01:00
Stefan Lazic 2aa8405006 Revert "PHP Rebuild"
This reverts commit 2ef3654841.
2026-09-05 09:32:05 +01:00
Stefan Lazic a59f998f2b Revert "Docker"
This reverts commit c4325018e0.
2026-09-05 09:32:03 +01:00
Stefan Lazic 39eeb937de Revert "Update 2026_09_04_000300_create_nextchair_platform_tables.php"
This reverts commit cd6abeca6e.
2026-09-05 09:32:01 +01:00
Stefan Lazic 33fb14b773 Revert "Management UI"
This reverts commit f11404c574.
2026-09-05 09:31:59 +01:00
Stefan Lazic 3e052e56b1 Revert "Login Loop"
This reverts commit 16c6c28f07.
2026-09-05 09:31:50 +01:00
Stefan Lazic fa96fe0e15 Revert "Update entrypoint.sh"
This reverts commit 5972d37425.
2026-09-05 09:31:41 +01:00
972 changed files with 41666 additions and 31622 deletions

No files matched your search

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