feat(tenant): implement TenantProvisioningService and OnTicketTenantSeeder for tenant management

This commit is contained in:
2026-09-21 16:44:57 -03:00
parent d33b435ba5
commit f5f4cb4e87
6 changed files with 302 additions and 95 deletions

View File

@@ -2,9 +2,10 @@
namespace App\Domains\Core\Tenant\Services; namespace App\Domains\Core\Tenant\Services;
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment; use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService; use App\Shared\Attachable\Services\AttachmentService;
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -96,12 +97,12 @@ class AdminWebsiteTypeService
{ {
return Tenant::query()->where('favicon_id', $attachment->id)->exists() return Tenant::query()->where('favicon_id', $attachment->id)->exists()
|| AdminWebsiteType::query() || AdminWebsiteType::query()
->where(function ($query) use ($attachment): void { ->where(function ($query) use ($attachment): void {
$query $query
->where('site_logo', $attachment->id) ->where('site_logo', $attachment->id)
->orWhere('footer_logo', $attachment->id) ->orWhere('footer_logo', $attachment->id)
->orWhere('favicon_id', $attachment->id); ->orWhere('favicon_id', $attachment->id);
}) })
->exists(); ->exists();
} }
} }

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Domains\Core\Tenant\Services;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Internal provisioning used by migrations and seeders.
*
* General tenant writes are intentionally not exposed through the HTTP API.
*/
class TenantProvisioningService
{
public function __construct(
protected AttachmentService $attachmentService,
protected WebsiteExtraService $websiteExtraService,
) {}
/** @param array<string, mixed> $data */
public function create(array $data): Tenant
{
return DB::transaction(function () use ($data): Tenant {
$images = [
'header_logo' => $data['header_logo'] ?? null,
'footer_logo' => $data['footer_logo'] ?? null,
'favicon' => $data['favicon'] ?? null,
'header_bg_image' => $data['header_bg_image'] ?? null,
'footer_bg_image' => $data['footer_bg_image'] ?? null,
];
$socialMedia = $data['social_media'] ?? [];
$extras = $data['extras'] ?? [];
unset(
$data['header_logo'],
$data['footer_logo'],
$data['favicon'],
$data['header_bg_image'],
$data['footer_bg_image'],
$data['social_media'],
$data['extras'],
);
foreach ($images as $key => $image) {
$data[$key.'_id'] = $this->storeTenantImage($image);
}
/** @var Tenant $tenant */
$tenant = Tenant::query()->create($data);
$this->syncSocialMedia($tenant, $socialMedia);
foreach ($extras as $code => $config) {
$this->websiteExtraService->updateForTenant($tenant, $code, $config);
}
return $tenant;
});
}
/** @param array<string, mixed> $data */
public function update(Tenant $tenant, array $data): Tenant
{
return DB::transaction(function () use ($tenant, $data): Tenant {
$imageKeys = [
'header_logo',
'footer_logo',
'favicon',
'header_bg_image',
'footer_bg_image',
];
$hasSocialMedia = array_key_exists('social_media', $data);
$socialMedia = $data['social_media'] ?? [];
unset($data['social_media']);
foreach ($imageKeys as $key) {
if (! array_key_exists($key, $data)) {
continue;
}
$data[$key.'_id'] = $this->storeTenantImage($data[$key]);
unset($data[$key]);
}
$tenant->update($data);
if ($hasSocialMedia) {
$this->syncSocialMedia($tenant, $socialMedia);
}
return $tenant->refresh();
});
}
/** @param array<int, array{code: string, url: string, orden?: int}> $socialMedia */
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
{
$associations = [];
foreach (array_values($socialMedia) as $index => $item) {
$associations[$item['code']] = [
'url' => $item['url'],
'orden' => $item['orden'] ?? $index,
];
}
$tenant->socialMedia()->sync($associations);
$tenant->unsetRelation('socialMedia');
}
private function storeTenantImage(mixed $image): ?int
{
if (! $image) {
return null;
}
$attachment = is_string($image) && Str::isUuid($image)
? Attachment::query()->where('key', $image)->first()
: $this->attachmentService->store($image, 'tenants');
return $attachment?->id;
}
}

View File

@@ -0,0 +1,62 @@
<?php
use Database\Seeders\OnTicketTenantSeeder;
use Database\Seeders\WebsiteTypeSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
return new class extends Migration
{
private const TENANT_CODE = 'onticket';
private const EXCLUDED_MENU_CODES = [
'product.detail',
'help',
'help.faq',
'help.contact',
'help.payment-methods',
'help.shipping',
'help.terms-and-conditions',
'adminapp.tickets',
'adminapp.fiesta-futbol-infantil.entradas',
'adminapp.fiesta-futbol-infantil.alojamientos',
'adminapp.fiesta-futbol-infantil.merchandising',
'adminapp.fiesta-futbol-infantil.comida',
'adminapp.desfile.entradas',
];
public function up(): void
{
if (app()->environment('testing')) {
Storage::fake('s3');
}
app(WebsiteTypeSeeder::class)->run();
app(OnTicketTenantSeeder::class)->run();
$now = now();
DB::table('menues')
->whereNotIn('code', self::EXCLUDED_MENU_CODES)
->pluck('code')
->each(function (string $menuCode) use ($now): void {
DB::table('tenants_menues')->updateOrInsert(
[
'tenant_code' => self::TENANT_CODE,
'menu_code' => $menuCode,
],
[
'static_content' => null,
'created_at' => $now,
'updated_at' => $now,
],
);
});
}
public function down(): void
{
// OnTicket is reference data. Never remove a possibly active tenant on rollback.
}
};

View File

@@ -13,7 +13,7 @@ use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Services\CatalogService; use App\Domains\Commerce\Catalog\Services\CatalogService;
use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Services\TenantService; use App\Domains\Core\Tenant\Services\TenantProvisioningService;
use App\Domains\Ticketing\Desfile\Services\InvitationPurchaseProvisioner; use App\Domains\Ticketing\Desfile\Services\InvitationPurchaseProvisioner;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@@ -27,7 +27,7 @@ class DesfilePuraTendenciaSeeder extends Seeder
private const TENANT_CODE = 'desfile_pura_tendencia'; private const TENANT_CODE = 'desfile_pura_tendencia';
public function __construct( public function __construct(
private readonly TenantService $tenantService, private readonly TenantProvisioningService $tenantService,
private readonly CatalogService $catalogService, private readonly CatalogService $catalogService,
private readonly InvitationPurchaseProvisioner $invitationPurchaseProvisioner, private readonly InvitationPurchaseProvisioner $invitationPurchaseProvisioner,
) {} ) {}

View File

@@ -0,0 +1,101 @@
<?php
namespace Database\Seeders;
use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Services\TenantProvisioningService;
use App\Domains\Core\Tenant\Services\WebsiteExtraService;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class OnTicketTenantSeeder extends Seeder
{
public function __construct(
private readonly TenantProvisioningService $tenantProvisioningService,
private readonly WebsiteExtraService $websiteExtraService,
private readonly OnticketImmersiveHeroCarouselSeeder $carouselSeeder,
) {}
public function run(): void
{
$client = Client::query()->firstOrCreate(
['code' => 'onticket'],
['name' => 'OnTicket'],
);
$type = AdminWebsiteType::query()->where('codigo', 'onticket')->firstOrFail();
$tenant = Tenant::query()->where('codigo', 'onticket')->first();
if ($tenant === null) {
$tenant = $this->tenantProvisioningService->create([
'client_id' => $client->id,
'codigo' => 'onticket',
'nombre' => $type->nombre,
'dominio' => $type->dominio,
'site_title' => $type->site_title,
'primary_color' => $type->primary_color,
'secondary_color' => $type->secondary_color,
'danger_color' => $type->danger_color,
'success_color' => $type->success_color,
'header_bg_color' => $type->surface_color,
'footer_bg_color' => $type->primary_color,
'display_categories' => true,
'display_seach_bar' => true,
'admin_website_type_code' => $type->codigo,
'storefront_website_type_code' => 'onticket_multi_event',
'header_logo' => $this->uploadedImage('onticket_logo.png'),
'footer_logo' => $this->uploadedImage('onticket_footer_logo.png'),
'favicon' => $this->uploadedImage('onticket_favicon.svg'),
'header_bg_image' => $this->uploadedImage('onticket_header_background.png'),
]);
} else {
$tenant->update([
'client_id' => $client->id,
'storefront_website_type_code' => 'onticket_multi_event',
'display_categories' => true,
'display_seach_bar' => true,
]);
if ($tenant->header_bg_image_id === null) {
$tenant = $this->tenantProvisioningService->update($tenant, [
'header_bg_image' => $this->uploadedImage('onticket_header_background.png'),
]);
}
}
foreach (['Música', 'Teatro', 'Deportes', 'Infantiles', 'Otros'] as $name) {
$tenant->eventCategories()->firstOrCreate(['nombre' => $name]);
}
if (! $tenant->websiteExtras()->whereHas(
'websiteTypeExtra',
fn ($query) => $query->where('codigo', 'immersiveHero')
)->exists()) {
$this->websiteExtraService->updateForTenant($tenant, 'immersiveHero', [
'eyebrow' => 'Encendé tu',
'title' => 'experiencia',
'description' => 'Reservá tu entrada y formá parte',
]);
}
$this->carouselSeeder->run();
}
private function uploadedImage(string $filename): UploadedFile
{
$path = public_path("images/tennants/onticket/{$filename}");
if (! is_file($path)) {
throw new RuntimeException("Image not found at path: {$path}");
}
$mimeType = match (strtolower(pathinfo($filename, PATHINFO_EXTENSION))) {
'svg' => 'image/svg+xml',
default => 'image/png',
};
return new UploadedFile($path, $filename, $mimeType, null, true);
}
}

View File

@@ -3,10 +3,8 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Services\TenantService; use App\Domains\Core\Tenant\Services\TenantProvisioningService;
use App\Domains\Core\Tenant\Services\WebsiteExtraService;
use App\Shared\Attachable\Models\Attachment; use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService; use App\Shared\Attachable\Services\AttachmentService;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -16,8 +14,6 @@ use Throwable;
class TenantSeeder extends Seeder class TenantSeeder extends Seeder
{ {
private const ONTICKET_CLIENT_CODE = 'onticket';
private const PYME_RURAL_CLIENT_CODE = 'pyme_rural'; private const PYME_RURAL_CLIENT_CODE = 'pyme_rural';
private const SONDER_CLIENT_CODE = 'sonder'; private const SONDER_CLIENT_CODE = 'sonder';
@@ -46,8 +42,7 @@ class TenantSeeder extends Seeder
]; ];
public function __construct( public function __construct(
protected TenantService $tenantService, protected TenantProvisioningService $tenantService,
protected WebsiteExtraService $websiteExtraService,
) {} ) {}
public function run(): void public function run(): void
@@ -57,89 +52,12 @@ class TenantSeeder extends Seeder
['name' => 'Sonder'], ['name' => 'Sonder'],
); );
$onTicketClient = Client::query()->firstOrCreate(
['code' => self::ONTICKET_CLIENT_CODE],
['name' => 'OnTicket'],
);
$pymeRuralClient = Client::query()->updateOrCreate( $pymeRuralClient = Client::query()->updateOrCreate(
['code' => self::PYME_RURAL_CLIENT_CODE], ['code' => self::PYME_RURAL_CLIENT_CODE],
['name' => 'Pyme Rural'], ['name' => 'Pyme Rural'],
); );
$onTicketType = AdminWebsiteType::query()->where('codigo', 'onticket')->firstOrFail(); $this->call(OnTicketTenantSeeder::class);
$onTicketTenant = Tenant::query()->where('codigo', 'onticket')->first();
if ($onTicketTenant === null) {
$this->tenantService->create([
'client_id' => $onTicketClient->id,
'codigo' => 'onticket',
'nombre' => $onTicketType->nombre,
'dominio' => $onTicketType->dominio,
'site_title' => $onTicketType->site_title,
'primary_color' => $onTicketType->primary_color,
'secondary_color' => $onTicketType->secondary_color,
'danger_color' => $onTicketType->danger_color,
'success_color' => $onTicketType->success_color,
'header_bg_color' => $onTicketType->surface_color,
'footer_bg_color' => $onTicketType->primary_color,
'display_categories' => true,
'display_seach_bar' => true,
'admin_website_type_code' => $onTicketType->codigo,
'storefront_website_type_code' => 'onticket_multi_event',
'header_logo' => $this->uploadedImage(
'images/tennants/onticket/onticket_logo.png',
'onticket_logo.png',
),
'footer_logo' => $this->uploadedImage(
'images/tennants/onticket/onticket_footer_logo.png',
'onticket_footer_logo.png',
),
'favicon' => $this->uploadedImage(
'images/tennants/onticket/onticket_favicon.svg',
'onticket_favicon.svg',
),
'header_bg_image' => $this->uploadedImage(
'images/tennants/onticket/onticket_header_background.png',
'onticket_header_background.png',
),
]);
} elseif ($onTicketTenant->header_bg_image_id === null) {
$this->tenantService->update($onTicketTenant, [
'header_bg_image' => $this->uploadedImage(
'images/tennants/onticket/onticket_header_background.png',
'onticket_header_background.png',
),
]);
}
if ($onTicketTenant !== null && $onTicketTenant->storefront_website_type_code === 'onticket') {
$onTicketTenant->update(['storefront_website_type_code' => 'onticket_multi_event']);
}
$onTicketTenant = Tenant::query()->where('codigo', 'onticket')->firstOrFail();
foreach (['Música', 'Teatro', 'Deportes', 'Infantiles', 'Otros'] as $nombre) {
$onTicketTenant->eventCategories()->firstOrCreate(['nombre' => $nombre]);
}
if ($onTicketTenant->storefront_website_type_code === 'onticket_multi_event' && ! $onTicketTenant->display_seach_bar) {
$onTicketTenant->update(['display_seach_bar' => true]);
}
if (
$onTicketTenant->storefront_website_type_code === 'onticket_multi_event'
&& ! $onTicketTenant->websiteExtras()->whereHas('websiteTypeExtra', fn ($query) => $query->where('codigo', 'immersiveHero'))->exists()
) {
$this->websiteExtraService->updateForTenant($onTicketTenant, 'immersiveHero', [
'eyebrow' => 'Encendé tu',
'title' => 'experiencia',
'description' => 'Reservá tu entrada y formá parte',
]);
}
$this->call(OnticketImmersiveHeroCarouselSeeder::class);
$this->deleteTenant('sonder'); $this->deleteTenant('sonder');