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();
}
}
+10 -1
View File
@@ -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 {
//
+3 -1
View File
@@ -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
View File
@@ -29,7 +29,10 @@
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"files": [
"app/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
+19 -81
View File
@@ -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
View File
@@ -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,
],
/**
-45
View File
@@ -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');
}
}
@@ -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');
}
};
+126 -12
View File
@@ -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();
}
}
+6
View File
@@ -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..."
+21
View File
@@ -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 folosiți aplicația, rugăm 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>