Faza 2: multi-tenancy + Filament dual panels + seed PSauto
Schema centrală: - companies (slug unique, status, plan_id, settings JSON, trial/active dates) - super_admins (operator platform) - plans (free/basic/pro) Schema tenant (toate cu company_id NOT NULL): - users (UNIQUE company_id+email) - clients - vehicles Tenancy core: - App\Tenancy\TenantManager singleton - App\Models\Concerns\BelongsToTenant trait + TenantScope - ResolveTenant middleware (slug → Company, 404 pentru rezervate/missing) - CheckTenantStatus middleware (suspended/expired/archived) - Fail-safe: TenantScope returns 0 rows când tenant nu e rezolvat Auth guards: - 'central' guard cu super_admins provider (panou platform) - 'web' guard cu users provider (per-tenant) Filament panels: - CentralPanelProvider la service.mir.md/admin - TenantPanelProvider la <slug>.service.mir.md/app - CompanyResource (central): CRUD companii cu status badge + filtre - ClientResource (tenant): CRUD clienți cu status, sursă, sold - VehicleResource (tenant): CRUD mașini cu marcă/model/VIN Seed: - 3 plans (free/basic/pro) - super-admin: vasyka.moraru@gmail.com / admin123 - demo company 'psauto' cu admin user admin@psauto.md / admin123 - 3 clienți + 3 mașini preluate din AutoCRM.html Bootstrap: - TrustProxies (Cloudflare→Traefik HTTPS detection) - forceScheme/forceRootUrl când APP_URL e HTTPS - Helper global tenant() în app/helpers.php (autoload via composer) - RUN_SEED env var în entrypoint pentru db:seed condiționat
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Central\Resources;
|
||||
|
||||
use App\Filament\Central\Resources\CompanyResource\Pages;
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CompanyResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Company::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
||||
|
||||
protected static ?string $navigationLabel = 'Companii';
|
||||
|
||||
protected static ?string $modelLabel = 'companie';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'companii';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Forms\Components\Section::make('Identificare')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('slug')
|
||||
->required()
|
||||
->alphaDash()
|
||||
->lowercase()
|
||||
->maxLength(30)
|
||||
->unique(ignoreRecord: true)
|
||||
->helperText('Subdomeniul: <slug>.service.mir.md'),
|
||||
Forms\Components\TextInput::make('name')->required()->maxLength(120),
|
||||
Forms\Components\TextInput::make('display_name')->maxLength(120),
|
||||
Forms\Components\TextInput::make('city')->maxLength(60),
|
||||
]),
|
||||
Forms\Components\Section::make('Contact')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('contact_name')->maxLength(120),
|
||||
Forms\Components\TextInput::make('phone')->tel()->maxLength(40),
|
||||
Forms\Components\TextInput::make('email')->email()->maxLength(120),
|
||||
]),
|
||||
Forms\Components\Section::make('Abonament')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Select::make('status')
|
||||
->options([
|
||||
'trial' => 'Trial',
|
||||
'active' => 'Activ',
|
||||
'expired' => 'Expirat',
|
||||
'suspended' => 'Suspendat',
|
||||
'archived' => 'Arhivat',
|
||||
])
|
||||
->default('trial')
|
||||
->required(),
|
||||
Forms\Components\Select::make('plan_id')
|
||||
->label('Plan')
|
||||
->options(fn () => Plan::pluck('name', 'id'))
|
||||
->searchable(),
|
||||
Forms\Components\DateTimePicker::make('trial_ends_at')->label('Trial expiră la'),
|
||||
Forms\Components\DateTimePicker::make('active_until')->label('Abonament până la'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')->sortable(),
|
||||
Tables\Columns\TextColumn::make('slug')
|
||||
->searchable()
|
||||
->copyable()
|
||||
->url(fn (Company $r) => $r->url('/app'))
|
||||
->openUrlInNewTab(),
|
||||
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->colors([
|
||||
'success' => ['active'],
|
||||
'warning' => ['trial'],
|
||||
'danger' => ['suspended', 'expired'],
|
||||
'gray' => ['archived'],
|
||||
]),
|
||||
Tables\Columns\TextColumn::make('plan.name')->label('Plan')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('city')->toggleable(),
|
||||
Tables\Columns\TextColumn::make('users_count')->counts('users')->label('Useri'),
|
||||
Tables\Columns\TextColumn::make('created_at')->date()->sortable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')->options([
|
||||
'trial' => 'Trial', 'active' => 'Activ', 'expired' => 'Expirat',
|
||||
'suspended' => 'Suspendat', 'archived' => 'Arhivat',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListCompanies::route('/'),
|
||||
'create' => Pages\CreateCompany::route('/create'),
|
||||
'edit' => Pages\EditCompany::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Central\Resources\CompanyResource\Pages;
|
||||
|
||||
use App\Filament\Central\Resources\CompanyResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateCompany extends CreateRecord
|
||||
{
|
||||
protected static string $resource = CompanyResource::class;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Central\Resources\CompanyResource\Pages;
|
||||
|
||||
use App\Filament\Central\Resources\CompanyResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditCompany extends EditRecord
|
||||
{
|
||||
protected static string $resource = CompanyResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Central\Resources\CompanyResource\Pages;
|
||||
|
||||
use App\Filament\Central\Resources\CompanyResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListCompanies extends ListRecords
|
||||
{
|
||||
protected static string $resource = CompanyResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\ClientResource\Pages;
|
||||
use App\Models\Tenant\Client;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ClientResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Client::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-users';
|
||||
|
||||
protected static ?string $navigationLabel = 'Clienți';
|
||||
|
||||
protected static ?string $modelLabel = 'client';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'clienți';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Forms\Components\Section::make('Date generale')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Select::make('type')
|
||||
->label('Tip')
|
||||
->options(['individual' => 'Persoană fizică', 'company' => 'Persoană juridică'])
|
||||
->default('individual')
|
||||
->required()
|
||||
->live(),
|
||||
Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(120),
|
||||
Forms\Components\TextInput::make('company_name')
|
||||
->label('Denumire companie')
|
||||
->visible(fn (Forms\Get $get) => $get('type') === 'company')
|
||||
->maxLength(160),
|
||||
Forms\Components\Select::make('status')
|
||||
->options([
|
||||
'new' => 'Nou', 'active' => 'Activ', 'vip' => 'VIP',
|
||||
'debtor' => 'Datornic', 'blocked' => 'Blocat', 'lost' => 'Pierdut',
|
||||
])
|
||||
->default('active')
|
||||
->required(),
|
||||
]),
|
||||
Forms\Components\Section::make('Contacte')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->required()->maxLength(40),
|
||||
Forms\Components\TextInput::make('phone_alt')->label('Telefon alternativ')->tel()->maxLength(40),
|
||||
Forms\Components\TextInput::make('email')->email()->maxLength(120),
|
||||
Forms\Components\TextInput::make('telegram')->maxLength(60),
|
||||
Forms\Components\TextInput::make('whatsapp')->maxLength(60),
|
||||
Forms\Components\TextInput::make('viber')->maxLength(60),
|
||||
]),
|
||||
Forms\Components\Section::make('Marketing')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('source')->label('Sursă')->maxLength(60),
|
||||
Forms\Components\TextInput::make('marketing_channel')->label('Canal marketing')->maxLength(60),
|
||||
]),
|
||||
Forms\Components\Section::make('Financiar')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('balance')->label('Sold')->numeric()->default(0),
|
||||
Forms\Components\TextInput::make('discount_pct')->label('Discount %')->numeric()->default(0),
|
||||
]),
|
||||
Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(3),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('phone')->searchable()->copyable(),
|
||||
Tables\Columns\TextColumn::make('email')->searchable()->toggleable(),
|
||||
Tables\Columns\TextColumn::make('vehicles_count')->counts('vehicles')->label('Mașini'),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->colors([
|
||||
'success' => ['active', 'vip'],
|
||||
'gray' => ['new'],
|
||||
'danger' => ['debtor', 'blocked', 'lost'],
|
||||
]),
|
||||
Tables\Columns\TextColumn::make('balance')
|
||||
->money(fn () => tenant()?->settings['currency'] ?? 'MDL')
|
||||
->color(fn ($state) => $state < 0 ? 'danger' : 'success'),
|
||||
Tables\Columns\TextColumn::make('created_at')->date()->sortable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')->options([
|
||||
'new' => 'Nou', 'active' => 'Activ', 'vip' => 'VIP',
|
||||
'debtor' => 'Datornic', 'blocked' => 'Blocat', 'lost' => 'Pierdut',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListClients::route('/'),
|
||||
'create' => Pages\CreateClient::route('/create'),
|
||||
'edit' => Pages\EditClient::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\ClientResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\ClientResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateClient extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ClientResource::class;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\ClientResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\ClientResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditClient extends EditRecord
|
||||
{
|
||||
protected static string $resource = ClientResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\ClientResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\ClientResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListClients extends ListRecords
|
||||
{
|
||||
protected static string $resource = ClientResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\VehicleResource\Pages;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class VehicleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Vehicle::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-truck';
|
||||
|
||||
protected static ?string $navigationLabel = 'Automobile';
|
||||
|
||||
protected static ?string $modelLabel = 'mașină';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'mașini';
|
||||
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Forms\Components\Section::make('Identificare')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Select::make('client_id')
|
||||
->label('Proprietar')
|
||||
->options(fn () => Client::pluck('name', 'id'))
|
||||
->searchable()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('plate')->label('Nr. înmatriculare')->maxLength(16),
|
||||
Forms\Components\TextInput::make('make')->label('Marca')->required()->maxLength(60),
|
||||
Forms\Components\TextInput::make('model')->required()->maxLength(60),
|
||||
Forms\Components\TextInput::make('year')->numeric()->minValue(1950)->maxValue(2100),
|
||||
Forms\Components\TextInput::make('vin')->maxLength(32),
|
||||
]),
|
||||
Forms\Components\Section::make('Tehnice')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('engine')->maxLength(60),
|
||||
Forms\Components\TextInput::make('gearbox')->maxLength(60),
|
||||
Forms\Components\Select::make('fuel')
|
||||
->options([
|
||||
'Benzină' => 'Benzină', 'Diesel' => 'Diesel', 'Hybrid' => 'Hybrid',
|
||||
'EV' => 'Electric', 'GPL' => 'GPL', 'GNC' => 'GNC',
|
||||
]),
|
||||
Forms\Components\TextInput::make('mileage')->label('Kilometraj')->numeric()->default(0),
|
||||
Forms\Components\TextInput::make('color')->maxLength(40),
|
||||
]),
|
||||
Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(3),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('plate')->label('Nr.')->searchable(),
|
||||
Tables\Columns\TextColumn::make('make')->sortable(),
|
||||
Tables\Columns\TextColumn::make('model'),
|
||||
Tables\Columns\TextColumn::make('year'),
|
||||
Tables\Columns\TextColumn::make('client.name')->label('Proprietar')->searchable(),
|
||||
Tables\Columns\TextColumn::make('vin')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('mileage')->label('Km')->numeric(),
|
||||
Tables\Columns\TextColumn::make('created_at')->date()->sortable(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListVehicles::route('/'),
|
||||
'create' => Pages\CreateVehicle::route('/create'),
|
||||
'edit' => Pages\EditVehicle::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\VehicleResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\VehicleResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateVehicle extends CreateRecord
|
||||
{
|
||||
protected static string $resource = VehicleResource::class;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\VehicleResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\VehicleResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditVehicle extends EditRecord
|
||||
{
|
||||
protected static string $resource = VehicleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\VehicleResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\VehicleResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListVehicles extends ListRecords
|
||||
{
|
||||
protected static string $resource = VehicleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Tenancy\TenantManager;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Blocks tenants whose company is suspended/expired/archived.
|
||||
* Must run AFTER ResolveTenant.
|
||||
*/
|
||||
class CheckTenantStatus
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$company = app(TenantManager::class)->current();
|
||||
|
||||
if (! $company) {
|
||||
return $next($request); // central context
|
||||
}
|
||||
|
||||
return match ($company->status) {
|
||||
'archived' => response('Cont arhivat.', 410),
|
||||
'suspended' => response()->view('tenant.suspended', ['company' => $company], 423),
|
||||
'expired' => response()->view('tenant.expired', ['company' => $company], 402),
|
||||
default => $next($request),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Reads the request Host, extracts the tenant slug (subdomain of CENTRAL_DOMAIN),
|
||||
* loads the matching Company and stores it in the TenantManager singleton.
|
||||
*
|
||||
* Examples (CENTRAL_DOMAIN=service.mir.md):
|
||||
* psauto.service.mir.md → slug="psauto"
|
||||
* service.mir.md → no tenant (central context)
|
||||
* www.service.mir.md → reserved → 404
|
||||
*/
|
||||
class ResolveTenant
|
||||
{
|
||||
/** Subdomains that are NEVER treated as tenant slugs. */
|
||||
public const RESERVED = [
|
||||
'www', 'api', 'app', 'admin', 'mail', 'mailpit',
|
||||
'git', 'gitea', 'coolify', 's3', 's3-admin',
|
||||
'ws', 'reverb', 'pulse', 'horizon',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next, ?string $required = null)
|
||||
{
|
||||
$host = $request->getHost();
|
||||
$central = config('tenancy.central_domains', []);
|
||||
$centralPrimary = config('app.central_domain') ?: $central[0] ?? 'service.mir.md';
|
||||
|
||||
// No subdomain → central context.
|
||||
if (in_array($host, $central, true)) {
|
||||
// Tenant panel must never run on the central host.
|
||||
if (str_starts_with($request->path(), 'app')) {
|
||||
throw new NotFoundHttpException('Tenant routes are not available on the central domain.');
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$slug = null;
|
||||
if (str_ends_with($host, ".{$centralPrimary}")) {
|
||||
$slug = substr($host, 0, -strlen(".{$centralPrimary}"));
|
||||
}
|
||||
|
||||
if (! $slug || in_array($slug, self::RESERVED, true) || str_contains($slug, '.')) {
|
||||
// Reserved or multi-level subdomain → not a tenant. 404.
|
||||
throw new NotFoundHttpException("Unknown subdomain: {$host}");
|
||||
}
|
||||
|
||||
$company = Company::where('slug', $slug)->first();
|
||||
|
||||
if (! $company) {
|
||||
throw new NotFoundHttpException("Tenant '{$slug}' not found.");
|
||||
}
|
||||
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
$request->attributes->set('tenant', $company);
|
||||
|
||||
if ($required === 'required' && ! app(TenantManager::class)->isResolved()) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Central;
|
||||
|
||||
use App\Models\Tenant\User;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
|
||||
use Stancl\Tenancy\Contracts\TenantWithDatabase;
|
||||
use Stancl\Tenancy\Database\Concerns\HasDatabase;
|
||||
use Stancl\Tenancy\Database\Concerns\HasDomains;
|
||||
|
||||
/**
|
||||
* Tenant model — extends Stancl Tenant for compatibility with the package
|
||||
* (so we can use stancl helpers later if we want to switch to multi-DB).
|
||||
*
|
||||
* In single-DB mode we don't use HasDatabase. Domain identification is
|
||||
* handled by our own ResolveTenant middleware (slug-based, not DNS records).
|
||||
*/
|
||||
class Company extends BaseTenant
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'companies';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'settings' => 'array',
|
||||
'data' => 'array',
|
||||
'trial_ends_at' => 'datetime',
|
||||
'active_until' => 'datetime',
|
||||
];
|
||||
|
||||
/** Stancl expects this to know what columns are NOT in the JSON `data` blob. */
|
||||
public static function getCustomColumns(): array
|
||||
{
|
||||
return [
|
||||
'id',
|
||||
'slug', 'name', 'display_name', 'city', 'phone', 'email', 'contact_name',
|
||||
'status', 'plan_id',
|
||||
'trial_ends_at', 'active_until',
|
||||
'settings',
|
||||
'created_at', 'updated_at', 'deleted_at',
|
||||
];
|
||||
}
|
||||
|
||||
public function plan()
|
||||
{
|
||||
return $this->belongsTo(Plan::class);
|
||||
}
|
||||
|
||||
public function users()
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return in_array($this->status, ['active', 'trial'], true);
|
||||
}
|
||||
|
||||
public function isAccessible(): bool
|
||||
{
|
||||
if ($this->status === 'archived' || $this->status === 'suspended') {
|
||||
return false;
|
||||
}
|
||||
if ($this->status === 'expired') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Get the URL for this tenant. */
|
||||
public function url(?string $path = '/'): string
|
||||
{
|
||||
$central = config('app.central_domain') ?: 'service.mir.md';
|
||||
return "https://{$this->slug}.{$central}{$path}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Central;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Plan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'slug', 'name', 'price_monthly', 'price_yearly', 'currency',
|
||||
'features', 'limits', 'is_active', 'is_public',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'features' => 'array',
|
||||
'limits' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'price_monthly' => 'decimal:2',
|
||||
'price_yearly' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function companies()
|
||||
{
|
||||
return $this->hasMany(Company::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Central;
|
||||
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class SuperAdmin extends Authenticatable implements FilamentUser
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $table = 'super_admins';
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'email', 'password', 'is_active', 'last_login_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password', 'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'last_login_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return $panel->getId() === 'central' && $this->is_active;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Scopes\TenantScope;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Trait applied on every tenant-scoped Eloquent model.
|
||||
* - Adds the global TenantScope so every query is filtered by company_id.
|
||||
* - On create, auto-fills company_id from the current tenant.
|
||||
* - Provides a `company()` relationship.
|
||||
*/
|
||||
trait BelongsToTenant
|
||||
{
|
||||
protected static function bootBelongsToTenant(): void
|
||||
{
|
||||
static::addGlobalScope(new TenantScope);
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->company_id)) {
|
||||
$tenant = app(TenantManager::class);
|
||||
if ($tenant->isResolved()) {
|
||||
$model->company_id = $tenant->currentId();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Scopes;
|
||||
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Scope;
|
||||
|
||||
/**
|
||||
* Auto-filter every query by the current tenant's company_id.
|
||||
* No-op when no tenant is resolved (central panel context).
|
||||
*/
|
||||
class TenantScope implements Scope
|
||||
{
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$tenant = app(TenantManager::class);
|
||||
|
||||
if (! $tenant->isResolved()) {
|
||||
// Fail-safe: no tenant set → return zero rows (prevents accidental
|
||||
// cross-tenant leak). Use withoutGlobalScopes() in central panel
|
||||
// to query across all tenants intentionally.
|
||||
$builder->whereRaw('0 = 1');
|
||||
return;
|
||||
}
|
||||
|
||||
$builder->where(
|
||||
$model->getTable() . '.company_id',
|
||||
$tenant->currentId()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Client extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'type', 'name', 'company_name',
|
||||
'phone', 'phone_alt', 'email',
|
||||
'telegram', 'whatsapp', 'viber',
|
||||
'source', 'marketing_channel', 'status',
|
||||
'balance', 'discount_pct', 'notes',
|
||||
'assigned_to', 'last_contact_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'balance' => 'decimal:2',
|
||||
'discount_pct' => 'decimal:2',
|
||||
'last_contact_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function vehicles(): HasMany
|
||||
{
|
||||
return $this->hasMany(Vehicle::class);
|
||||
}
|
||||
|
||||
public function assignedTo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_to');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
/**
|
||||
* Tenant-bound user. Belongs to exactly one Company.
|
||||
* UNIQUE(company_id, email) — same email can exist in different tenants
|
||||
* as completely separate accounts.
|
||||
*/
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
use BelongsToTenant, HasFactory, Notifiable, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'name', 'email', 'phone', 'avatar_url',
|
||||
'role', 'status', 'locale',
|
||||
'email_verified_at', 'password', 'last_login_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password', 'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'last_login_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return $panel->getId() === 'tenant'
|
||||
&& $this->status === 'active';
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Vehicle extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'client_id',
|
||||
'make', 'model', 'year', 'vin', 'plate',
|
||||
'engine', 'gearbox', 'fuel', 'mileage', 'color', 'notes',
|
||||
];
|
||||
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
public function getDisplayNameAttribute(): string
|
||||
{
|
||||
return trim("{$this->make} {$this->model} " . ($this->year ?: ''));
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->singleton(TenantManager::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
// Behind a TLS-terminating proxy (Cloudflare → Coolify Traefik → Octane).
|
||||
if (! $this->app->runningInConsole() && (str_starts_with(config('app.url'), 'https://') || env('FORCE_HTTPS'))) {
|
||||
URL::forceScheme('https');
|
||||
URL::forceRootUrl(config('app.url'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-12
@@ -10,8 +10,6 @@ use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
@@ -19,28 +17,32 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
/**
|
||||
* Central panel — only on the central domain (service.mir.md/admin).
|
||||
* Uses the 'central' auth guard (super_admins).
|
||||
*/
|
||||
class CentralPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->id('central')
|
||||
->path('admin')
|
||||
->domain(env('CENTRAL_DOMAIN', 'service.mir.md'))
|
||||
->login()
|
||||
->brandName('AutoCRM — Platformă')
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
'primary' => Color::Indigo,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->authGuard('central')
|
||||
->authPasswordBroker('super_admins')
|
||||
->discoverResources(in: app_path('Filament/Central/Resources'), for: 'App\\Filament\\Central\\Resources')
|
||||
->discoverPages(in: app_path('Filament/Central/Pages'), for: 'App\\Filament\\Central\\Pages')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Central/Widgets'), for: 'App\\Filament\\Central\\Widgets')
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Http\Middleware\CheckTenantStatus;
|
||||
use App\Http\Middleware\ResolveTenant;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
/**
|
||||
* Tenant panel — served on every <slug>.service.mir.md.
|
||||
* ResolveTenant middleware loads the current Company before any auth check.
|
||||
*/
|
||||
class TenantPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->id('tenant')
|
||||
->path('app')
|
||||
->login()
|
||||
->brandName('AutoCRM')
|
||||
->colors([
|
||||
'primary' => Color::Blue,
|
||||
])
|
||||
->authGuard('web')
|
||||
->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources')
|
||||
->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Tenant/Widgets'), for: 'App\\Filament\\Tenant\\Widgets')
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
ResolveTenant::class,
|
||||
CheckTenantStatus::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tenancy;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
|
||||
/**
|
||||
* Tenant context resolver. Stored in the application container as a
|
||||
* singleton so middleware can set the active tenant, and Eloquent
|
||||
* scopes / Filament resources can read it.
|
||||
*
|
||||
* Usage:
|
||||
* app(TenantManager::class)->setCurrent($company);
|
||||
* tenant() // returns Company|null
|
||||
*/
|
||||
class TenantManager
|
||||
{
|
||||
protected ?Company $current = null;
|
||||
|
||||
public function setCurrent(?Company $company): void
|
||||
{
|
||||
$this->current = $company;
|
||||
}
|
||||
|
||||
public function current(): ?Company
|
||||
{
|
||||
return $this->current;
|
||||
}
|
||||
|
||||
public function currentId(): ?int
|
||||
{
|
||||
return $this->current?->id;
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return $this->current !== null;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->current = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
if (! function_exists('tenant')) {
|
||||
/**
|
||||
* Get the current tenant Company, or null if in central context.
|
||||
*/
|
||||
function tenant(): ?\App\Models\Central\Company
|
||||
{
|
||||
return app(\App\Tenancy\TenantManager::class)->current();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user