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:
2026-05-05 21:29:52 +00:00
parent 125566cb81
commit 4b1635d045
48 changed files with 1510 additions and 386 deletions
@@ -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()];
}
}
+31
View File
@@ -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),
};
}
}
+69
View File
@@ -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);
}
}
+81
View File
@@ -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}";
}
}
+27
View File
@@ -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);
}
}
+40
View File
@@ -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;
}
}
+36
View File
@@ -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);
}
}
+33
View File
@@ -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()
);
}
}
+39
View File
@@ -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');
}
}
+51
View File
@@ -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';
}
}
+29
View File
@@ -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 ?: ''));
}
}
-49
View File
@@ -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',
];
}
}
+8 -8
View File
@@ -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'));
}
}
}
@@ -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,
]);
}
}
+44
View File
@@ -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;
}
}
+11
View File
@@ -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();
}
}