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();
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -3,6 +3,7 @@
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -11,7 +12,15 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
// Trust Cloudflare + Coolify Traefik so HTTPS scheme is detected
|
||||
// and X-Forwarded-* headers are honored.
|
||||
$middleware->trustProxies(at: '*', headers:
|
||||
Request::HEADER_X_FORWARDED_FOR
|
||||
| Request::HEADER_X_FORWARDED_HOST
|
||||
| Request::HEADER_X_FORWARDED_PORT
|
||||
| Request::HEADER_X_FORWARDED_PROTO
|
||||
| Request::HEADER_X_FORWARDED_AWS_ELB
|
||||
);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@@ -2,5 +2,7 @@
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\Filament\AdminPanelProvider::class,
|
||||
App\Providers\TenancyServiceProvider::class,
|
||||
App\Providers\Filament\CentralPanelProvider::class,
|
||||
App\Providers\Filament\TenantPanelProvider::class,
|
||||
];
|
||||
|
||||
+4
-1
@@ -29,7 +29,10 @@
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"app/helpers.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
|
||||
+19
-81
@@ -1,96 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Central\SuperAdmin;
|
||||
use App\Models\Tenant\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
// Tenant-side auth (per-company users on <slug>.service.mir.md).
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
// Central-side auth (super-admins on service.mir.md/admin).
|
||||
'central' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'super_admins',
|
||||
],
|
||||
],
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
'super_admins' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => SuperAdmin::class,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
@@ -99,18 +42,13 @@ return [
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
'super_admins' => [
|
||||
'provider' => 'super_admins',
|
||||
'table' => 'password_reset_tokens',
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
|
||||
+12
-14
@@ -2,37 +2,35 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use Stancl\Tenancy\Database\Models\Domain;
|
||||
use Stancl\Tenancy\Database\Models\Tenant;
|
||||
|
||||
return [
|
||||
'tenant_model' => Tenant::class,
|
||||
'id_generator' => Stancl\Tenancy\UUIDGenerator::class,
|
||||
// We use our own Company as the tenant model (single DB, slug-based identification).
|
||||
'tenant_model' => Company::class,
|
||||
'id_generator' => null, // We use auto-increment IDs, not UUIDs.
|
||||
|
||||
'domain_model' => Domain::class,
|
||||
|
||||
/**
|
||||
* The list of domains hosting your central app.
|
||||
*
|
||||
* Only relevant if you're using the domain or subdomain identification middleware.
|
||||
*/
|
||||
'central_domains' => [
|
||||
env('CENTRAL_DOMAIN', 'service.mir.md'),
|
||||
'127.0.0.1',
|
||||
'localhost',
|
||||
],
|
||||
|
||||
/**
|
||||
* Tenancy bootstrappers are executed when tenancy is initialized.
|
||||
* Their responsibility is making Laravel features tenant-aware.
|
||||
*
|
||||
* To configure their behavior, see the config keys below.
|
||||
* Single-database mode: NO bootstrappers active.
|
||||
* We rely on Eloquent global scopes via App\Models\Concerns\BelongsToTenant
|
||||
* for data isolation. Cache/queue scoping handled manually if/when needed.
|
||||
*/
|
||||
'bootstrappers' => [
|
||||
Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class,
|
||||
Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
|
||||
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
|
||||
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
|
||||
// Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
|
||||
// Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class, // multi-DB only
|
||||
// Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
|
||||
// Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
|
||||
// Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
|
||||
],
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateTenantsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenants', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
|
||||
// your custom columns may go here
|
||||
|
||||
$table->timestamps();
|
||||
$table->json('data')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenants');
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateDomainsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('domains', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('domain', 255)->unique();
|
||||
$table->string('tenant_id');
|
||||
|
||||
$table->timestamps();
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('domains');
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateTenantUserImpersonationTokensTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_user_impersonation_tokens', function (Blueprint $table) {
|
||||
$table->string('token', 128)->primary();
|
||||
$table->string('tenant_id');
|
||||
$table->string('user_id');
|
||||
$table->string('auth_guard');
|
||||
$table->string('redirect_url');
|
||||
$table->timestamp('created_at');
|
||||
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_user_impersonation_tokens');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('plans', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->string('slug')->unique();
|
||||
$t->string('name');
|
||||
$t->decimal('price_monthly', 10, 2)->default(0);
|
||||
$t->decimal('price_yearly', 10, 2)->default(0);
|
||||
$t->string('currency', 3)->default('MDL');
|
||||
$t->json('features')->nullable(); // ['kanban', 'reports', 'ai', ...]
|
||||
$t->json('limits')->nullable(); // ['max_users' => 5, 'max_vehicles' => 100, ...]
|
||||
$t->boolean('is_active')->default(true);
|
||||
$t->boolean('is_public')->default(true);
|
||||
$t->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('plans');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('companies', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->string('slug')->unique(); // psauto, autoplus, ...
|
||||
$t->string('name'); // PSauto SRL
|
||||
$t->string('display_name')->nullable(); // shown in UI
|
||||
$t->string('city')->nullable();
|
||||
$t->string('phone')->nullable();
|
||||
$t->string('email')->nullable();
|
||||
$t->string('contact_name')->nullable();
|
||||
|
||||
$t->enum('status', ['trial', 'active', 'expired', 'suspended', 'archived'])
|
||||
->default('trial');
|
||||
|
||||
$t->foreignId('plan_id')->nullable()->constrained()->nullOnDelete();
|
||||
$t->timestamp('trial_ends_at')->nullable();
|
||||
$t->timestamp('active_until')->nullable();
|
||||
|
||||
// White-label settings (preluat din cfg-ul prototipului AutoCRM.html).
|
||||
$t->json('settings')->nullable();
|
||||
// Compatibilitate cu stancl/tenancy v3 — stochează metadate libere.
|
||||
$t->json('data')->nullable();
|
||||
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
$t->index(['status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('companies');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('super_admins', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->string('name');
|
||||
$t->string('email')->unique();
|
||||
$t->timestamp('email_verified_at')->nullable();
|
||||
$t->string('password');
|
||||
$t->boolean('is_active')->default(true);
|
||||
$t->timestamp('last_login_at')->nullable();
|
||||
$t->rememberToken();
|
||||
$t->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('super_admins');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
|
||||
|
||||
$t->string('name');
|
||||
$t->string('email');
|
||||
$t->string('phone')->nullable();
|
||||
$t->string('avatar_url')->nullable();
|
||||
|
||||
$t->string('role')->default('user'); // admin / manager / receptionist / mechanic / user
|
||||
$t->string('status')->default('active'); // active / inactive / blocked
|
||||
$t->string('locale', 5)->default('ro');
|
||||
|
||||
$t->timestamp('email_verified_at')->nullable();
|
||||
$t->string('password');
|
||||
$t->timestamp('last_login_at')->nullable();
|
||||
$t->rememberToken();
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
// CRITIC: same email can exist across tenants but unique within tenant.
|
||||
$t->unique(['company_id', 'email']);
|
||||
$t->index(['company_id', 'role']);
|
||||
$t->index(['company_id', 'status']);
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $t) {
|
||||
$t->string('email')->primary();
|
||||
$t->string('token');
|
||||
$t->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $t) {
|
||||
$t->string('id')->primary();
|
||||
$t->foreignId('user_id')->nullable()->index();
|
||||
$t->string('ip_address', 45)->nullable();
|
||||
$t->text('user_agent')->nullable();
|
||||
$t->longText('payload');
|
||||
$t->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sessions');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('users');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('clients', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$t->string('type')->default('individual'); // individual / company
|
||||
$t->string('name');
|
||||
$t->string('company_name')->nullable(); // for type=company
|
||||
|
||||
$t->string('phone');
|
||||
$t->string('phone_alt')->nullable();
|
||||
$t->string('email')->nullable();
|
||||
$t->string('telegram')->nullable();
|
||||
$t->string('whatsapp')->nullable();
|
||||
$t->string('viber')->nullable();
|
||||
|
||||
$t->string('source')->nullable(); // call / site / telegram / google / ...
|
||||
$t->string('marketing_channel')->nullable();
|
||||
$t->string('status')->default('active'); // new / active / vip / debtor / blocked / lost
|
||||
|
||||
$t->decimal('balance', 12, 2)->default(0); // negative = customer owes us
|
||||
$t->decimal('discount_pct', 5, 2)->default(0);
|
||||
$t->text('notes')->nullable();
|
||||
|
||||
$t->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
|
||||
$t->timestamp('last_contact_at')->nullable();
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
$t->index(['company_id', 'status']);
|
||||
$t->index(['company_id', 'phone']);
|
||||
$t->index(['company_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('clients');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('vehicles', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
|
||||
$t->foreignId('client_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$t->string('make'); // BMW, Audi, ...
|
||||
$t->string('model'); // X5, A4, ...
|
||||
$t->smallInteger('year')->nullable();
|
||||
$t->string('vin', 32)->nullable();
|
||||
$t->string('plate', 16)->nullable();
|
||||
|
||||
$t->string('engine')->nullable(); // 3.0i / 2.0 TDI
|
||||
$t->string('gearbox')->nullable();
|
||||
$t->string('fuel')->nullable();
|
||||
$t->unsignedInteger('mileage')->default(0);
|
||||
$t->string('color')->nullable();
|
||||
$t->text('notes')->nullable();
|
||||
|
||||
$t->timestamps();
|
||||
$t->softDeletes();
|
||||
|
||||
$t->index(['company_id', 'client_id']);
|
||||
$t->index(['company_id', 'plate']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('vehicles');
|
||||
}
|
||||
};
|
||||
@@ -2,24 +2,138 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Central\SuperAdmin;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\User;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
// ─── Plans ────────────────────────────────────────────────
|
||||
$free = Plan::firstOrCreate(['slug' => 'free'], [
|
||||
'name' => 'Free',
|
||||
'price_monthly' => 0,
|
||||
'currency' => 'MDL',
|
||||
'features' => ['core'],
|
||||
'limits' => ['max_users' => 2, 'max_vehicles' => 50],
|
||||
]);
|
||||
|
||||
Plan::firstOrCreate(['slug' => 'basic'], [
|
||||
'name' => 'Basic',
|
||||
'price_monthly' => 299,
|
||||
'currency' => 'MDL',
|
||||
'features' => ['core', 'workorders', 'kanban'],
|
||||
'limits' => ['max_users' => 5, 'max_vehicles' => 500],
|
||||
]);
|
||||
|
||||
Plan::firstOrCreate(['slug' => 'pro'], [
|
||||
'name' => 'Pro',
|
||||
'price_monthly' => 599,
|
||||
'currency' => 'MDL',
|
||||
'features' => ['core', 'workorders', 'kanban', 'reports', 'ai', 'api'],
|
||||
'limits' => ['max_users' => -1, 'max_vehicles' => -1],
|
||||
]);
|
||||
|
||||
// ─── Super-admin (operator platformă) ─────────────────────
|
||||
SuperAdmin::firstOrCreate(['email' => 'vasyka.moraru@gmail.com'], [
|
||||
'name' => 'Vasyka',
|
||||
'password' => Hash::make('admin123'),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// ─── PSauto demo company ──────────────────────────────────
|
||||
$psauto = Company::firstOrCreate(['slug' => 'psauto'], [
|
||||
'name' => 'PSauto SRL',
|
||||
'display_name' => 'PSauto',
|
||||
'city' => 'Chișinău',
|
||||
'phone' => '+373 22 123 456',
|
||||
'email' => 'contact@psauto.md',
|
||||
'contact_name' => 'Manager PSauto',
|
||||
'status' => 'active',
|
||||
'plan_id' => $free->id,
|
||||
'active_until' => now()->addYear(),
|
||||
'settings' => [
|
||||
'currency' => 'MDL',
|
||||
'language' => 'ro',
|
||||
'theme_color' => '#3B82F6',
|
||||
'labor_rate' => 400,
|
||||
'posts' => ['Post 1', 'Post 2', 'Post 3'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Activate tenant context for the seeded data so global scopes auto-fill company_id.
|
||||
app(TenantManager::class)->setCurrent($psauto);
|
||||
|
||||
// ─── Admin user pentru PSauto ─────────────────────────────
|
||||
User::firstOrCreate(
|
||||
['company_id' => $psauto->id, 'email' => 'admin@psauto.md'],
|
||||
[
|
||||
'name' => 'Administrator PSauto',
|
||||
'password' => Hash::make('admin123'),
|
||||
'role' => 'admin',
|
||||
'status' => 'active',
|
||||
'phone' => '+373 22 123 456',
|
||||
'locale' => 'ro',
|
||||
'email_verified_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
// ─── Clienți demo (din AutoCRM.html) ──────────────────────
|
||||
$c1 = Client::firstOrCreate(
|
||||
['company_id' => $psauto->id, 'phone' => '+373 69 100001'],
|
||||
[
|
||||
'type' => 'individual',
|
||||
'name' => 'Ion Popescu',
|
||||
'email' => 'ion@mail.com',
|
||||
'status' => 'vip',
|
||||
'source' => 'recommend',
|
||||
]
|
||||
);
|
||||
|
||||
$c2 = Client::firstOrCreate(
|
||||
['company_id' => $psauto->id, 'phone' => '+373 79 200002'],
|
||||
[
|
||||
'type' => 'individual',
|
||||
'name' => 'Maria Dumitru',
|
||||
'status' => 'active',
|
||||
'discount_pct' => 5,
|
||||
'source' => 'site',
|
||||
]
|
||||
);
|
||||
|
||||
$c3 = Client::firstOrCreate(
|
||||
['company_id' => $psauto->id, 'phone' => '+373 60 300003'],
|
||||
[
|
||||
'type' => 'individual',
|
||||
'name' => 'Andrei Lupu',
|
||||
'status' => 'active',
|
||||
'discount_pct' => 10,
|
||||
'notes' => 'Doar piese originale',
|
||||
'source' => 'instagram',
|
||||
]
|
||||
);
|
||||
|
||||
// ─── Mașini demo ──────────────────────────────────────────
|
||||
Vehicle::firstOrCreate(
|
||||
['company_id' => $psauto->id, 'client_id' => $c1->id, 'make' => 'BMW', 'model' => 'X5'],
|
||||
['year' => 2020, 'plate' => 'CIU 001', 'engine' => '3.0i', 'gearbox' => 'Automat 8AT', 'fuel' => 'Benzină', 'mileage' => 85000, 'color' => 'Alb']
|
||||
);
|
||||
Vehicle::firstOrCreate(
|
||||
['company_id' => $psauto->id, 'client_id' => $c2->id, 'make' => 'Audi', 'model' => 'A4'],
|
||||
['year' => 2019, 'plate' => 'CIU 002', 'engine' => '2.0 TDI', 'gearbox' => 'DSG7', 'fuel' => 'Diesel', 'mileage' => 45000, 'color' => 'Negru']
|
||||
);
|
||||
Vehicle::firstOrCreate(
|
||||
['company_id' => $psauto->id, 'client_id' => $c3->id, 'make' => 'Porsche', 'model' => 'Cayenne'],
|
||||
['year' => 2021, 'plate' => 'CIU 003', 'engine' => '3.0 TDI', 'gearbox' => 'Tiptronic', 'fuel' => 'Diesel', 'mileage' => 22000, 'color' => 'Gri']
|
||||
);
|
||||
|
||||
app(TenantManager::class)->clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
||||
php artisan migrate --force --no-interaction || echo "[entrypoint] migrate failed (non-fatal)"
|
||||
fi
|
||||
|
||||
# Run seeders if requested. Uses firstOrCreate so it's idempotent.
|
||||
if [ "${RUN_SEED:-false}" = "true" ]; then
|
||||
echo "[entrypoint] Running database seed..."
|
||||
php artisan db:seed --force --no-interaction || echo "[entrypoint] seed failed (non-fatal)"
|
||||
fi
|
||||
|
||||
# Production caches
|
||||
if [ "${APP_ENV:-production}" = "production" ]; then
|
||||
echo "[entrypoint] Caching config/routes/views..."
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{ $company->name ?? 'Cont' }} — Abonament expirat</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #fafafa; color: #1f2937; }
|
||||
.card { max-width: 480px; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.06); text-align: center; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 .5rem; color: #ca8a04; }
|
||||
p { line-height: 1.6; color: #4b5563; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>⏰ Abonament expirat</h1>
|
||||
<p>Abonamentul pentru <b>{{ $company->name }}</b> a expirat.</p>
|
||||
<p>Pentru a continua să folosiți aplicația, vă rugăm să achitați factura.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{ $company->name ?? 'Cont' }} — Cont suspendat</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #fafafa; color: #1f2937; }
|
||||
.card { max-width: 480px; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.06); text-align: center; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 .5rem; color: #b91c1c; }
|
||||
p { line-height: 1.6; color: #4b5563; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>⛔ Cont suspendat</h1>
|
||||
<p>Contul <b>{{ $company->name }}</b> a fost suspendat.</p>
|
||||
<p>Pentru detalii, contactați administratorul platformei.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user