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

@@ -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;
}
}