refactor(tenant): introduce TenantInformationService for loading tenant data and resolving website extras

This commit is contained in:
2026-07-29 15:48:01 -03:00
parent 27d4f9fac6
commit 8e3ee7eff6
8 changed files with 436 additions and 33 deletions

View File

@@ -5,26 +5,30 @@ namespace App\Domains\Tenant\Controllers;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Requests\BootstrapTenantRequest;
use App\Domains\Tenant\Resources\TenantResource;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Http\Controllers\Controller;
class BootstrapTenantController extends Controller
{
public function __construct(
protected TenantInformationService $tenantInformationService
) {}
public function __invoke(BootstrapTenantRequest $request): TenantResource
{
/** @var string $dominio */
$dominio = $request->validated('dominio');
return TenantResource::make(
Tenant::query()
->with([
'headerLogo',
'footerLogo',
$this->tenantInformationService->load(
Tenant::query()
->where('dominio', $dominio)
->firstOrFail(),
[
'menues',
'socialMedia',
'categories' => fn ($query) => $query->orderBy('nombre'),
])
->where('dominio', $dominio)
->firstOrFail()
]
)
);
}
}

View File

@@ -6,6 +6,7 @@ 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\TenantInformationService;
use App\Domains\Tenant\Services\TenantService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -13,16 +14,20 @@ use Illuminate\Http\Response;
class TenantController extends Controller
{
public function __construct(protected TenantService $tenantService) {}
public function __construct(
protected TenantService $tenantService,
protected TenantInformationService $tenantInformationService,
) {}
public function index(): JsonResponse
{
return TenantResource::collection(
Tenant::query()
->with(['headerLogo', 'footerLogo', 'socialMedia'])
->latest()
->paginateFromRequest()
)->response();
$tenants = Tenant::query()
->latest()
->paginateFromRequest();
$this->tenantInformationService->loadMany($tenants->getCollection());
return TenantResource::collection($tenants)->response();
}
public function store(StoreTenantRequest $request): JsonResponse
@@ -30,20 +35,14 @@ class TenantController extends Controller
$tenant = $this->tenantService->create($request->validated());
return TenantResource::make(
$tenant->loadMissing([
'headerLogo',
'footerLogo',
'socialMedia',
'websiteType',
'websiteExtras.websiteTypeExtra',
])
$this->tenantInformationService->load($tenant)
)->response()->setStatusCode(201);
}
public function show(Tenant $tenant): TenantResource
{
return TenantResource::make(
$tenant->loadMissing(['headerLogo', 'footerLogo', 'socialMedia'])
$this->tenantInformationService->load($tenant)
);
}
@@ -52,7 +51,7 @@ class TenantController extends Controller
$tenant = $this->tenantService->update($tenant, $request->validated());
return TenantResource::make(
$tenant->loadMissing(['headerLogo', 'footerLogo', 'socialMedia'])
$this->tenantInformationService->load($tenant)
);
}

View File

@@ -18,6 +18,10 @@ class WebsiteExtra extends Model
protected $table = 'websites_extras';
private mixed $resolvedConfig = null;
private bool $hasResolvedConfig = false;
/**
* @return array<string, string>
*/
@@ -44,4 +48,17 @@ class WebsiteExtra extends Model
{
return $this->belongsTo(WebsiteTypeExtra::class, 'website_type_extra_id');
}
public function setResolvedConfig(mixed $config): self
{
$this->resolvedConfig = $config;
$this->hasResolvedConfig = true;
return $this;
}
public function resolvedConfig(): mixed
{
return $this->hasResolvedConfig ? $this->resolvedConfig : $this->config;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Tenant\Resources;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\Category;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
@@ -40,10 +41,11 @@ class TenantResource extends JsonResource
),
'extras' => $this->whenLoaded(
'websiteExtras',
fn () => $this->websiteExtras
->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->nombre => $extra->config,
])
fn () => $this->websiteExtras->mapWithKeys(fn ($extra) => [
$extra->websiteTypeExtra->nombre => $this->formatExtraConfig(
$extra->resolvedConfig()
),
])
),
// 1 day
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
@@ -73,6 +75,23 @@ class TenantResource extends JsonResource
];
}
private function formatExtraConfig(mixed $value): mixed
{
if ($value instanceof Attachment) {
return $value->getTemporaryUrl(1440);
}
if (! is_array($value)) {
return $value;
}
foreach ($value as $key => $item) {
$value[$key] = $this->formatExtraConfig($item);
}
return $value;
}
/**
* @param Collection<int, Category> $categories
* @return Collection<int, array<string, mixed>>

View File

@@ -0,0 +1,177 @@
<?php
namespace App\Domains\Tenant\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteExtra;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
class TenantInformationService
{
private const DEFAULT_RELATIONS = [
'headerLogo',
'footerLogo',
'socialMedia',
'websiteType',
'websiteExtras.websiteTypeExtra',
];
/**
* @param array<int|string, mixed> $relations
*/
public function load(Tenant $tenant, array $relations = []): Tenant
{
$this->loadMany(new EloquentCollection([$tenant]), $relations);
return $tenant;
}
/**
* @param EloquentCollection<int, Tenant> $tenants
* @param array<int|string, mixed> $relations
* @return EloquentCollection<int, Tenant>
*/
public function loadMany(
EloquentCollection $tenants,
array $relations = []
): EloquentCollection {
$tenants->loadMissing([
...self::DEFAULT_RELATIONS,
...$relations,
]);
$websiteExtras = $tenants
->flatMap(fn (Tenant $tenant): Collection => $tenant->websiteExtras);
$this->resolveWebsiteExtraAttachments($websiteExtras);
return $tenants;
}
/**
* Replace attachment IDs with their models in each extra's resolved config.
*
* @param Collection<int, WebsiteExtra> $websiteExtras
*/
private function resolveWebsiteExtraAttachments(Collection $websiteExtras): void
{
$attachmentIds = $websiteExtras
->flatMap(function (WebsiteExtra $extra): array {
$ids = [];
foreach ($this->attachmentTransformPaths($extra) as $path) {
$ids = [
...$ids,
...$this->valuesAtPath($extra->config, $this->pathSegments($path)),
];
}
return $ids;
})
->filter(fn (mixed $id): bool => is_int($id) || ctype_digit((string) $id))
->map(fn (mixed $id): int => (int) $id)
->unique()
->values();
$attachments = Attachment::query()
->whereIn('id', $attachmentIds)
->get()
->keyBy('id');
foreach ($websiteExtras as $extra) {
$config = $extra->config;
foreach ($this->attachmentTransformPaths($extra) as $path) {
$config = $this->replaceAtPath(
$config,
$this->pathSegments($path),
fn (mixed $attachmentId): ?Attachment => $attachments->get((int) $attachmentId)
);
}
$extra->setResolvedConfig($config);
}
}
/**
* @return array<int, string>
*/
private function attachmentTransformPaths(WebsiteExtra $extra): array
{
return collect($extra->websiteTypeExtra->config_schema['transforms'] ?? [])
->filter(
fn (mixed $transform): bool => is_array($transform)
&& ($transform['handler'] ?? null) === 'attachment'
)
->keys()
->all();
}
/**
* @return array<int, string>
*/
private function pathSegments(string $path): array
{
$path = ltrim($path, '$');
$path = ltrim($path, '.');
return $path === '' ? [] : explode('.', $path);
}
/**
* @return array<int, mixed>
*/
private function valuesAtPath(mixed $value, array $segments): array
{
if ($segments === []) {
return [$value];
}
if (! is_array($value)) {
return [];
}
$segment = array_shift($segments);
if ($segment === '*') {
return collect($value)
->flatMap(fn (mixed $item): array => $this->valuesAtPath($item, $segments))
->all();
}
if (! array_key_exists($segment, $value)) {
return [];
}
return $this->valuesAtPath($value[$segment], $segments);
}
private function replaceAtPath(mixed $value, array $segments, callable $replace): mixed
{
if ($segments === []) {
return $value === null ? null : $replace($value);
}
if (! is_array($value)) {
return $value;
}
$segment = array_shift($segments);
if ($segment === '*') {
foreach ($value as $key => $item) {
$value[$key] = $this->replaceAtPath($item, $segments, $replace);
}
return $value;
}
if (array_key_exists($segment, $value)) {
$value[$segment] = $this->replaceAtPath($value[$segment], $segments, $replace);
}
return $value;
}
}

View File

@@ -2,6 +2,7 @@
namespace Database\Seeders;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantService;
@@ -58,6 +59,35 @@ class TenantSeeder extends Seeder
'header_logo' => $this->uploadedImage('images/sonder_header.png', 'sonder_header.png'),
'footer_logo' => $this->uploadedImage('images/sonder_footer.png', 'sonder_footer.png'),
'social_media' => self::SOCIAL_MEDIA,
'website_type_code' => 'shopit',
'extras' => [
'carousel' => [
$this->uploadedImage(
'images/sonder-main-carousel/01-urban-team.png',
'01-urban-team.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/02-running-shoes.png',
'02-running-shoes.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/03-streetwear.png',
'03-streetwear.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/04-football-training.png',
'04-football-training.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/05-activewear-essentials.png',
'05-activewear-essentials.png',
),
$this->uploadedImage(
'images/sonder-main-carousel/06-city-runners.png',
'06-city-runners.png',
),
],
],
]);
$this->deleteTenant('fiesta_futbol_infantil');
@@ -86,6 +116,27 @@ class TenantSeeder extends Seeder
'futbol_infantil_footer.png',
),
'social_media' => self::SOCIAL_MEDIA,
'website_type_code' => 'onticket',
'extras' => [
'heroConfig' => [
'title_html' => '<h1>Fiesta Fútbol Infantil</h1>',
'description_html' => '<p>Viví una jornada inolvidable de fútbol infantil.</p>',
'button_text' => 'Comprar entradas',
'button_href' => '/tickets',
'background_image_id' => $this->uploadedImage(
'images/futbol_infantil_hero.jpg',
'futbol_infantil_hero.jpg',
),
],
'eventConfig' => [
'title' => 'Fiesta Fútbol Infantil',
'location' => 'Rosario, Santa Fe',
'dates' => [
'2026-12-05',
'2026-12-06',
],
],
],
]);
}
@@ -101,6 +152,24 @@ class TenantSeeder extends Seeder
$attachmentService = app(AttachmentService::class);
$extraAttachments = Attachment::query()
->where('path', 'like', 'tenants/%/extras/%')
->get()
->filter(
fn (Attachment $attachment): bool => str_starts_with(
$attachment->path,
"tenants/{$codigo}/extras/"
)
);
foreach ($extraAttachments as $attachment) {
try {
$attachmentService->delete($attachment);
} catch (Throwable) {
// Ignore cleanup errors while recreating demo data.
}
}
if ($tenant->headerLogo) {
try {
$attachmentService->delete($tenant->headerLogo);
@@ -128,10 +197,17 @@ class TenantSeeder extends Seeder
throw new RuntimeException("Image not found at path: {$path}");
}
$mimeType = match (strtolower(pathinfo($filename, PATHINFO_EXTENSION))) {
'jpg', 'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'webp' => 'image/webp',
default => 'image/png',
};
return new UploadedFile(
$path,
$filename,
'image/png',
$mimeType,
null,
true,
);

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Seeders;
use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\SocialMediaSeeder;
use Database\Seeders\TenantSeeder;
use Database\Seeders\WebsiteTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
@@ -18,6 +19,7 @@ class TenantSeederTest extends TestCase
Storage::fake('s3');
$this->seed([
WebsiteTypeSeeder::class,
SocialMediaSeeder::class,
TenantSeeder::class,
]);
@@ -42,4 +44,65 @@ class TenantSeederTest extends TestCase
$this->assertSame([0, 1, 2, 3], $socialMedia->pluck('pivot.orden')->all());
}
}
public function test_it_assigns_each_tenant_its_website_type_and_extras(): void
{
Storage::fake('s3');
$this->seed([
WebsiteTypeSeeder::class,
SocialMediaSeeder::class,
TenantSeeder::class,
]);
$sonder = Tenant::query()
->where('codigo', 'sonder')
->with('websiteExtras.websiteTypeExtra')
->sole();
$this->assertSame('shopit', $sonder->website_type_code);
$carousel = $sonder->websiteExtras
->firstWhere('websiteTypeExtra.nombre', 'carousel');
$this->assertNotNull($carousel);
$this->assertCount(6, $carousel->config);
foreach ($carousel->config as $attachmentId) {
$this->assertDatabaseHas('attachments', [
'id' => $attachmentId,
'type' => 'image',
]);
}
$fiesta = Tenant::query()
->where('codigo', 'fiesta_futbol_infantil')
->with('websiteExtras.websiteTypeExtra')
->sole();
$this->assertSame('onticket', $fiesta->website_type_code);
$extras = $fiesta->websiteExtras->keyBy('websiteTypeExtra.nombre');
$this->assertEqualsCanonicalizing(
['heroConfig', 'eventConfig'],
$extras->keys()->all(),
);
$heroConfig = $extras->get('heroConfig')->config;
$this->assertSame('<h1>Fiesta Fútbol Infantil</h1>', $heroConfig['title_html']);
$this->assertIsInt($heroConfig['background_image_id']);
$this->assertDatabaseHas('attachments', [
'id' => $heroConfig['background_image_id'],
'type' => 'image',
]);
$this->assertSame([
'title' => 'Fiesta Fútbol Infantil',
'location' => 'Rosario, Santa Fe',
'dates' => [
'2026-12-05',
'2026-12-06',
],
], $extras->get('eventConfig')->config);
}
}

View File

@@ -2,7 +2,9 @@
namespace Tests\Feature\Tenant;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantInformationService;
use Database\Seeders\WebsiteTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
@@ -99,10 +101,10 @@ class StoreTenantWithExtrasTest extends TestCase
$response->assertCreated();
$config = Tenant::query()
$tenant = Tenant::query()
->where('codigo', 'festival')
->sole()
->websiteExtras()
->sole();
$config = $tenant->websiteExtras()
->sole()
->config;
@@ -112,7 +114,53 @@ class StoreTenantWithExtrasTest extends TestCase
'id' => $config[0],
'type' => 'image',
]);
$response->assertJsonPath('data.extras.carousel.0', $config[0]);
$attachment = Attachment::query()->findOrFail($config[0]);
$carouselUrl = $response->json('data.extras.carousel.0');
$this->assertIsString($carouselUrl);
$this->assertStringContainsString($attachment->key, $carouselUrl);
app(TenantInformationService::class)->load($tenant);
$this->assertInstanceOf(
Attachment::class,
$tenant->websiteExtras->sole()->resolvedConfig()[0]
);
}
public function test_it_returns_scalar_attachment_fields_as_temporary_urls(): void
{
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
$response = $this->postJson('/api/tenants', array_merge($this->tenantData(), [
'website_type_code' => 'onticket',
'extras' => [
'heroConfig' => [
'title_html' => '<h1>Festival</h1>',
'background_image_id' => $image,
],
],
]));
$response->assertCreated();
$heroConfig = Tenant::query()
->where('codigo', 'festival')
->sole()
->websiteExtras()
->whereHas(
'websiteTypeExtra',
fn ($query) => $query->where('nombre', 'heroConfig')
)
->sole()
->config;
$attachment = Attachment::query()->findOrFail($heroConfig['background_image_id']);
$backgroundUrl = $response->json('data.extras.heroConfig.background_image_id');
$this->assertIsString($backgroundUrl);
$this->assertStringContainsString($attachment->key, $backgroundUrl);
}
/**