5560ebbdfd
Первый Eloquent-слой над schema v8.6 + middleware для RLS-фильтрации HTTP-запросов. Pest 19/19 passed (1672 ms): 4 RLS smoke + 8 model smoke + 5 middleware + 2 default. app/app/Models/ (NEW Tenant.php, Project.php; MODIFIED User.php): - Tenant — saas-уровневая модель (БЕЗ RLS, тенант-родитель). Soft Deletes. hasMany Users, Projects. - User — переписан под нашу схему: password_hash вместо password, first_name/last_name вместо name, override getAuthPassword/Name для Laravel auth-интеграции. Soft Deletes. belongsTo Tenant. - Project — tenant-aware с RLS. belongsTo Tenant. app/database/factories/ (NEW TenantFactory, ProjectFactory; MODIFIED UserFactory): - TenantFactory: уникальный subdomain через Str::random + дефолты (timezone Europe/Moscow, locale ru, is_trial true, api_key_limit 5). - UserFactory: tenant_id через Tenant::factory() chain, email unique через faker, password_hash через Hash::make. - ProjectFactory: tenant_id через factory chain, дефолты под schema.sql (region_mask 255, delivery_days_mask 127, assignment_strategy manual). app/app/Http/Middleware/SetTenantContext.php (NEW, alias `tenant`): - Резолюция tenant_id (приоритет): auth()->user() → subdomain (3+ parts HTTP_HOST) → X-Tenant-Id header (только dev/testing). - Без контекста → 403 Forbidden с явным сообщением. - SET LOCAL app.current_tenant_id внутри транзакции (DB::beginTransaction + SET LOCAL + next() + commit/rollback). PgBouncer-safe (Прил. И Г.1 кейс 2). - Зарегистрирован в bootstrap/app.php через $middleware->alias(). app/tests/Feature/ (NEW TenantModelsTest, SetTenantContextTest): - TenantModelsTest (8 тестов): factories + связи (Tenant→users/projects, User→tenant, Project→tenant) + проверка User::getAuthPassword override. - SetTenantContextTest (5 тестов): 403 без контекста, X-Tenant-Id header, игнор не-числового header, subdomain резолюция через absolute URL, middleware устанавливает app.current_tenant_id для last query. Сопутствующие правки: - app/.gitignore: + _ide_helper_models.php (сгенерированный, как и _ide_helper.php — не в репо) - app/phpstan-baseline.neon: regenerated — Pest dynamic methods ($this->get(), withHeaders()) и factory-return-type mismatch (Larastan v3 не понимает array<string, mixed> vs array<model property of T>) + Request::user/header (Larastan тип hint мисс) + console.php $this в Closure — все в baseline до миграции на typed properties / pest extension - CLAUDE.md §6: Pest 6/6 → 19/19, добавлены модели + middleware - memory project_state.md, MEMORY.md: обновлены под новый этап Не сделано в этой сессии (отложено): - Deal model — composite primary key (id, received_at) + partitioned parent. Эта модель сложнее по архитектуре Eloquent. - ide-helper:models -W -M -N запускался — добавил @mixin IdeHelperX и сгенерил _ide_helper_models.php; но т.к. этот файл gitignored, @mixin строки удалены из моделей (PHPStan не нашёл бы класс на CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
87 lines
2.4 KiB
PHP
87 lines
2.4 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Models;
|
||
|
||
use Database\Factories\UserFactory;
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||
use Illuminate\Notifications\Notifiable;
|
||
|
||
/**
|
||
* Пользователь тенанта (менеджер, оператор, owner).
|
||
*
|
||
* Tenant-aware модель с RLS: SELECT/INSERT/UPDATE/DELETE фильтруются
|
||
* политикой `tenant_isolation` по `current_setting('app.current_tenant_id')`.
|
||
*
|
||
* Источник: db/schema.sql v8.6 §4, table `users`. **НЕ путать**
|
||
* с `saas_admin_users` (админы SaaS-портала, отдельная таблица).
|
||
*/
|
||
class User extends Authenticatable
|
||
{
|
||
/** @use HasFactory<UserFactory> */
|
||
use HasFactory, Notifiable, SoftDeletes;
|
||
|
||
protected $fillable = [
|
||
'tenant_id',
|
||
'email',
|
||
'password_hash',
|
||
'first_name',
|
||
'last_name',
|
||
'phone',
|
||
'timezone',
|
||
'avatar_path',
|
||
'totp_secret',
|
||
'totp_enabled',
|
||
'notification_preferences',
|
||
'sound_enabled',
|
||
'telegram_user_id',
|
||
'is_active',
|
||
];
|
||
|
||
protected $hidden = [
|
||
'password_hash',
|
||
'totp_secret',
|
||
];
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'totp_enabled' => 'boolean',
|
||
'sound_enabled' => 'boolean',
|
||
'is_active' => 'boolean',
|
||
'notification_preferences' => 'array',
|
||
'telegram_user_id' => 'integer',
|
||
'email_verified_at' => 'datetime',
|
||
'last_login_at' => 'datetime',
|
||
'last_active_at' => 'datetime',
|
||
'created_at' => 'datetime',
|
||
'updated_at' => 'datetime',
|
||
'deleted_at' => 'datetime',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Auth-интеграция: схема использует `password_hash` вместо
|
||
* стандартного Laravel-имени `password`.
|
||
*/
|
||
public function getAuthPassword(): string
|
||
{
|
||
return (string) $this->password_hash;
|
||
}
|
||
|
||
public function getAuthPasswordName(): string
|
||
{
|
||
return 'password_hash';
|
||
}
|
||
|
||
/** @return BelongsTo<Tenant, $this> */
|
||
public function tenant(): BelongsTo
|
||
{
|
||
return $this->belongsTo(Tenant::class);
|
||
}
|
||
}
|