29 files changed
+869
-82
No files matched your search
@@ -3,7 +3,6 @@ APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8085
|
||||
APP_DEFAULT_BUSINESS_SLUG=
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
||||
@@ -21,9 +21,31 @@ php artisan serve
|
||||
```
|
||||
|
||||
Configure the MySQL connection in `.env` before running migrations. The
|
||||
application resolves a business from the request hostname through the
|
||||
`business_domains` table. Local development can use a configured local domain
|
||||
or the `APP_DEFAULT_BUSINESS_SLUG` fallback.
|
||||
application resolves a business only from the request hostname through the
|
||||
`business_domains` table; it never guesses a tenant from the first business
|
||||
in the database. For local multi-tenant work, map each configured hostname to
|
||||
127.0.0.1 in `/etc/hosts`.
|
||||
|
||||
## Platform and tenant setup
|
||||
|
||||
Migrations create schema only. They do not create a demo business or a
|
||||
business owner. After migrating, open `/install` once and create the platform
|
||||
administrator. That account can sign in at `/manager/sign-in`, create
|
||||
businesses, edit their primary domains, and share the one-time owner setup
|
||||
link generated during provisioning.
|
||||
|
||||
Business provisioning creates the tenant domain, settings, booking rules,
|
||||
opening hours, system roles, owner staff profile, and seven-day owner
|
||||
invitation in one transaction. The owner chooses a password through that
|
||||
invitation; platform administrators and business owners are separate account
|
||||
types.
|
||||
|
||||
If demo data is useful for local development, opt into the existing seeder
|
||||
explicitly:
|
||||
|
||||
```bash
|
||||
NEXTCHAIR_SEED_DEMO=true php artisan db:seed
|
||||
```
|
||||
|
||||
## Docker deployment
|
||||
|
||||
@@ -59,6 +81,10 @@ docker compose exec app php artisan key:generate
|
||||
docker compose exec app php artisan migrate
|
||||
```
|
||||
|
||||
Then visit `http://localhost:8085/install` to create the first platform
|
||||
administrator. Do not run migrations or create tenants from container startup;
|
||||
the tenant owner is provisioned manually from Manager.
|
||||
|
||||
Migrations are intentionally not run by container startup. To inspect the
|
||||
application logs:
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ class AuthController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
if ($request->user()->isSuperAdmin) {
|
||||
return redirect()->intended(route('manager.dashboard'));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('customer.dashboard'));
|
||||
}
|
||||
|
||||
@@ -100,10 +104,11 @@ class AuthController extends Controller
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
$wasSuperAdmin = $request->user()?->isSuperAdmin === true;
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('public.home');
|
||||
return redirect()->route($wasSuperAdmin ? 'manager.login' : 'public.home');
|
||||
}
|
||||
}
|
||||
@@ -2,39 +2,75 @@
|
||||
|
||||
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 App\Models\ApplicationInstallation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class InstallController extends Controller
|
||||
{
|
||||
public function create(): View|RedirectResponse
|
||||
public function create(Request $request): View|RedirectResponse
|
||||
{
|
||||
if (ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) return redirect()->route('public.home');
|
||||
if ($this->isInstalled()) {
|
||||
return $request->user()?->isSuperAdmin
|
||||
? redirect()->route('manager.dashboard')
|
||||
: redirect()->route('manager.login');
|
||||
}
|
||||
|
||||
return view('install');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (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']]);
|
||||
if ($this->isInstalled()) {
|
||||
return redirect()->route('manager.login');
|
||||
}
|
||||
|
||||
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]]);
|
||||
$data = $request->validate([
|
||||
'adminName' => ['required', 'string', 'max:160'],
|
||||
'adminEmail' => ['required', 'email', 'max:190', 'unique:User,email'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
$admin = DB::transaction(function () use ($data): User {
|
||||
$admin = User::create([
|
||||
'name' => trim($data['adminName']),
|
||||
'email' => strtolower(trim($data['adminEmail'])),
|
||||
'passwordHash' => Hash::make($data['password']),
|
||||
'isSuperAdmin' => true,
|
||||
'emailVerified' => now(),
|
||||
]);
|
||||
|
||||
ApplicationInstallation::create([
|
||||
'id' => 'singleton',
|
||||
'status' => InstallationStatus::Installed,
|
||||
'installedAt' => now(),
|
||||
'installedVersion' => 'laravel-1',
|
||||
'initialAdminUserId' => $admin->id,
|
||||
'primaryBusinessId' => null,
|
||||
'metadata' => ['installer' => 'platform-admin-only'],
|
||||
]);
|
||||
|
||||
return $admin;
|
||||
});
|
||||
return redirect()->route('install')->with('status', 'Installation complete. You can now sign in.');
|
||||
|
||||
Auth::login($admin);
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->route('manager.dashboard')->with('status', 'Platform setup complete. Create your first business from Manager.');
|
||||
}
|
||||
|
||||
private function isInstalled(): bool
|
||||
{
|
||||
try {
|
||||
return ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessDomain;
|
||||
use App\Services\Businesses\BusinessProvisioningService;
|
||||
use App\Services\Tenant\DomainNormalizer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class ManagerBusinessController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
return view('manager.businesses.create');
|
||||
}
|
||||
|
||||
public function store(Request $request, BusinessProvisioningService $provisioner, DomainNormalizer $domains): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:160'],
|
||||
'shortName' => ['nullable', 'string', 'max:80'],
|
||||
'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug'],
|
||||
'domain' => ['required', 'string', 'max:253'],
|
||||
'email' => ['nullable', 'email', 'max:190'],
|
||||
'phone' => ['nullable', 'string', 'max:40'],
|
||||
'timezone' => ['required', 'timezone'],
|
||||
'currency' => ['required', 'string', 'size:3'],
|
||||
'ownerName' => ['required', 'string', 'max:160'],
|
||||
'ownerEmail' => ['required', 'email', 'max:190', 'unique:User,email'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$data['domain'] = $domains->normalize($data['domain']);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return back()->withErrors(['domain' => $exception->getMessage()])->withInput();
|
||||
}
|
||||
|
||||
if (BusinessDomain::query()->where('hostname', $data['domain'])->exists()) {
|
||||
return back()->withErrors(['domain' => 'That hostname is already assigned to another business.'])->withInput();
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $provisioner->provision($data, [
|
||||
'name' => $data['ownerName'],
|
||||
'email' => $data['ownerEmail'],
|
||||
], $request->user());
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return back()->withErrors(['domain' => $exception->getMessage()])->withInput();
|
||||
}
|
||||
|
||||
$hostname = $result->business->primaryDomain()->value('hostname');
|
||||
$invitationUrl = request()->getScheme().'://'.$hostname.'/invite/'.$result->ownerInvitation->token;
|
||||
|
||||
return redirect()->route('manager.businesses.show', $result->business)
|
||||
->with('status', 'Business created. The owner can finish setup using the invitation link below.')
|
||||
->with('owner_invitation_url', $invitationUrl);
|
||||
}
|
||||
|
||||
public function show(Business $business): View
|
||||
{
|
||||
$business->load(['domains', 'settings', 'bookingRule', 'hours', 'staff.user', 'staff.role']);
|
||||
|
||||
return view('manager.businesses.show', compact('business'));
|
||||
}
|
||||
|
||||
public function edit(Business $business): View
|
||||
{
|
||||
$business->load('primaryDomain');
|
||||
|
||||
return view('manager.businesses.edit', compact('business'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, DomainNormalizer $domains): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:160'],
|
||||
'shortName' => ['nullable', 'string', 'max:80'],
|
||||
'slug' => ['required', 'alpha_dash', 'max:80', 'unique:Business,slug,'.$business->id.',id'],
|
||||
'domain' => ['required', 'string', 'max:253'],
|
||||
'email' => ['nullable', 'email', 'max:190'],
|
||||
'phone' => ['nullable', 'string', 'max:40'],
|
||||
'timezone' => ['required', 'timezone'],
|
||||
'currency' => ['required', 'string', 'size:3'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$hostname = $domains->normalize($data['domain']);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return back()->withErrors(['domain' => $exception->getMessage()])->withInput();
|
||||
}
|
||||
|
||||
$domainTaken = BusinessDomain::query()
|
||||
->where('hostname', $hostname)
|
||||
->where('businessId', '!=', $business->id)
|
||||
->exists();
|
||||
|
||||
if ($domainTaken) {
|
||||
return back()->withErrors(['domain' => 'That hostname is already assigned to another business.'])->withInput();
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($business, $data, $hostname): void {
|
||||
$business->update([
|
||||
'name' => trim($data['name']),
|
||||
'shortName' => filled($data['shortName'] ?? null) ? trim($data['shortName']) : null,
|
||||
'slug' => strtolower(trim($data['slug'])),
|
||||
'email' => filled($data['email'] ?? null) ? strtolower(trim($data['email'])) : null,
|
||||
'phone' => filled($data['phone'] ?? null) ? trim($data['phone']) : null,
|
||||
'timezone' => $data['timezone'],
|
||||
'currency' => strtoupper($data['currency']),
|
||||
]);
|
||||
|
||||
$primary = $business->domains()->where('isPrimary', true)->first();
|
||||
if ($primary) {
|
||||
$primary->update(['hostname' => $hostname]);
|
||||
} else {
|
||||
BusinessDomain::create(['businessId' => $business->id, 'hostname' => $hostname, 'isPrimary' => true, 'isActive' => true]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('manager.businesses.show', $business)->with('status', 'Business details updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ManagerDashboardController extends Controller
|
||||
{
|
||||
public function __invoke(): View
|
||||
{
|
||||
$businesses = Business::query()
|
||||
->with(['primaryDomain', 'staff.user', 'staff.role'])
|
||||
->withCount(['staff', 'domains'])
|
||||
->latest('createdAt')
|
||||
->get();
|
||||
|
||||
return view('manager.dashboard', [
|
||||
'businesses' => $businesses,
|
||||
'businessCount' => $businesses->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\StaffInvitation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OwnerInvitationController extends Controller
|
||||
{
|
||||
public function show(string $token): View|RedirectResponse
|
||||
{
|
||||
$invitation = $this->findValidInvitation($token);
|
||||
|
||||
return view('auth.owner-invite', [
|
||||
'invitation' => $invitation,
|
||||
'business' => $invitation->business,
|
||||
]);
|
||||
}
|
||||
|
||||
public function accept(Request $request, string $token): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
$invitation = $this->findValidInvitation($token);
|
||||
|
||||
DB::transaction(function () use ($invitation, $data): void {
|
||||
$invitation->staff->user->update([
|
||||
'passwordHash' => Hash::make($data['password']),
|
||||
'emailVerified' => now(),
|
||||
]);
|
||||
$invitation->update(['acceptedAt' => now()]);
|
||||
});
|
||||
|
||||
Auth::login($invitation->staff->user->fresh());
|
||||
$request->session()->regenerate();
|
||||
|
||||
$hostname = $invitation->business->primaryDomain?->hostname;
|
||||
$destination = $hostname
|
||||
? request()->getScheme().'://'.$hostname.'/owner'
|
||||
: route('owner.dashboard');
|
||||
|
||||
return redirect()->to($destination)->with('status', 'Your owner workspace is ready.');
|
||||
}
|
||||
|
||||
private function findValidInvitation(string $token): StaffInvitation
|
||||
{
|
||||
$invitation = StaffInvitation::query()->with(['business.primaryDomain', 'staff.user'])->where('token', $token)->firstOrFail();
|
||||
|
||||
abort_if($invitation->acceptedAt || $invitation->expiresAt->isPast(), 410, 'This invitation has expired or has already been used.');
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\InstallationStatus;
|
||||
use App\Models\ApplicationInstallation;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PlatformAuthController extends Controller
|
||||
{
|
||||
public function showLogin(Request $request): View|RedirectResponse
|
||||
{
|
||||
if ($request->user()?->isSuperAdmin) {
|
||||
return redirect()->route('manager.dashboard');
|
||||
}
|
||||
|
||||
if (! ApplicationInstallation::query()->where('status', InstallationStatus::Installed)->exists()) {
|
||||
return redirect()->route('install');
|
||||
}
|
||||
|
||||
return view('auth.platform-sign-in');
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email', 'max:190'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if (! Auth::attempt($credentials, $request->boolean('remember')) || ! $request->user()?->isSuperAdmin) {
|
||||
Auth::logout();
|
||||
|
||||
return back()->withErrors(['email' => 'Those platform administrator details do not match our records.'])->onlyInput('email');
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('manager.dashboard'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureSuperAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()) {
|
||||
return redirect()->route('manager.login');
|
||||
}
|
||||
|
||||
abort_unless($request->user()->isSuperAdmin === true, 403);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessDomain;
|
||||
use App\Services\Tenant\TenantContext;
|
||||
use App\Services\Tenant\DomainNormalizer;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -14,26 +14,25 @@ class ResolveTenant
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$hostname = strtolower(rtrim($request->getHost(), '.'));
|
||||
$hostname = null;
|
||||
try {
|
||||
$hostname = app(DomainNormalizer::class)->normalize($request->getHost());
|
||||
$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)->forget();
|
||||
}
|
||||
|
||||
if ($business) {
|
||||
app(TenantContext::class)->set($business);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ class Business extends Model
|
||||
}
|
||||
|
||||
public function domains(): HasMany { return $this->hasMany(BusinessDomain::class, 'businessId'); }
|
||||
public function primaryDomain(): HasOne { return $this->hasOne(BusinessDomain::class, 'businessId')->where('isPrimary', true); }
|
||||
public function locations(): HasMany { return $this->hasMany(BusinessLocation::class, 'businessId'); }
|
||||
public function hours(): HasMany { return $this->hasMany(BusinessHours::class, 'businessId'); }
|
||||
public function settings(): HasOne { return $this->hasOne(BusinessSettings::class, 'businessId'); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Middleware\ResolveTenant;
|
||||
use App\Http\Middleware\EnsureStaffPermission;
|
||||
use App\Http\Middleware\EnsureSuperAdmin;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -17,6 +18,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->alias([
|
||||
'tenant' => ResolveTenant::class,
|
||||
'staff.permission' => EnsureStaffPermission::class,
|
||||
'platform.admin' => EnsureSuperAdmin::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
@@ -15,8 +15,6 @@ return [
|
||||
|
||||
'name' => env('APP_NAME', 'NextChair'),
|
||||
|
||||
'default_business_slug' => env('APP_DEFAULT_BUSINESS_SLUG'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|
||||
@@ -27,6 +27,12 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Demo data is opt-in. Application installation and migrations must
|
||||
// never create a business or an owner implicitly.
|
||||
if (! app()->environment('local') || ! env('NEXTCHAIR_SEED_DEMO', false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$business = Business::updateOrCreate(
|
||||
['slug' => 'demo-barbershop'],
|
||||
['name' => 'Blake Heritage Barbershop', 'shortName' => 'Blake Heritage', 'description' => 'Classic cuts, modern service, and a simple way to book your next visit.', 'phone' => '+44 20 7946 0000', 'email' => 'hello@blakeheritage.test', 'timezone' => 'Europe/London', 'currency' => 'GBP', 'locale' => 'en-GB'],
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<x-layouts.public :business="$business">
|
||||
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">{{ $business->name }}</p>
|
||||
<h1 class="mt-3 text-4xl font-black tracking-tight">Finish your owner setup</h1>
|
||||
<p class="mt-4 text-sm leading-6 text-muted">Choose a password for {{ $invitation->staff->user->email }} to access the business workspace.</p>
|
||||
@if ($errors->any())
|
||||
<div class="mt-6 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('owner.invite.accept', $invitation->token) }}" class="mt-8 space-y-5">
|
||||
@csrf
|
||||
<label class="block text-sm font-bold">Password
|
||||
<input name="password" type="password" minlength="8" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
|
||||
</label>
|
||||
<label class="block text-sm font-bold">Confirm password
|
||||
<input name="password_confirmation" type="password" minlength="8" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
|
||||
</label>
|
||||
<x-ui.button type="submit" class="w-full">Open owner workspace</x-ui.button>
|
||||
</form>
|
||||
</main>
|
||||
</x-layouts.public>
|
||||
@@ -0,0 +1,20 @@
|
||||
<x-layouts.public :business="null">
|
||||
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-16">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">NextChair Manager</p>
|
||||
<h1 class="mt-3 text-4xl font-black tracking-tight">Platform sign in</h1>
|
||||
<p class="mt-4 text-sm leading-6 text-muted">Sign in with the platform administrator account to manage businesses.</p>
|
||||
@if ($errors->any())
|
||||
<div class="mt-6 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('manager.login.store') }}" class="mt-8 space-y-5">
|
||||
@csrf
|
||||
<label class="block text-sm font-bold">Email
|
||||
<input name="email" value="{{ old('email') }}" type="email" required autofocus class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
|
||||
</label>
|
||||
<label class="block text-sm font-bold">Password
|
||||
<input name="password" type="password" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
|
||||
</label>
|
||||
<x-ui.button type="submit" class="w-full">Sign in</x-ui.button>
|
||||
</form>
|
||||
</main>
|
||||
</x-layouts.public>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Manager · {{ config('app.name') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-stone text-ink">
|
||||
<header class="border-b border-line bg-white">
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-5 py-5 sm:px-8 lg:px-12">
|
||||
<a href="{{ route('manager.dashboard') }}" class="text-sm font-black tracking-tight">NextChair <span class="font-normal text-muted">Manager</span></a>
|
||||
<div class="flex items-center gap-4 text-xs font-bold uppercase tracking-widest">
|
||||
<span class="hidden text-muted sm:inline">{{ auth()->user()->name }}</span>
|
||||
<form method="POST" action="{{ route('auth.signout') }}">@csrf<button class="text-muted hover:text-ink">Sign out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{{ $slot }}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +1,26 @@
|
||||
<x-layouts.public :business="null"><main class="mx-auto flex min-h-screen max-w-xl flex-col justify-center px-5 py-16 lg:px-10"><p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">NextChair setup</p><h1 class="mt-3 text-4xl font-black tracking-tight">Create your business</h1><p class="mt-4 text-sm leading-6 text-muted">Set up the first business owner and start configuring your booking workspace.</p>@if (session('status'))<div class="mt-6 rounded-tile bg-accent p-4 text-sm font-bold text-ink">{{ session('status') }}</div><a href="{{ route('auth.signin') }}" class="mt-6 text-sm font-bold text-accent-foreground underline">Sign in</a>@else @if ($errors->any())<div class="mt-6 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>@endif<form method="POST" action="{{ route('install.store') }}" class="mt-8 space-y-5">@csrf<label class="block text-sm font-bold">Business name<input name="businessName" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><label class="block text-sm font-bold">Business slug<input name="slug" required placeholder="my-barbershop" class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><label class="block text-sm font-bold">Domain <span class="font-normal text-muted">(optional)</span><input name="domain" placeholder="book.example.com" class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><div class="grid gap-5 sm:grid-cols-2"><label class="block text-sm font-bold">Owner name<input name="adminName" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><label class="block text-sm font-bold">Owner email<input name="adminEmail" type="email" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label></div><label class="block text-sm font-bold">Password<input name="password" type="password" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><label class="block text-sm font-bold">Confirm password<input name="password_confirmation" type="password" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3"></label><x-ui.button type="submit" class="w-full">Finish setup</x-ui.button></form>@endif</main></x-layouts.public>
|
||||
<x-layouts.public :business="null">
|
||||
<main class="mx-auto flex min-h-screen max-w-xl flex-col justify-center px-5 py-16 lg:px-10">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">NextChair setup</p>
|
||||
<h1 class="mt-3 text-4xl font-black tracking-tight">Create the platform administrator</h1>
|
||||
<p class="mt-4 text-sm leading-6 text-muted">This first account manages the NextChair platform. Businesses and their owners are created later from the protected Manager area.</p>
|
||||
@if ($errors->any())
|
||||
<div class="mt-6 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('install.store') }}" class="mt-8 space-y-5">
|
||||
@csrf
|
||||
<label class="block text-sm font-bold">Your name
|
||||
<input name="adminName" value="{{ old('adminName') }}" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
|
||||
</label>
|
||||
<label class="block text-sm font-bold">Email
|
||||
<input name="adminEmail" value="{{ old('adminEmail') }}" type="email" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
|
||||
</label>
|
||||
<label class="block text-sm font-bold">Password
|
||||
<input name="password" type="password" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
|
||||
</label>
|
||||
<label class="block text-sm font-bold">Confirm password
|
||||
<input name="password_confirmation" type="password" required class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">
|
||||
</label>
|
||||
<x-ui.button type="submit" class="w-full">Finish platform setup</x-ui.button>
|
||||
</form>
|
||||
</main>
|
||||
</x-layouts.public>
|
||||
@@ -0,0 +1,22 @@
|
||||
<x-layouts.manager>
|
||||
<main class="mx-auto max-w-4xl px-5 py-10 sm:px-8 lg:px-12 lg:py-14">
|
||||
<a href="{{ route('manager.dashboard') }}" class="text-xs font-bold uppercase tracking-widest text-muted">← Businesses</a>
|
||||
<div class="mt-10"><p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">New tenant</p><h1 class="mt-3 text-4xl font-black tracking-tight">Create a business</h1><p class="mt-3 max-w-2xl text-sm leading-6 text-muted">Provision the tenant, its defaults, and the owner’s secure setup invitation together.</p></div>
|
||||
@if ($errors->any())<div class="mt-8 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>@endif
|
||||
<form method="POST" action="{{ route('manager.businesses.store') }}" class="mt-8 space-y-8">
|
||||
@csrf
|
||||
<section class="rounded-jumbo border border-line bg-white p-6 sm:p-8"><h2 class="text-lg font-black">Business details</h2><div class="mt-6 grid gap-5 sm:grid-cols-2">
|
||||
<label class="block text-sm font-bold sm:col-span-2">Business name<input name="name" value="{{ old('name') }}" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
|
||||
<label class="block text-sm font-bold">Short name <span class="font-normal text-muted">(optional)</span><input name="shortName" value="{{ old('shortName') }}" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
|
||||
<label class="block text-sm font-bold">Slug<input name="slug" value="{{ old('slug') }}" placeholder="blake-heritage" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
|
||||
<label class="block text-sm font-bold sm:col-span-2">Primary domain<input name="domain" value="{{ old('domain') }}" placeholder="book.example.com" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"><span class="mt-2 block text-xs font-normal text-muted">This hostname routes public and owner requests to this tenant.</span></label>
|
||||
<label class="block text-sm font-bold">Business email<input name="email" value="{{ old('email') }}" type="email" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
|
||||
<label class="block text-sm font-bold">Phone<input name="phone" value="{{ old('phone') }}" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
|
||||
<label class="block text-sm font-bold">Timezone<select name="timezone" class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">@foreach (DateTimeZone::listIdentifiers() as $timezone)<option value="{{ $timezone }}" @selected(old('timezone', 'Europe/London') === $timezone)>{{ $timezone }}</option>@endforeach</select></label>
|
||||
<label class="block text-sm font-bold">Currency<input name="currency" value="{{ old('currency', 'GBP') }}" maxlength="3" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label>
|
||||
</div></section>
|
||||
<section class="rounded-jumbo border border-line bg-white p-6 sm:p-8"><h2 class="text-lg font-black">Business owner</h2><p class="mt-2 text-sm text-muted">The owner receives a one-time link to choose their password.</p><div class="mt-6 grid gap-5 sm:grid-cols-2"><label class="block text-sm font-bold">Owner name<input name="ownerName" value="{{ old('ownerName') }}" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Owner email<input name="ownerEmail" value="{{ old('ownerEmail') }}" type="email" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label></div></section>
|
||||
<div class="flex justify-end"><x-ui.button type="submit">Provision business</x-ui.button></div>
|
||||
</form>
|
||||
</main>
|
||||
</x-layouts.manager>
|
||||
@@ -0,0 +1,8 @@
|
||||
<x-layouts.manager>
|
||||
<main class="mx-auto max-w-4xl px-5 py-10 sm:px-8 lg:px-12 lg:py-14">
|
||||
<a href="{{ route('manager.businesses.show', $business) }}" class="text-xs font-bold uppercase tracking-widest text-muted">← {{ $business->name }}</a>
|
||||
<div class="mt-10"><p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">Tenant settings</p><h1 class="mt-3 text-4xl font-black tracking-tight">Edit business</h1></div>
|
||||
@if ($errors->any())<div class="mt-8 rounded-tile border border-red-200 bg-red-50 p-4 text-sm text-red-700">{{ $errors->first() }}</div>@endif
|
||||
<form method="POST" action="{{ route('manager.businesses.update', $business) }}" class="mt-8 rounded-jumbo border border-line bg-white p-6 sm:p-8">@csrf @method('PUT')<div class="grid gap-5 sm:grid-cols-2"><label class="block text-sm font-bold sm:col-span-2">Business name<input name="name" value="{{ old('name', $business->name) }}" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Short name<input name="shortName" value="{{ old('shortName', $business->shortName) }}" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Slug<input name="slug" value="{{ old('slug', $business->slug) }}" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold sm:col-span-2">Primary domain<input name="domain" value="{{ old('domain', $business->primaryDomain?->hostname) }}" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Business email<input name="email" value="{{ old('email', $business->email) }}" type="email" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Phone<input name="phone" value="{{ old('phone', $business->phone) }}" class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label><label class="block text-sm font-bold">Timezone<select name="timezone" class="mt-2 w-full rounded-tile border border-line bg-white px-4 py-3">@foreach (DateTimeZone::listIdentifiers() as $timezone)<option value="{{ $timezone }}" @selected(old('timezone', $business->timezone) === $timezone)>{{ $timezone }}</option>@endforeach</select></label><label class="block text-sm font-bold">Currency<input name="currency" value="{{ old('currency', $business->currency) }}" maxlength="3" required class="mt-2 w-full rounded-tile border border-line px-4 py-3"></label></div><div class="mt-8 flex justify-end"><x-ui.button type="submit">Save changes</x-ui.button></div></form>
|
||||
</main>
|
||||
</x-layouts.manager>
|
||||
@@ -0,0 +1,10 @@
|
||||
<x-layouts.manager>
|
||||
<main class="mx-auto max-w-6xl px-5 py-10 sm:px-8 lg:px-12 lg:py-14">
|
||||
<a href="{{ route('manager.dashboard') }}" class="text-xs font-bold uppercase tracking-widest text-muted">← Businesses</a>
|
||||
<div class="mt-10 flex flex-col justify-between gap-5 sm:flex-row sm:items-end"><div><p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">Business tenant</p><h1 class="mt-3 text-4xl font-black tracking-tight">{{ $business->name }}</h1><p class="mt-3 text-sm text-muted">{{ $business->slug }}</p></div><x-ui.button variant="secondary" href="{{ route('manager.businesses.edit', $business) }}">Edit business</x-ui.button></div>
|
||||
@if (session('status'))<div class="mt-8 rounded-tile bg-accent p-4 text-sm font-bold text-ink">{{ session('status') }}</div>@endif
|
||||
@if (session('owner_invitation_url'))<div class="mt-4 rounded-jumbo border border-amber-200 bg-amber-50 p-6"><p class="text-xs font-bold uppercase tracking-widest text-amber-900">Owner setup link</p><p class="mt-2 text-sm text-amber-900">Share this link securely with the business owner. It expires after seven days and can only be used once.</p><code class="mt-4 block break-all rounded-tile bg-white p-3 text-xs text-ink">{{ session('owner_invitation_url') }}</code></div>@endif
|
||||
<section class="mt-10 grid gap-4 sm:grid-cols-3"><x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Primary domain</p><p class="mt-3 font-black">{{ $business->primaryDomain?->hostname ?: 'Not configured' }}</p></x-ui.card><x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Timezone</p><p class="mt-3 font-black">{{ $business->timezone }}</p></x-ui.card><x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Defaults</p><p class="mt-3 font-black">{{ $business->hours->count() }} days · {{ $business->staff->count() }} staff</p></x-ui.card></section>
|
||||
<div class="mt-10 grid gap-8 lg:grid-cols-2"><section><h2 class="text-lg font-black">Domains</h2><div class="mt-4 space-y-3">@foreach ($business->domains as $domain)<div class="flex items-center justify-between rounded-tile border border-line bg-white p-4"><div><p class="font-bold">{{ $domain->hostname }}</p><p class="mt-1 text-xs text-muted">{{ $domain->isPrimary ? 'Primary' : 'Additional' }}</p></div><x-ui.badge tone="{{ $domain->isActive ? 'accent' : 'default' }}">{{ $domain->isActive ? 'Active' : 'Inactive' }}</x-ui.badge></div>@endforeach</div></section><section><h2 class="text-lg font-black">Staff and owners</h2><div class="mt-4 space-y-3">@foreach ($business->staff as $member)<div class="rounded-tile border border-line bg-white p-4"><p class="font-bold">{{ $member->user?->name ?: $member->user?->email }}</p><p class="mt-1 text-xs text-muted">{{ $member->role?->name }} · {{ $member->user?->email }}</p></div>@endforeach</div></section></div>
|
||||
</main>
|
||||
</x-layouts.manager>
|
||||
@@ -0,0 +1,39 @@
|
||||
<x-layouts.manager>
|
||||
<main class="mx-auto max-w-7xl px-5 py-10 sm:px-8 lg:px-12 lg:py-14">
|
||||
<div class="flex flex-col justify-between gap-5 sm:flex-row sm:items-end">
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.3em] text-accent-foreground">Platform operations</p>
|
||||
<h1 class="mt-3 text-4xl font-black tracking-tight">Businesses</h1>
|
||||
<p class="mt-3 text-sm text-muted">Create and maintain isolated NextChair tenants.</p>
|
||||
</div>
|
||||
<x-ui.button href="{{ route('manager.businesses.create') }}">Create business</x-ui.button>
|
||||
</div>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="mt-8 rounded-tile bg-accent p-4 text-sm font-bold text-ink">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<section class="mt-10 grid gap-4 sm:grid-cols-3">
|
||||
<x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Businesses</p><p class="mt-3 text-3xl font-black">{{ $businessCount }}</p></x-ui.card>
|
||||
<x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Tenant model</p><p class="mt-3 text-lg font-black">Domain routed</p></x-ui.card>
|
||||
<x-ui.card class="p-5"><p class="text-[10px] font-bold uppercase tracking-widest text-muted">Provisioning</p><p class="mt-3 text-lg font-black">Owner invited</p></x-ui.card>
|
||||
</section>
|
||||
|
||||
<section class="mt-10 space-y-3">
|
||||
@forelse ($businesses as $business)
|
||||
@php($owner = $business->staff->first(fn ($member) => $member->role?->name === 'Owner'))
|
||||
<a href="{{ route('manager.businesses.show', $business) }}" class="block rounded-jumbo border border-line bg-white p-6 transition hover:border-ink">
|
||||
<div class="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-3"><h2 class="text-xl font-black">{{ $business->name }}</h2><x-ui.badge tone="accent">Active</x-ui.badge></div>
|
||||
<p class="mt-2 text-sm text-muted">{{ $business->primaryDomain?->hostname ?: 'No primary domain' }} · {{ $business->slug }}</p>
|
||||
</div>
|
||||
<div class="text-left text-sm sm:text-right"><p class="font-bold">{{ $owner?->user?->email ?: 'Owner setup pending' }}</p><p class="mt-1 text-xs text-muted">{{ $business->staff_count }} staff · {{ $business->domains_count }} domain{{ $business->domains_count === 1 ? '' : 's' }}</p></div>
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<div class="rounded-jumbo border border-dashed border-line bg-white p-12 text-center"><p class="text-lg font-bold">No businesses yet</p><p class="mt-2 text-sm text-muted">Create the first tenant to begin onboarding an owner.</p><a href="{{ route('manager.businesses.create') }}" class="mt-5 inline-block text-xs font-bold uppercase tracking-widest underline">Create a business</a></div>
|
||||
@endforelse
|
||||
</section>
|
||||
</main>
|
||||
</x-layouts.manager>
|
||||
+17
-3
@@ -15,6 +15,10 @@ use App\Http\Controllers\CustomerAppointmentsController;
|
||||
use App\Http\Controllers\OwnerTeamController;
|
||||
use App\Http\Controllers\OwnerHoursController;
|
||||
use App\Http\Controllers\InstallController;
|
||||
use App\Http\Controllers\PlatformAuthController;
|
||||
use App\Http\Controllers\ManagerDashboardController;
|
||||
use App\Http\Controllers\ManagerBusinessController;
|
||||
use App\Http\Controllers\OwnerInvitationController;
|
||||
use App\Http\Controllers\CustomerProfileController;
|
||||
use App\Http\Controllers\OwnerReportsController;
|
||||
use App\Http\Controllers\OwnerReviewsController;
|
||||
@@ -23,6 +27,12 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::get('/install', [InstallController::class, 'create'])->name('install');
|
||||
Route::post('/install', [InstallController::class, 'store'])->name('install.store');
|
||||
|
||||
Route::get('/manager/sign-in', [PlatformAuthController::class, 'showLogin'])->name('manager.login');
|
||||
Route::post('/manager/sign-in', [PlatformAuthController::class, 'login'])->name('manager.login.store');
|
||||
Route::get('/invite/{token}', [OwnerInvitationController::class, 'show'])->name('owner.invite');
|
||||
Route::post('/invite/{token}', [OwnerInvitationController::class, 'accept'])->name('owner.invite.accept');
|
||||
Route::post('/sign-out', [AuthController::class, 'logout'])->middleware('auth')->name('auth.signout');
|
||||
|
||||
Route::middleware('tenant')->group(function (): void {
|
||||
Route::get('/', PublicBusinessController::class)->name('public.home');
|
||||
Route::get('/welcome', [AuthController::class, 'showWelcome'])->name('auth.welcome');
|
||||
@@ -38,7 +48,6 @@ Route::middleware('tenant')->group(function (): void {
|
||||
Route::get('/create-account', [AuthController::class, 'showRegister'])->name('register');
|
||||
Route::get('/forgot-password', [AuthController::class, 'showForgot'])->name('auth.forgot');
|
||||
Route::post('/forgot-password', [AuthController::class, 'forgot'])->name('auth.forgot.store');
|
||||
Route::post('/sign-out', [AuthController::class, 'logout'])->middleware('auth')->name('auth.signout');
|
||||
Route::get('/book', [BookingController::class, 'services'])->name('booking.services');
|
||||
Route::post('/book', [BookingController::class, 'selectServices'])->name('booking.services.store');
|
||||
Route::get('/book/barber-time', [BookingController::class, 'time'])->name('booking.time');
|
||||
@@ -79,6 +88,11 @@ Route::middleware('tenant')->group(function (): void {
|
||||
Route::post('/owner/settings/reviews/{review}/toggle', [OwnerReviewsController::class, 'toggle'])->middleware(['auth', 'staff.permission:MANAGE_REVIEWS'])->name('owner.reviews.toggle');
|
||||
});
|
||||
|
||||
Route::prefix('manager')->name('manager.')->group(function (): void {
|
||||
Route::view('/', 'manager.placeholder')->name('dashboard');
|
||||
Route::prefix('manager')->name('manager.')->middleware('platform.admin')->group(function (): void {
|
||||
Route::get('/', ManagerDashboardController::class)->name('dashboard');
|
||||
Route::get('/businesses/create', [ManagerBusinessController::class, 'create'])->name('businesses.create');
|
||||
Route::post('/businesses', [ManagerBusinessController::class, 'store'])->name('businesses.store');
|
||||
Route::get('/businesses/{business}', [ManagerBusinessController::class, 'show'])->name('businesses.show');
|
||||
Route::get('/businesses/{business}/edit', [ManagerBusinessController::class, 'edit'])->name('businesses.edit');
|
||||
Route::put('/businesses/{business}', [ManagerBusinessController::class, 'update'])->name('businesses.update');
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ApplicationInstallation;
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -10,25 +12,41 @@ class InstallationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_a_clean_database_can_be_installed_once(): void
|
||||
public function test_a_clean_database_creates_only_a_platform_super_admin(): void
|
||||
{
|
||||
$this->get('/install')->assertOk()->assertSee('Create your business');
|
||||
$this->get('/install')->assertOk()->assertSee('Create the platform administrator');
|
||||
|
||||
$response = $this->post('/install', [
|
||||
'businessName' => 'Installed Shop',
|
||||
'slug' => 'installed-shop',
|
||||
'domain' => 'localhost',
|
||||
'adminName' => 'First Owner',
|
||||
'adminEmail' => 'first-owner@example.test',
|
||||
'adminName' => 'Platform Admin',
|
||||
'adminEmail' => 'admin@example.test',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('install'));
|
||||
$this->assertDatabaseHas('ApplicationInstallation', ['id' => 'singleton', 'status' => 'INSTALLED']);
|
||||
$this->assertDatabaseHas('Business', ['slug' => 'installed-shop']);
|
||||
$this->assertDatabaseHas('User', ['email' => 'first-owner@example.test']);
|
||||
$this->get('/install')->assertRedirect(route('public.home'));
|
||||
$response->assertRedirect(route('manager.dashboard'));
|
||||
$this->assertDatabaseHas('ApplicationInstallation', [
|
||||
'id' => 'singleton',
|
||||
'status' => 'INSTALLED',
|
||||
'primaryBusinessId' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('User', ['email' => 'admin@example.test', 'isSuperAdmin' => 1]);
|
||||
$this->assertSame(0, Business::count());
|
||||
$this->get('/install')->assertRedirect(route('manager.dashboard'));
|
||||
$this->assertSame(1, ApplicationInstallation::count());
|
||||
}
|
||||
|
||||
public function test_installation_cannot_be_repeated(): void
|
||||
{
|
||||
$admin = User::factory()->create(['isSuperAdmin' => true]);
|
||||
ApplicationInstallation::create(['id' => 'singleton', 'status' => 'INSTALLED', 'installedAt' => now(), 'initialAdminUserId' => $admin->id]);
|
||||
|
||||
$this->post('/install', [
|
||||
'adminName' => 'Another Admin',
|
||||
'adminEmail' => 'another@example.test',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
])->assertRedirect(route('manager.login'));
|
||||
|
||||
$this->assertDatabaseMissing('User', ['email' => 'another@example.test']);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Models\Business;
|
||||
use App\Models\BusinessDomain;
|
||||
use App\Models\CustomerProfile;
|
||||
use App\Models\User;
|
||||
use App\Services\Businesses\BusinessProvisioningService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -13,42 +14,85 @@ class TenantAndAuthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_the_host_selects_only_its_business(): void
|
||||
public function test_manager_is_restricted_to_platform_super_admins(): void
|
||||
{
|
||||
$this->seed();
|
||||
$this->get('/manager')->assertRedirect(route('manager.login'));
|
||||
|
||||
$other = Business::create([
|
||||
'slug' => 'other-barbershop',
|
||||
'name' => 'Other Barbershop',
|
||||
$member = User::factory()->create(['isSuperAdmin' => false]);
|
||||
$this->actingAs($member)->get('/manager')->assertForbidden();
|
||||
|
||||
$admin = User::factory()->create(['isSuperAdmin' => true]);
|
||||
$this->actingAs($admin)->get('/manager')->assertOk()->assertSee('Businesses');
|
||||
}
|
||||
|
||||
public function test_manager_provisions_an_isolated_business_and_owner_invitation(): void
|
||||
{
|
||||
$admin = User::factory()->create(['isSuperAdmin' => true]);
|
||||
|
||||
$response = $this->actingAs($admin)->post('/manager/businesses', [
|
||||
'name' => 'New Chair Studio',
|
||||
'shortName' => 'New Chair',
|
||||
'slug' => 'new-chair-studio',
|
||||
'domain' => 'new-chair.test',
|
||||
'email' => 'hello@new-chair.test',
|
||||
'timezone' => 'Europe/London',
|
||||
'currency' => 'GBP',
|
||||
'locale' => 'en-GB',
|
||||
]);
|
||||
BusinessDomain::create([
|
||||
'businessId' => $other->id,
|
||||
'hostname' => 'other.test',
|
||||
'isPrimary' => true,
|
||||
'isActive' => true,
|
||||
'ownerName' => 'Owner Person',
|
||||
'ownerEmail' => 'owner@new-chair.test',
|
||||
]);
|
||||
|
||||
$this->get('http://localhost/')
|
||||
->assertOk()
|
||||
->assertSee('Blake Heritage Barbershop')
|
||||
->assertSee('Classic Haircut')
|
||||
->assertDontSeeText('Other Barbershop');
|
||||
$business = Business::where('slug', 'new-chair-studio')->firstOrFail();
|
||||
$response->assertRedirect(route('manager.businesses.show', $business));
|
||||
$this->assertDatabaseHas('BusinessSettings', ['businessId' => $business->id]);
|
||||
$this->assertDatabaseHas('BookingRule', ['businessId' => $business->id]);
|
||||
$this->assertSame(7, $business->hours()->count());
|
||||
$this->assertDatabaseHas('Role', ['businessId' => $business->id, 'name' => 'Owner']);
|
||||
$this->assertDatabaseHas('StaffInvitation', ['businessId' => $business->id, 'email' => 'owner@new-chair.test']);
|
||||
$this->assertDatabaseMissing('User', ['email' => 'owner@new-chair.test', 'isSuperAdmin' => 1]);
|
||||
}
|
||||
|
||||
$this->get('http://other.test/')
|
||||
public function test_owner_invitation_is_one_time_and_redirects_to_the_business_domain(): void
|
||||
{
|
||||
$result = $this->app->make(BusinessProvisioningService::class)->provision([
|
||||
'name' => 'Invite Shop', 'slug' => 'invite-shop', 'domain' => 'invite.test',
|
||||
], ['name' => 'Invited Owner', 'email' => 'invited-owner@example.test']);
|
||||
|
||||
$this->get('http://invite.test/invite/'.$result->ownerInvitation->token)
|
||||
->assertOk()
|
||||
->assertSee('Other Barbershop')
|
||||
->assertDontSeeText('Classic Haircut');
|
||||
->assertSee('Finish your owner setup');
|
||||
|
||||
$this->post('http://invite.test/invite/'.$result->ownerInvitation->token, [
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
])->assertRedirect('http://invite.test/owner');
|
||||
|
||||
$this->assertNotNull($result->ownerInvitation->fresh()->acceptedAt);
|
||||
$this->assertAuthenticatedAs(User::where('email', 'invited-owner@example.test')->firstOrFail());
|
||||
$this->get('http://invite.test/invite/'.$result->ownerInvitation->token)->assertStatus(410);
|
||||
}
|
||||
|
||||
public function test_the_host_selects_only_its_business(): void
|
||||
{
|
||||
$provisioner = $this->app->make(BusinessProvisioningService::class);
|
||||
$first = $provisioner->provision([
|
||||
'name' => 'First Barbershop', 'slug' => 'first-barbershop', 'domain' => 'first.test',
|
||||
], ['name' => 'First Owner', 'email' => 'first-owner@example.test']);
|
||||
$second = $provisioner->provision([
|
||||
'name' => 'Second Barbershop', 'slug' => 'second-barbershop', 'domain' => 'second.test',
|
||||
], ['name' => 'Second Owner', 'email' => 'second-owner@example.test']);
|
||||
|
||||
$this->get('http://first.test/')->assertOk()->assertSee('First Barbershop')->assertDontSee('Second Barbershop');
|
||||
$this->get('http://second.test/')->assertOk()->assertSee('Second Barbershop')->assertDontSee('First Barbershop');
|
||||
$this->assertNotSame($first->business->id, $second->business->id);
|
||||
}
|
||||
|
||||
public function test_registration_creates_a_customer_profile_in_the_current_tenant(): void
|
||||
{
|
||||
$this->seed();
|
||||
$business = $this->app->make(BusinessProvisioningService::class)->provision([
|
||||
'name' => 'Customer Shop', 'slug' => 'customer-shop', 'domain' => 'customer.test',
|
||||
], ['name' => 'Shop Owner', 'email' => 'shop-owner@example.test'])->business;
|
||||
|
||||
$response = $this->withHeaders(['Host' => 'localhost'])
|
||||
->post('/register', [
|
||||
$response = $this->post('http://customer.test/register', [
|
||||
'firstName' => 'Taylor',
|
||||
'lastName' => 'Member',
|
||||
'email' => 'taylor@example.test',
|
||||
@@ -59,17 +103,9 @@ class TenantAndAuthTest extends TestCase
|
||||
|
||||
$response->assertRedirect(route('customer.dashboard'));
|
||||
$this->assertAuthenticated();
|
||||
|
||||
$this->assertDatabaseHas('User', ['email' => 'taylor@example.test']);
|
||||
$this->assertDatabaseHas('CustomerProfile', [
|
||||
'email' => 'taylor@example.test',
|
||||
'isGuest' => 0,
|
||||
'businessId' => Business::where('slug', 'demo-barbershop')->value('id'),
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('CustomerProfile', ['email' => 'taylor@example.test', 'isGuest' => 0, 'businessId' => $business->id]);
|
||||
$this->get('http://customer.test/account')->assertOk()->assertSee('Good to see you, Taylor');
|
||||
$this->get('http://customer.test/owner')->assertForbidden();
|
||||
$this->assertSame(1, CustomerProfile::where('email', 'taylor@example.test')->count());
|
||||
$this->withHeaders(['Host' => 'localhost'])->get('/account')->assertOk()->assertSee('Good to see you, Taylor');
|
||||
$this->withHeaders(['Host' => 'localhost'])->get('/owner')->assertForbidden();
|
||||
$this->assertNotNull(User::where('email', 'taylor@example.test')->first());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user