29 files changed
+876
-89
No files matched your search
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Businesses;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\StaffInvitation;
|
||||
|
||||
final readonly class BusinessProvisioningResult
|
||||
{
|
||||
public function __construct(
|
||||
public Business $business,
|
||||
public StaffInvitation $ownerInvitation,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Businesses;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Models\BookingRule;
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessDomain;
|
||||
use App\Models\BusinessHours;
|
||||
use App\Models\BusinessSettings;
|
||||
use App\Models\Role;
|
||||
use App\Models\RolePermission;
|
||||
use App\Models\StaffInvitation;
|
||||
use App\Models\StaffProfile;
|
||||
use App\Models\User;
|
||||
use App\Services\Tenant\DomainNormalizer;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class BusinessProvisioningService
|
||||
{
|
||||
public function __construct(private readonly DomainNormalizer $domains) {}
|
||||
|
||||
/**
|
||||
* Create a complete, isolated tenant and an owner onboarding invitation.
|
||||
*
|
||||
* The owner is deliberately created without a password. They can only
|
||||
* enter the business workspace after completing the one-time invitation.
|
||||
*
|
||||
* @param array<string, mixed> $businessData
|
||||
* @param array<string, mixed> $ownerData
|
||||
*/
|
||||
public function provision(array $businessData, array $ownerData, ?User $invitedBy = null): BusinessProvisioningResult
|
||||
{
|
||||
$hostname = $this->domains->normalize((string) $businessData['domain']);
|
||||
|
||||
return DB::transaction(function () use ($businessData, $ownerData, $hostname, $invitedBy): BusinessProvisioningResult {
|
||||
$slug = strtolower(trim((string) $businessData['slug']));
|
||||
|
||||
if (Business::query()->where('slug', $slug)->exists()) {
|
||||
throw new \InvalidArgumentException('That business slug is already in use.');
|
||||
}
|
||||
|
||||
if (BusinessDomain::query()->where('hostname', $hostname)->exists()) {
|
||||
throw new \InvalidArgumentException('That hostname is already assigned to another business.');
|
||||
}
|
||||
|
||||
$business = Business::create([
|
||||
'slug' => $slug,
|
||||
'name' => trim((string) $businessData['name']),
|
||||
'shortName' => filled($businessData['shortName'] ?? null) ? trim((string) $businessData['shortName']) : null,
|
||||
'description' => filled($businessData['description'] ?? null) ? trim((string) $businessData['description']) : null,
|
||||
'phone' => filled($businessData['phone'] ?? null) ? trim((string) $businessData['phone']) : null,
|
||||
'email' => strtolower(trim((string) ($businessData['email'] ?? ''))),
|
||||
'timezone' => $businessData['timezone'] ?? 'Europe/London',
|
||||
'currency' => strtoupper((string) ($businessData['currency'] ?? 'GBP')),
|
||||
'locale' => $businessData['locale'] ?? 'en-GB',
|
||||
]);
|
||||
|
||||
BusinessDomain::create([
|
||||
'businessId' => $business->id,
|
||||
'hostname' => $hostname,
|
||||
'isPrimary' => true,
|
||||
'isActive' => true,
|
||||
]);
|
||||
|
||||
BusinessSettings::create([
|
||||
'businessId' => $business->id,
|
||||
'cancellationPolicy' => 'Please give us 24 hours notice if you need to cancel.',
|
||||
]);
|
||||
|
||||
BookingRule::create([
|
||||
'businessId' => $business->id,
|
||||
'minNoticeMinutes' => 120,
|
||||
'maxAdvanceDays' => 60,
|
||||
'cancellationWindowHours' => 24,
|
||||
'rescheduleWindowHours' => 24,
|
||||
'slotIntervalMinutes' => 30,
|
||||
'bufferAfterMinutes' => 5,
|
||||
'customerCanChooseStaff' => true,
|
||||
'allowGuestBooking' => true,
|
||||
]);
|
||||
|
||||
foreach ([
|
||||
'MONDAY' => [600, 1140],
|
||||
'TUESDAY' => [600, 1140],
|
||||
'WEDNESDAY' => [600, 1140],
|
||||
'THURSDAY' => [600, 1140],
|
||||
'FRIDAY' => [600, 1200],
|
||||
'SATURDAY' => [540, 1020],
|
||||
'SUNDAY' => [null, null],
|
||||
] as $day => [$opens, $closes]) {
|
||||
BusinessHours::create([
|
||||
'businessId' => $business->id,
|
||||
'dayOfWeek' => $day,
|
||||
'isClosed' => $opens === null,
|
||||
'opensAtMinutes' => $opens,
|
||||
'closesAtMinutes' => $closes,
|
||||
]);
|
||||
}
|
||||
|
||||
$ownerRole = $this->createRole($business, 'Owner', Permission::cases());
|
||||
$barberRole = $this->createRole($business, 'Barber', [
|
||||
Permission::ManageOwnAppointments,
|
||||
Permission::CheckInCustomers,
|
||||
Permission::ManageOwnAvailability,
|
||||
Permission::BookOnBehalfOfCustomer,
|
||||
Permission::ManageLoyaltyStamps,
|
||||
]);
|
||||
|
||||
$owner = User::create([
|
||||
'name' => trim((string) $ownerData['name']),
|
||||
'email' => strtolower(trim((string) $ownerData['email'])),
|
||||
'passwordHash' => null,
|
||||
'isSuperAdmin' => false,
|
||||
]);
|
||||
|
||||
$staff = StaffProfile::create([
|
||||
'userId' => $owner->id,
|
||||
'businessId' => $business->id,
|
||||
'roleId' => $ownerRole->id,
|
||||
'status' => 'ACTIVE',
|
||||
'isBookableOnline' => true,
|
||||
]);
|
||||
|
||||
$invitation = StaffInvitation::create([
|
||||
'businessId' => $business->id,
|
||||
'staffId' => $staff->id,
|
||||
'email' => $owner->email,
|
||||
'token' => Str::random(64),
|
||||
'invitedByUserId' => $invitedBy?->id,
|
||||
'expiresAt' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
// Keep the local variable explicit so static analysers understand
|
||||
// that the default staff role is intentionally provisioned too.
|
||||
unset($barberRole);
|
||||
|
||||
return new BusinessProvisioningResult($business, $invitation);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Permission> $permissions
|
||||
*/
|
||||
private function createRole(Business $business, string $name, array $permissions): Role
|
||||
{
|
||||
$role = Role::create(['businessId' => $business->id, 'name' => $name, 'isSystem' => true]);
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
RolePermission::create(['roleId' => $role->id, 'permission' => $permission->value]);
|
||||
}
|
||||
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Tenant;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class DomainNormalizer
|
||||
{
|
||||
public function normalize(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException('A domain is required.');
|
||||
}
|
||||
|
||||
$candidate = str_contains($value, '://') ? $value : 'http://'.$value;
|
||||
$host = parse_url($candidate, PHP_URL_HOST);
|
||||
|
||||
if (! is_string($host) || $host === '' || str_contains($host, '/')) {
|
||||
throw new InvalidArgumentException('Enter a valid hostname, such as book.example.com.');
|
||||
}
|
||||
|
||||
$host = strtolower(rtrim($host, '.'));
|
||||
|
||||
if (strlen($host) > 253 || preg_match('/\s/', $host) || ! preg_match('/^[a-z0-9.-]+$/', $host)) {
|
||||
throw new InvalidArgumentException('Enter a valid hostname, such as book.example.com.');
|
||||
}
|
||||
|
||||
return $host;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ final class TenantContext
|
||||
$this->business = $business;
|
||||
}
|
||||
|
||||
public function forget(): void
|
||||
{
|
||||
$this->business = null;
|
||||
}
|
||||
|
||||
public function business(): ?Business
|
||||
{
|
||||
return $this->business;
|
||||
|
||||
Reference in new issue
Block a user