Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user