PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
690 files changed
+33944 -12046

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
<?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
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum CalendarProvider: string { case Google = 'GOOGLE'; }
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum CalendarSyncStatus: string { case Synced = 'SYNCED'; case Pending = 'PENDING'; case Failed = 'FAILED'; case Cancelled = 'CANCELLED'; }
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum GalleryPlacement: string { case Hero = 'HERO'; case Portfolio = 'PORTFOLIO'; }
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum InstallationStatus: string { case NotInstalled = 'NOT_INSTALLED'; case Installing = 'INSTALLING'; case Installed = 'INSTALLED'; case Failed = 'FAILED'; }
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum LoyaltyCardFont: string { case Sans = 'SANS'; case Serif = 'SERIF'; case Mono = 'MONO'; }
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum LoyaltyCardTextSize: string { case Sm = 'SM'; case Md = 'MD'; case Lg = 'LG'; }
+5
View File
@@ -0,0 +1,5 @@
<?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
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum NotificationChannel: string { case Email = 'EMAIL'; case Push = 'PUSH'; case InApp = 'IN_APP'; }
+5
View File
@@ -0,0 +1,5 @@
<?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
@@ -0,0 +1,8 @@
<?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
@@ -0,0 +1,24 @@
<?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
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum RewardPositionMode: string { case AfterAllStamps = 'AFTER_ALL_STAMPS'; case FinalSlotIsReward = 'FINAL_SLOT_IS_REWARD'; }
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum RewardStatus: string { case Available = 'AVAILABLE'; case Redeemed = 'REDEEMED'; case Expired = 'EXPIRED'; }
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum RewardType: string { case Percentage = 'PERCENTAGE'; case FixedAmount = 'FIXED_AMOUNT'; case FreeService = 'FREE_SERVICE'; }
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum StaffStatus: string
{
case Active = 'ACTIVE';
case OnBreak = 'ON_BREAK';
case Inactive = 'INACTIVE';
}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace App\Enums;
enum TimeOffStatus: string { case Pending = 'PENDING'; case Approved = 'APPROVED'; case Declined = 'DECLINED'; }
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers;
use App\Models\CustomerProfile;
use App\Models\User;
use App\Services\Tenant\TenantContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class AuthController extends Controller
{
public function showLogin(TenantContext $tenant): View
{
return view('auth.sign-in', ['business' => $tenant->require()]);
}
public function showWelcome(TenantContext $tenant): View
{
return view('auth.welcome', ['business' => $tenant->require()]);
}
public function showVerify(TenantContext $tenant): View
{
return view('auth.verify', ['business' => $tenant->require()]);
}
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email', 'max:190'],
'password' => ['required', 'string'],
]);
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
return back()->withErrors(['email' => 'Those details do not match our records.'])->onlyInput('email');
}
$request->session()->regenerate();
return redirect()->intended(route('customer.dashboard'));
}
public function showRegister(TenantContext $tenant): View
{
return view('auth.register', ['business' => $tenant->require()]);
}
public function showForgot(TenantContext $tenant): View
{
return view('auth.forgot-password', ['business' => $tenant->require()]);
}
public function forgot(Request $request): RedirectResponse
{
$request->validate(['email' => ['required', 'email']]);
return back()->with('status', 'If an account exists for that email, reset instructions will be sent shortly.');
}
public function register(Request $request, TenantContext $tenant): RedirectResponse
{
$validated = $request->validate([
'firstName' => ['required', 'string', 'max:80'],
'lastName' => ['required', 'string', 'max:80'],
'email' => ['required', 'email', 'max:190', 'unique:User,email'],
'phone' => ['nullable', 'string', 'max:40'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
$user = DB::transaction(function () use ($validated, $tenant): User {
$user = User::create([
'name' => trim($validated['firstName'].' '.$validated['lastName']),
'email' => strtolower(trim($validated['email'])),
'phone' => $validated['phone'] ?? null,
'passwordHash' => Hash::make($validated['password']),
]);
CustomerProfile::create([
'businessId' => $tenant->require()->id,
'userId' => $user->id,
'firstName' => trim($validated['firstName']),
'lastName' => trim($validated['lastName']),
'email' => strtolower(trim($validated['email'])),
'phone' => $validated['phone'] ?? null,
'isGuest' => false,
]);
return $user;
});
Auth::login($user);
$request->session()->regenerate();
return redirect()->route('customer.dashboard');
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('public.home');
}
}
+106
View File
@@ -0,0 +1,106 @@
<?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
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,36 @@
<?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,
]);
}
}
@@ -0,0 +1,42 @@
<?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.');
}
}
@@ -0,0 +1,21 @@
<?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'));
}
}
@@ -0,0 +1,21 @@
<?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.');
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Enums\Permission;
use App\Enums\InstallationStatus;
use App\Models\{ApplicationInstallation, BookingRule, Business, BusinessDomain, BusinessSettings, Role, RolePermission, StaffProfile, User};
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class InstallController extends Controller
{
public function create(): View|RedirectResponse
{
if (ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) return redirect()->route('public.home');
return view('install');
}
public function store(Request $request): RedirectResponse
{
if (ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) return redirect()->route('public.home');
$data = $request->validate(['businessName' => ['required', 'string', 'max:190'], 'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug'], 'adminName' => ['required', 'string', 'max:190'], 'adminEmail' => ['required', 'email', 'max:190', 'unique:User,email'], 'password' => ['required', 'string', 'min:8', 'confirmed'], 'domain' => ['nullable', 'string', 'max:190']]);
DB::transaction(function () use ($data, $request): void {
$business = Business::create(['slug' => strtolower($data['slug']), 'name' => $data['businessName'], 'shortName' => $data['businessName'], 'timezone' => config('app.timezone', 'Europe/London'), 'currency' => 'GBP', 'locale' => 'en-GB']);
BusinessDomain::create(['businessId' => $business->id, 'hostname' => strtolower($data['domain'] ?: $request->getHost()), 'isPrimary' => true, 'isActive' => true]);
BusinessSettings::create(['businessId' => $business->id]);
BookingRule::create(['businessId' => $business->id, 'allowGuestBooking' => true]);
$role = Role::create(['businessId' => $business->id, 'name' => 'Owner', 'isSystem' => true]);
foreach (Permission::cases() as $permission) RolePermission::create(['roleId' => $role->id, 'permission' => $permission]);
$user = User::create(['name' => $data['adminName'], 'email' => strtolower($data['adminEmail']), 'passwordHash' => Hash::make($data['password']), 'emailVerified' => now()]);
$staff = StaffProfile::create(['userId' => $user->id, 'businessId' => $business->id, 'roleId' => $role->id, 'status' => 'ACTIVE', 'isBookableOnline' => false]);
ApplicationInstallation::create(['id' => 'singleton', 'status' => InstallationStatus::Installed, 'installedAt' => now(), 'installedVersion' => 'laravel-1', 'initialAdminUserId' => $user->id, 'primaryBusinessId' => $business->id, 'metadata' => ['starter' => true]]);
});
return redirect()->route('install')->with('status', 'Installation complete. You can now sign in.');
}
}
@@ -0,0 +1,43 @@
<?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.');
}
}
@@ -0,0 +1,27 @@
<?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();
}
}
@@ -0,0 +1,32 @@
<?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.');
}
}
@@ -0,0 +1,37 @@
<?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;
}
}
@@ -0,0 +1,16 @@
<?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'));
}
}
@@ -0,0 +1,21 @@
<?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.');
}
}
@@ -0,0 +1,43 @@
<?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.');
}
}
@@ -0,0 +1,31 @@
<?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.');
}
}
@@ -0,0 +1,22 @@
<?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'));
}
}
@@ -0,0 +1,61 @@
<?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.');
}
}
@@ -0,0 +1,36 @@
<?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'));
}
}
@@ -0,0 +1,26 @@
<?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);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use App\Models\Business;
use App\Models\BusinessDomain;
use App\Services\Tenant\TenantContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class ResolveTenant
{
public function handle(Request $request, Closure $next): Response
{
$hostname = strtolower(rtrim($request->getHost(), '.'));
try {
$business = BusinessDomain::query()
->where('hostname', $hostname)
->where('isActive', true)
->with('business')
->first()?->business;
if (! $business && app()->environment('local')) {
$slug = config('app.default_business_slug');
$business = $slug
? Business::query()->where('slug', $slug)->first()
: null;
}
} catch (Throwable) {
// The install page and framework smoke tests must remain
// reachable before the tenant schema has been created.
$business = null;
}
if ($business) {
app(TenantContext::class)->set($business);
}
return $next($request);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?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
@@ -0,0 +1,32 @@
<?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
@@ -0,0 +1,19 @@
<?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
@@ -0,0 +1,18 @@
<?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
@@ -0,0 +1,18 @@
<?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
@@ -0,0 +1,17 @@
<?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
@@ -0,0 +1,16 @@
<?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'); }
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Models\Concerns\UsesStringIds;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Business extends Model
{
use UsesStringIds;
public const CREATED_AT = 'createdAt';
public const UPDATED_AT = 'updatedAt';
protected $table = 'Business';
protected $fillable = [
'slug', 'name', 'shortName', 'description', 'logoUrl', 'coverImageUrl',
'faviconUrl', 'appIconUrl', 'themeColor', 'backgroundColor', 'phone',
'email', 'website', 'instagramUrl', 'facebookUrl', 'timezone', 'currency',
'locale', 'bottomNavShowLabels', 'icon192Url', 'icon512Url', 'maskableIcon192Url',
'maskableIcon512Url', 'splashScreens', 'welcomeBackgroundDesktopUrl',
'welcomeBackgroundMobileUrl', 'googlePlaceId', 'googleRating', 'googleRatingCount',
];
protected function casts(): array
{
return ['bottomNavShowLabels' => 'boolean', 'splashScreens' => 'array', 'googleRating' => 'decimal:1'];
}
public function domains(): HasMany { return $this->hasMany(BusinessDomain::class, 'businessId'); }
public function locations(): HasMany { return $this->hasMany(BusinessLocation::class, 'businessId'); }
public function hours(): HasMany { return $this->hasMany(BusinessHours::class, 'businessId'); }
public function settings(): HasOne { return $this->hasOne(BusinessSettings::class, 'businessId'); }
public function bookingRule(): HasOne { return $this->hasOne(BookingRule::class, 'businessId'); }
public function staff(): HasMany { return $this->hasMany(StaffProfile::class, 'businessId'); }
public function serviceCategories(): HasMany { return $this->hasMany(ServiceCategory::class, 'businessId'); }
public function services(): HasMany { return $this->hasMany(Service::class, 'businessId'); }
public function appointments(): HasMany { return $this->hasMany(Appointment::class, 'businessId'); }
public function loyaltyProgramme(): HasOne { return $this->hasOne(LoyaltyProgramme::class, 'businessId'); }
public function staffInvitations(): HasMany { return $this->hasMany(StaffInvitation::class, 'businessId'); }
public function notifications(): HasMany { return $this->hasMany(Notification::class, 'businessId'); }
public function pushSubscriptions(): HasMany { return $this->hasMany(PushSubscription::class, 'businessId'); }
public function mediaAssets(): HasMany { return $this->hasMany(MediaAsset::class, 'businessId'); }
public function galleryImages(): HasMany { return $this->hasMany(BusinessGalleryImage::class, 'businessId'); }
public function reviews(): HasMany { return $this->hasMany(Review::class, 'businessId'); }
}
+26
View File
@@ -0,0 +1,26 @@
<?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
@@ -0,0 +1,19 @@
<?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
@@ -0,0 +1,17 @@
<?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
@@ -0,0 +1,21 @@
<?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
@@ -0,0 +1,17 @@
<?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
@@ -0,0 +1,27 @@
<?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
@@ -0,0 +1,17 @@
<?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
@@ -0,0 +1,27 @@
<?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'); }
}
@@ -0,0 +1,17 @@
<?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
@@ -0,0 +1,20 @@
<?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
@@ -0,0 +1,17 @@
<?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
@@ -0,0 +1,17 @@
<?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
@@ -0,0 +1,23 @@
<?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
@@ -0,0 +1,22 @@
<?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
@@ -0,0 +1,20 @@
<?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
@@ -0,0 +1,19 @@
<?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
@@ -0,0 +1,24 @@
<?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
@@ -0,0 +1,18 @@
<?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
@@ -0,0 +1,19 @@
<?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
@@ -0,0 +1,20 @@
<?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
@@ -0,0 +1,17 @@
<?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
@@ -0,0 +1,20 @@
<?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
@@ -0,0 +1,18 @@
<?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
@@ -0,0 +1,26 @@
<?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
@@ -0,0 +1,21 @@
<?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
@@ -0,0 +1,18 @@
<?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
@@ -0,0 +1,21 @@
<?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
@@ -0,0 +1,19 @@
<?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
@@ -0,0 +1,41 @@
<?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
@@ -0,0 +1,15 @@
<?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
@@ -0,0 +1,18 @@
<?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
@@ -0,0 +1,72 @@
<?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
@@ -0,0 +1,15 @@
<?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
@@ -0,0 +1,20 @@
<?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.
}
}
@@ -0,0 +1,100 @@
<?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);
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Services\Booking;
use App\Enums\AppointmentStatus;
use App\Models\Appointment;
use App\Models\AppointmentService;
use App\Models\Business;
use App\Models\CustomerProfile;
use App\Models\GuestAppointmentAccessToken;
use App\Models\Service;
use App\Services\Availability\AvailabilityService;
use App\Services\Notifications\NotificationService;
use App\Enums\NotificationType;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class BookingService
{
public function __construct(private readonly AvailabilityService $availability, private readonly NotificationService $notifications) {}
/** @return array{appointment: Appointment, accessToken: string} */
public function create(Business $business, array $input): array
{
$serviceIds = array_values(array_unique($input['serviceIds']));
$services = Service::query()->where('businessId', $business->id)->whereIn('id', $serviceIds)->where('isActive', true)->get();
if ($services->count() !== count($serviceIds)) {
throw new RuntimeException('One or more selected services are no longer available.');
}
$start = CarbonImmutable::parse($input['startAt'])->setTimezone($business->timezone);
$end = $start->addMinutes($services->sum('durationMinutes') + $services->sum('bufferBeforeMinutes') + $services->sum('bufferAfterMinutes'));
$available = $this->availability->slots($business, $start, $serviceIds, $input['staffId'])->first(fn ($slot) => $slot['startAt'] === $start->toIso8601String());
if (! $available) {
throw new RuntimeException('That time has just been booked. Please choose another slot.');
}
$rawToken = Str::random(64);
$appointment = DB::transaction(function () use ($business, $input, $services, $start, $end, $rawToken): Appointment {
$conflict = Appointment::query()
->where('businessId', $business->id)
->where('staffId', $input['staffId'])
->whereNotIn('status', [AppointmentStatus::Cancelled->value, AppointmentStatus::NoShow->value])
->where('startAt', '<', $end->utc())
->where('endAt', '>', $start->utc())
->lockForUpdate()
->exists();
if ($conflict) {
throw new RuntimeException('That time has just been booked. Please choose another slot.');
}
$email = strtolower(trim($input['email']));
$customer = CustomerProfile::query()->where('businessId', $business->id)->where('email', $email)->first();
if (! $customer) $customer = new CustomerProfile(['businessId' => $business->id, 'email' => $email]);
$customer->firstName = trim($input['firstName']);
$customer->lastName = trim($input['lastName']);
$customer->phone = trim($input['phone'] ?? '') ?: $customer->phone;
$customer->isGuest = ! $customer->userId;
$customer->save();
$appointment = Appointment::create([
'businessId' => $business->id,
'customerId' => $customer->id,
'staffId' => $input['staffId'],
'status' => $business->bookingRule?->requireApproval ? AppointmentStatus::Pending : AppointmentStatus::Confirmed,
'startAt' => $start->utc(),
'endAt' => $end->utc(),
'notes' => $input['notes'] ?? null,
]);
foreach ($services as $service) {
AppointmentService::create(['appointmentId' => $appointment->id, 'serviceId' => $service->id, 'priceAtBooking' => $service->price, 'durationAtBookingMinutes' => $service->durationMinutes]);
}
GuestAppointmentAccessToken::create(['appointmentId' => $appointment->id, 'tokenHash' => hash('sha256', $rawToken), 'expiresAt' => now()->addMonths(6)]);
return $appointment;
});
$appointment->load(['customer', 'staff.user', 'services.service']);
$notificationType = $appointment->status === AppointmentStatus::Pending ? NotificationType::BookingPendingApproval : NotificationType::BookingConfirmed;
$this->notifications->create(
$business,
$notificationType,
$appointment->status === AppointmentStatus::Pending
? 'Your booking request is waiting for the shop to approve.'
: 'Your appointment is confirmed.',
"booking-created:{$appointment->id}",
$appointment->customer->userId,
$appointment->customer->id,
$appointment->id,
"/book/confirmation",
$business->name,
);
return ['appointment' => $appointment, 'accessToken' => $rawToken];
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Services\Loyalty;
use App\Enums\{LoyaltyTransactionType, RewardPositionMode, RewardStatus, RewardType};
use App\Models\{Appointment, Business, CustomerProfile, LoyaltyAccount, LoyaltyProgramme, LoyaltyReward, LoyaltyTransaction, StaffProfile};
use Illuminate\Support\Facades\DB;
use RuntimeException;
class LoyaltyService
{
public function summary(Business $business, CustomerProfile $customer): ?array
{
$programme = $business->loyaltyProgramme;
if (! $programme) return null;
$account = LoyaltyAccount::query()
->where('customerId', $customer->id)
->where('loyaltyProgrammeId', $programme->id)
->with(['rewards' => fn ($query) => $query->where('status', RewardStatus::Available->value)->latest('createdAt')])
->first();
return [
'programme' => $programme,
'account' => $account,
'currentStamps' => $account?->currentStamps ?? 0,
'threshold' => $this->threshold($programme),
'availableReward' => $account?->rewards->first(),
];
}
public function addStamp(Business $business, CustomerProfile $customer, StaffProfile $staff, int $amount = 1, ?Appointment $appointment = null, ?string $reason = null): LoyaltyAccount
{
if ($amount < 1) throw new RuntimeException('Stamp amount must be positive.');
$programme = $business->loyaltyProgramme;
if (! $programme?->isEnabled) throw new RuntimeException('Loyalty is not enabled for this business.');
$this->assertEligible($programme, $appointment);
return DB::transaction(function () use ($programme, $customer, $staff, $amount, $appointment, $reason): LoyaltyAccount {
$account = LoyaltyAccount::query()->firstOrCreate(['customerId' => $customer->id, 'loyaltyProgrammeId' => $programme->id], ['currentStamps' => 0]);
$account = LoyaltyAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail();
$account->currentStamps += $amount;
$account->save();
LoyaltyTransaction::create([
'loyaltyAccountId' => $account->id,
'type' => LoyaltyTransactionType::StampAdded,
'amount' => $amount,
'reason' => $reason,
'appointmentId' => $appointment?->id,
'authoredByStaffId' => $staff->id,
]);
if ($account->currentStamps >= $this->threshold($programme) && ! $account->rewards()->where('status', RewardStatus::Available->value)->exists()) {
LoyaltyReward::create([
'loyaltyAccountId' => $account->id,
'rewardType' => $programme->rewardType,
'value' => $programme->rewardValue,
'label' => $programme->rewardLabel ?: 'Reward',
'status' => RewardStatus::Available,
'expiresAt' => $programme->rewardExpiresAfterDays ? now()->addDays($programme->rewardExpiresAfterDays) : null,
]);
}
return $account->fresh();
});
}
public function correctStamp(LoyaltyAccount $account, StaffProfile $staff, int $amount, string $reason): void
{
if ($amount < 1 || trim($reason) === '') throw new RuntimeException('A positive amount and reason are required.');
DB::transaction(function () use ($account, $staff, $amount, $reason): void {
$locked = LoyaltyAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail();
$removed = min($amount, $locked->currentStamps);
$locked->currentStamps -= $removed;
$locked->save();
LoyaltyTransaction::create(['loyaltyAccountId' => $locked->id, 'type' => LoyaltyTransactionType::StampRemoved, 'amount' => -$removed, 'reason' => trim($reason), 'authoredByStaffId' => $staff->id]);
});
}
public function redeemReward(Business $business, LoyaltyReward $reward, Appointment $appointment, StaffProfile $staff): string
{
$reward->loadMissing('account.programme');
$account = $reward->account;
$programme = $account?->programme;
if (! $programme || $reward->status !== RewardStatus::Available || $appointment->businessId !== $business->id || $appointment->customerId !== $account->customerId) {
throw new RuntimeException('That reward is not available for this appointment.');
}
$subtotal = (float) $appointment->services()->sum('priceAtBooking');
$discount = match ($reward->rewardType) {
RewardType::Percentage => min($subtotal, round($subtotal * ((float) $reward->value / 100), 2)),
RewardType::FixedAmount => min($subtotal, (float) $reward->value),
RewardType::FreeService => min($subtotal, (float) ($programme->freeService?->price ?? $subtotal)),
};
DB::transaction(function () use ($reward, $account, $appointment, $staff, $discount, $programme): void {
$locked = LoyaltyReward::query()->whereKey($reward->id)->lockForUpdate()->firstOrFail();
if ($locked->status !== RewardStatus::Available) throw new RuntimeException('That reward has already been redeemed.');
$locked->status = RewardStatus::Redeemed;
$locked->redeemedAt = now();
$locked->redeemedByStaffId = $staff->id;
$locked->appliedToAppointmentId = $appointment->id;
$locked->save();
$lockedAccount = LoyaltyAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail();
$lockedAccount->currentStamps = max(0, $lockedAccount->currentStamps - $programme->stampsRequired);
$lockedAccount->save();
LoyaltyTransaction::create(['loyaltyAccountId' => $lockedAccount->id, 'type' => LoyaltyTransactionType::RewardRedeemed, 'amount' => -$programme->stampsRequired, 'reason' => 'Reward redeemed', 'appointmentId' => $appointment->id, 'authoredByStaffId' => $staff->id]);
$appointment->discountAmount = $discount;
$appointment->save();
});
return number_format($discount, 2, '.', '');
}
public function awardForCompletedAppointment(Appointment $appointment, StaffProfile $staff): ?LoyaltyAccount
{
if ($appointment->status->value !== 'COMPLETED') return null;
$already = LoyaltyTransaction::query()->where('appointmentId', $appointment->id)->where('type', LoyaltyTransactionType::StampAdded->value)->exists();
if ($already) return null;
$appointment->loadMissing('business', 'customer', 'services');
return $this->addStamp($appointment->business, $appointment->customer, $staff, 1, $appointment);
}
private function threshold(LoyaltyProgramme $programme): int
{
return max(1, $programme->rewardPosition === RewardPositionMode::FinalSlotIsReward ? $programme->stampsRequired - 1 : $programme->stampsRequired);
}
private function assertEligible(LoyaltyProgramme $programme, ?Appointment $appointment): void
{
if (! $appointment) return;
$serviceIds = $appointment->services->pluck('serviceId');
$eligibleServices = $programme->eligibleServices()->pluck('serviceId');
$eligibleStaff = $programme->eligibleStaff()->pluck('staffId');
if ($eligibleStaff->isNotEmpty() && ! $eligibleStaff->contains($appointment->staffId)) throw new RuntimeException("This staff member's visits don't earn loyalty stamps.");
if ($eligibleServices->isNotEmpty() && $serviceIds->intersect($eligibleServices)->isEmpty()) throw new RuntimeException("None of the services on this visit earn a loyalty stamp.");
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Services\Notifications;
use App\Enums\{NotificationChannel, NotificationType};
use App\Models\{Business, Notification};
class NotificationService
{
public function create(Business $business, NotificationType $type, string $body, string $idempotencyKey, ?string $userId = null, ?string $customerId = null, ?string $appointmentId = null, ?string $deepLinkPath = null, ?string $subject = null): Notification
{
$notification = Notification::query()->firstOrCreate(
['idempotencyKey' => $idempotencyKey],
['businessId' => $business->id, 'recipientUserId' => $userId, 'recipientCustomerId' => $customerId, 'type' => $type, 'appointmentId' => $appointmentId, 'subject' => $subject, 'bodyText' => $body, 'deepLinkPath' => $deepLinkPath],
);
if ($userId) $notification->deliveries()->firstOrCreate(['channel' => NotificationChannel::InApp], ['status' => 'SENT', 'sentAt' => now()]);
return $notification;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Tenant;
use App\Models\Business;
final class TenantContext
{
private ?Business $business = null;
public function set(Business $business): void
{
$this->business = $business;
}
public function business(): ?Business
{
return $this->business;
}
public function id(): ?string
{
return $this->business?->getKey();
}
public function require(): Business
{
abort_unless($this->business, 404, 'Business not found.');
return $this->business;
}
}