feat: implement tenant management system including database schema, CRUD services, and bootstrap functionality

This commit is contained in:
2026-06-26 12:16:50 -03:00
parent 0dae985230
commit 1f9d876bc3
8 changed files with 394 additions and 5 deletions

View File

@@ -6,12 +6,17 @@ use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Requests\StoreTenantRequest;
use App\Domains\Tenant\Requests\UpdateTenantRequest;
use App\Domains\Tenant\Resources\TenantResource;
use App\Domains\Tenant\Services\TenantService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class TenantController extends Controller
{
public function __construct(protected TenantService $tenantService)
{
}
public function index(): JsonResponse
{
return TenantResource::collection(Tenant::query()->latest()->get())->response();
@@ -19,7 +24,7 @@ class TenantController extends Controller
public function store(StoreTenantRequest $request): JsonResponse
{
$tenant = Tenant::query()->create($request->validated());
$tenant = $this->tenantService->create($request->validated());
return TenantResource::make($tenant)->response()->setStatusCode(201);
}
@@ -31,7 +36,7 @@ class TenantController extends Controller
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
{
$tenant->update($request->validated());
$tenant = $this->tenantService->update($tenant, $request->validated());
return TenantResource::make($tenant);
}

View File

@@ -13,6 +13,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'codigo',
'nombre',
'dominio',
'primary_color',
'secondary_color',
'danger_color',
'header_footer_bg_color',
'header_logo',
'footer_logo',
])]
class Tenant extends Model
{

View File

@@ -5,6 +5,8 @@ namespace App\Domains\Tenant\Requests;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class StoreTenantRequest extends FormRequest
@@ -34,6 +36,52 @@ class StoreTenantRequest extends FormRequest
*/
public function rules(): array
{
$logoRule = [
'nullable',
static function (string $attribute, mixed $value, Closure $fail): void {
if ($value instanceof UploadedFile) {
$mime = $value->getMimeType();
if (! str_starts_with($mime, 'image/')) {
$fail("The {$attribute} must be a valid image or SVG.");
}
return;
}
if (is_string($value)) {
if (Str::isUuid($value)) {
return;
}
try {
$payload = trim($value);
$declaredMimeType = null;
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
$declaredMimeType = strtolower($matches['mime']);
$payload = $matches['data'];
}
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
if ($decoded === false || $decoded === '') {
$fail("The {$attribute} must be a valid base64 image or SVG.");
return;
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->buffer($decoded);
if (! $mime && $declaredMimeType) {
$mime = $declaredMimeType;
}
if (! $mime || ! str_starts_with($mime, 'image/')) {
$fail("The {$attribute} must be a valid image or SVG.");
}
} catch (\Throwable) {
$fail("The {$attribute} must be a valid image or SVG.");
}
return;
}
$fail("The {$attribute} must be a valid file or base64 string.");
},
];
return [
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
'nombre' => ['required', 'string', 'max:255'],
@@ -49,6 +97,12 @@ class StoreTenantRequest extends FormRequest
'max:255',
Rule::unique('tenants', 'dominio'),
],
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
];
}
}

View File

@@ -6,6 +6,8 @@ use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class UpdateTenantRequest extends FormRequest
@@ -38,6 +40,52 @@ class UpdateTenantRequest extends FormRequest
/** @var Tenant|null $tenant */
$tenant = $this->route('tenant');
$logoRule = [
'nullable',
static function (string $attribute, mixed $value, Closure $fail): void {
if ($value instanceof UploadedFile) {
$mime = $value->getMimeType();
if (! str_starts_with($mime, 'image/')) {
$fail("The {$attribute} must be a valid image or SVG.");
}
return;
}
if (is_string($value)) {
if (Str::isUuid($value)) {
return;
}
try {
$payload = trim($value);
$declaredMimeType = null;
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
$declaredMimeType = strtolower($matches['mime']);
$payload = $matches['data'];
}
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
if ($decoded === false || $decoded === '') {
$fail("The {$attribute} must be a valid base64 image or SVG.");
return;
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->buffer($decoded);
if (! $mime && $declaredMimeType) {
$mime = $declaredMimeType;
}
if (! $mime || ! str_starts_with($mime, 'image/')) {
$fail("The {$attribute} must be a valid image or SVG.");
}
} catch (\Throwable) {
$fail("The {$attribute} must be a valid image or SVG.");
}
return;
}
$fail("The {$attribute} must be a valid file or base64 string.");
},
];
return [
'codigo' => [
'required',
@@ -58,6 +106,12 @@ class UpdateTenantRequest extends FormRequest
'max:255',
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
],
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'header_logo' => $logoRule,
'footer_logo' => $logoRule,
];
}
}

View File

@@ -20,6 +20,12 @@ class TenantResource extends JsonResource
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'dominio' => $this->dominio,
'primary_color' => $this->primary_color,
'secondary_color' => $this->secondary_color,
'danger_color' => $this->danger_color,
'header_footer_bg_color' => $this->header_footer_bg_color,
'header_logo' => $this->header_logo,
'footer_logo' => $this->footer_logo,
];
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TenantService
{
public function __construct(protected AttachmentService $attachmentService)
{
}
/**
* Create a new tenant and store its logos.
*
* @param array<string, mixed> $data
* @return Tenant
*/
public function create(array $data): Tenant
{
return DB::transaction(function () use ($data): Tenant {
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
unset($data['header_logo'], $data['footer_logo']);
/** @var Tenant $tenant */
$tenant = Tenant::query()->create($data);
if ($headerLogo) {
$attachment = $this->attachmentService->store($headerLogo, 'tenants');
$tenant->header_logo = $attachment->key;
$tenant->attachments()->attach($attachment->id);
}
if ($footerLogo) {
$attachment = $this->attachmentService->store($footerLogo, 'tenants');
$tenant->footer_logo = $attachment->key;
$tenant->attachments()->attach($attachment->id);
}
if ($headerLogo || $footerLogo) {
$tenant->save();
}
return $tenant;
});
}
/**
* Update an existing tenant and store new logos if uploaded.
*
* @param Tenant $tenant
* @param array<string, mixed> $data
* @return Tenant
*/
public function update(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Tenant {
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
$headerLogo = $data['header_logo'] ?? null;
$footerLogo = $data['footer_logo'] ?? null;
unset($data['header_logo'], $data['footer_logo']);
$tenant->fill($data);
if ($hasHeaderLogoKey) {
if ($headerLogo) {
if (! Str::isUuid($headerLogo)) {
$attachment = $this->attachmentService->store($headerLogo, 'tenants');
$tenant->header_logo = $attachment->key;
$tenant->attachments()->attach($attachment->id);
} else {
$tenant->header_logo = $headerLogo;
}
} else {
$tenant->header_logo = null;
}
}
if ($hasFooterLogoKey) {
if ($footerLogo) {
if (! Str::isUuid($footerLogo)) {
$attachment = $this->attachmentService->store($footerLogo, 'tenants');
$tenant->footer_logo = $attachment->key;
$tenant->attachments()->attach($attachment->id);
} else {
$tenant->footer_logo = $footerLogo;
}
} else {
$tenant->footer_logo = null;
}
}
$tenant->save();
return $tenant;
});
}
}