feat(brand): implement update functionality and validation for brand details
This commit is contained in:
@@ -2,17 +2,30 @@
|
|||||||
|
|
||||||
namespace App\Domains\Core\Tenant\Controllers\AdminApp;
|
namespace App\Domains\Core\Tenant\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Core\Tenant\Requests\AdminApp\UpdateBrandRequest;
|
||||||
use App\Domains\Core\Tenant\Resources\AdminApp\BrandResource;
|
use App\Domains\Core\Tenant\Resources\AdminApp\BrandResource;
|
||||||
|
use App\Domains\Core\Tenant\Services\BrandService;
|
||||||
use App\Domains\Core\Tenant\Services\TenantInformationService;
|
use App\Domains\Core\Tenant\Services\TenantInformationService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class BrandController extends Controller
|
class BrandController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(Request $request, TenantInformationService $tenantInformationService): BrandResource
|
public function show(Request $request, TenantInformationService $tenantInformationService): BrandResource
|
||||||
{
|
{
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return BrandResource::make($tenantInformationService->load($tenant));
|
return BrandResource::make($tenantInformationService->load($tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
UpdateBrandRequest $request,
|
||||||
|
BrandService $brandService,
|
||||||
|
TenantInformationService $tenantInformationService,
|
||||||
|
): BrandResource {
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
$brandService->update($tenant, $request->validated());
|
||||||
|
|
||||||
|
return BrandResource::make($tenantInformationService->load($tenant->fresh()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Core\Tenant\Requests\AdminApp;
|
||||||
|
|
||||||
|
use App\Shared\Rules\CroppedImageOrBase64Rule;
|
||||||
|
use App\Shared\Rules\ImageOrBase64Rule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateBrandRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$shopit = $this->user()->tenant()->firstOrFail()->storefront_website_type_code === 'shopit';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
|
'primary_color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||||
|
'footer_bg_color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||||
|
'header_logo' => ['present', 'nullable', new ImageOrBase64Rule],
|
||||||
|
'footer_logo' => ['present', 'nullable', new ImageOrBase64Rule],
|
||||||
|
'banner_images' => [$shopit ? 'present' : 'prohibited', 'array', 'max:10'],
|
||||||
|
'banner_images.*' => ['required', new CroppedImageOrBase64Rule],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,10 @@ class BrandResource extends JsonResource
|
|||||||
'header_bg_color' => $this->header_bg_color,
|
'header_bg_color' => $this->header_bg_color,
|
||||||
'footer_color' => $this->footerColor(),
|
'footer_color' => $this->footerColor(),
|
||||||
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
||||||
|
'header_logo_key' => $this->headerLogo?->key,
|
||||||
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
|
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
|
||||||
|
'footer_logo_key' => $this->footerLogo?->key,
|
||||||
|
'can_edit_carousel' => $this->canEditCarousel(),
|
||||||
'banner_images' => $this->bannerImages(),
|
'banner_images' => $this->bannerImages(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -48,7 +51,7 @@ class BrandResource extends JsonResource
|
|||||||
/** @return array<int, array<string, mixed>> */
|
/** @return array<int, array<string, mixed>> */
|
||||||
private function bannerImages(): array
|
private function bannerImages(): array
|
||||||
{
|
{
|
||||||
if ($this->storefront_website_type_code !== 'shopit') {
|
if (! $this->canEditCarousel()) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +76,7 @@ class BrandResource extends JsonResource
|
|||||||
->firstWhere('variant', AttachmentCrop::DESKTOP);
|
->firstWhere('variant', AttachmentCrop::DESKTOP);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'key' => $image->key,
|
||||||
'url' => ($desktopCrop?->croppedAttachment ?? $image)->getTemporaryUrl(1440),
|
'url' => ($desktopCrop?->croppedAttachment ?? $image)->getTemporaryUrl(1440),
|
||||||
'original_url' => $image->getTemporaryUrl(1440),
|
'original_url' => $image->getTemporaryUrl(1440),
|
||||||
'crop' => $desktopCrop === null ? null : [
|
'crop' => $desktopCrop === null ? null : [
|
||||||
@@ -84,4 +88,17 @@ class BrandResource extends JsonResource
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canEditCarousel(): bool
|
||||||
|
{
|
||||||
|
if ($this->storefront_website_type_code !== 'shopit') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extra = $this->websiteExtras->first(
|
||||||
|
fn ($extra) => $extra->websiteTypeExtra->codigo === 'carousel'
|
||||||
|
);
|
||||||
|
|
||||||
|
return $extra === null || $extra->is_enabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
165
app/Domains/Core/Tenant/Services/BrandService.php
Normal file
165
app/Domains/Core/Tenant/Services/BrandService.php
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Core\Tenant\Services;
|
||||||
|
|
||||||
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
|
use App\Shared\Attachable\Enums\AttachmentType;
|
||||||
|
use App\Shared\Attachable\Models\Attachment;
|
||||||
|
use App\Shared\Attachable\Services\AttachmentService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class BrandService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AttachmentService $attachmentService,
|
||||||
|
private readonly WebsiteExtraService $websiteExtraService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function update(Tenant $tenant, array $data): void
|
||||||
|
{
|
||||||
|
$carousel = $tenant->storefront_website_type_code === 'shopit'
|
||||||
|
? $tenant->websiteExtras()->whereHas('websiteTypeExtra',
|
||||||
|
fn ($query) => $query->where('codigo', 'carousel'))->first()
|
||||||
|
: null;
|
||||||
|
$allowedBannerIds = is_array($carousel?->config) ? $carousel->config : [];
|
||||||
|
if ($carousel !== null && ! $carousel->is_enabled && ($data['banner_images'] ?? []) !== []) {
|
||||||
|
throw ValidationException::withMessages(['banner_images' => 'El carrusel está deshabilitado.']);
|
||||||
|
}
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach (['header_logo' => $tenant->header_logo_id, 'footer_logo' => $tenant->footer_logo_id] as $field => $id) {
|
||||||
|
$this->existingImage($data[$field], $id === null ? [] : [$id], $field);
|
||||||
|
}
|
||||||
|
foreach ($data['banner_images'] ?? [] as $index => $item) {
|
||||||
|
$image = is_array($item) ? $item['image'] : $item;
|
||||||
|
$attachment = $this->existingImage($image, $allowedBannerIds, "banner_images.{$index}");
|
||||||
|
if ($attachment !== null) {
|
||||||
|
if (isset($seen[$attachment->id])) {
|
||||||
|
throw ValidationException::withMessages(["banner_images.{$index}" => 'La imagen está repetida.']);
|
||||||
|
}
|
||||||
|
$seen[$attachment->id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$created = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($tenant, $data, $carousel, &$created): void {
|
||||||
|
$header = $this->logo($data['header_logo'], $tenant->header_logo_id, $tenant, $created);
|
||||||
|
$footer = $this->logo($data['footer_logo'], $tenant->footer_logo_id, $tenant, $created);
|
||||||
|
|
||||||
|
$tenant->update([
|
||||||
|
'nombre' => trim($data['nombre']),
|
||||||
|
'primary_color' => $data['primary_color'],
|
||||||
|
'footer_bg_color' => $data['footer_bg_color'],
|
||||||
|
'header_logo_id' => $header?->id,
|
||||||
|
'footer_logo_id' => $footer?->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($tenant->storefront_website_type_code !== 'shopit'
|
||||||
|
|| ($carousel !== null && ! $carousel->is_enabled)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $this->websiteExtraService->definitionForTenant($tenant, 'carousel');
|
||||||
|
$ids = [];
|
||||||
|
$recrops = [];
|
||||||
|
foreach ($data['banner_images'] as $item) {
|
||||||
|
$image = is_array($item) ? $item['image'] : $item;
|
||||||
|
$attachment = $this->existingImage($image, $carousel?->config ?? [], 'banner_images');
|
||||||
|
$crops = $this->crops($item);
|
||||||
|
|
||||||
|
if ($attachment === null) {
|
||||||
|
$attachment = $crops === null
|
||||||
|
? $this->attachmentService->store($image, "tenants/{$tenant->codigo}/extras/carousel")
|
||||||
|
: $this->attachmentService->storeCroppedImageVariants(
|
||||||
|
$image, "tenants/{$tenant->codigo}/extras/carousel", $crops
|
||||||
|
);
|
||||||
|
$created[] = $attachment;
|
||||||
|
} elseif ($crops !== null) {
|
||||||
|
$recrops[] = [$attachment, $crops];
|
||||||
|
}
|
||||||
|
$ids[] = $attachment->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->websiteExtras()->updateOrCreate(
|
||||||
|
['website_type_extra_id' => $definition->id],
|
||||||
|
['config' => $ids]
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($recrops as [$attachment, $crops]) {
|
||||||
|
$this->attachmentService->updateImageCropVariants($attachment, $crops);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
foreach ($created as $attachment) {
|
||||||
|
$paths = [$attachment->path];
|
||||||
|
foreach ($attachment->cropVariants as $crop) {
|
||||||
|
if ($crop->croppedAttachment !== null) {
|
||||||
|
$paths[] = $crop->croppedAttachment->path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Storage::disk('s3')->delete($paths);
|
||||||
|
}
|
||||||
|
throw $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, int> $allowedIds */
|
||||||
|
private function existingImage(mixed $value, array $allowedIds, string $field): ?Attachment
|
||||||
|
{
|
||||||
|
if (! is_string($value) || ! Str::isUuid($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment = Attachment::query()->where('key', $value)->first();
|
||||||
|
if ($attachment === null || $attachment->type !== AttachmentType::Image
|
||||||
|
|| ! in_array($attachment->id, $allowedIds, true)) {
|
||||||
|
throw ValidationException::withMessages([$field => 'La imagen no pertenece a esta marca.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, Attachment> $created */
|
||||||
|
private function logo(mixed $value, ?int $currentId, Tenant $tenant, array &$created): ?Attachment
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->existingImage($value, $currentId === null ? [] : [$currentId], 'logo');
|
||||||
|
if ($existing !== null) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment = $this->attachmentService->store($value, "tenants/{$tenant->codigo}/logos");
|
||||||
|
$created[] = $attachment;
|
||||||
|
|
||||||
|
return $attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<string, mixed>>|null */
|
||||||
|
private function crops(mixed $item): ?array
|
||||||
|
{
|
||||||
|
if (! is_array($item)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($item['crop_horizontal'], $item['crop_vertical'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$crop = [
|
||||||
|
'crop_horizontal' => $item['crop_horizontal'],
|
||||||
|
'crop_vertical' => $item['crop_vertical'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return ['desktop' => $crop, 'mobile' => $crop];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ use Illuminate\Support\Facades\Route;
|
|||||||
Route::prefix('v1/adminapp/tenant')
|
Route::prefix('v1/adminapp/tenant')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('brand', BrandController::class);
|
Route::get('brand', [BrandController::class, 'show']);
|
||||||
|
Route::put('brand', [BrandController::class, 'update']);
|
||||||
Route::get('website-extras', [WebsiteExtraController::class, 'show']);
|
Route::get('website-extras', [WebsiteExtraController::class, 'show']);
|
||||||
Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']);
|
Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']);
|
||||||
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);
|
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Shared\Attachable\Models\Attachment;
|
|||||||
use App\Shared\Attachable\Models\AttachmentCrop;
|
use App\Shared\Attachable\Models\AttachmentCrop;
|
||||||
use Database\Seeders\AuthorizationSeeder;
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -80,8 +81,10 @@ class AdminAppBrandControllerTest extends TestCase
|
|||||||
|
|
||||||
foreach (['header_logo' => $header, 'footer_logo' => $footer] as $field => $image) {
|
foreach (['header_logo' => $header, 'footer_logo' => $footer] as $field => $image) {
|
||||||
$this->assertStringContainsString($image->path, $response->json("data.{$field}"));
|
$this->assertStringContainsString($image->path, $response->json("data.{$field}"));
|
||||||
|
$response->assertJsonPath("data.{$field}_key", $image->key);
|
||||||
}
|
}
|
||||||
foreach ([$first, $second] as $index => $image) {
|
foreach ([$first, $second] as $index => $image) {
|
||||||
|
$response->assertJsonPath("data.banner_images.{$index}.key", $image->key);
|
||||||
$this->assertStringContainsString(
|
$this->assertStringContainsString(
|
||||||
$image->path,
|
$image->path,
|
||||||
$response->json("data.banner_images.{$index}.original_url")
|
$response->json("data.banner_images.{$index}.original_url")
|
||||||
@@ -94,6 +97,63 @@ class AdminAppBrandControllerTest extends TestCase
|
|||||||
$this->assertStringNotContainsString('other.png', $response->getContent());
|
$this->assertStringNotContainsString('other.png', $response->getContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_keeps_original_carousel_attachment_and_returns_its_crop(): void
|
||||||
|
{
|
||||||
|
$websiteType = $this->websiteType('shopit');
|
||||||
|
$carousel = $websiteType->extras()->create([
|
||||||
|
'codigo' => 'carousel', 'nombre' => 'Carrusel', 'is_required' => false,
|
||||||
|
'config_schema' => ['request_rules' => [], 'transforms' => [
|
||||||
|
'$.*' => ['handler' => 'attachment', 'attachment_type' => 'image'],
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
$tenant = $this->tenant('acme', $websiteType, [
|
||||||
|
'primary_color' => '#112233', 'footer_bg_color' => '#112233',
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs($this->admin($tenant));
|
||||||
|
$file = UploadedFile::fake()->image('banner.png', 400, 100);
|
||||||
|
$dataUrl = 'data:image/png;base64,'.base64_encode(file_get_contents($file->getRealPath()));
|
||||||
|
|
||||||
|
$response = $this->putJson('/api/v1/adminapp/tenant/brand', [
|
||||||
|
'nombre' => 'Acme Nueva', 'primary_color' => '#abcdef',
|
||||||
|
'footer_bg_color' => '#abcdef', 'header_logo' => null, 'footer_logo' => null,
|
||||||
|
'banner_images' => [[
|
||||||
|
'image' => $dataUrl,
|
||||||
|
'crop_horizontal' => ['start_percentage' => 10, 'end_percentage' => 90],
|
||||||
|
'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100],
|
||||||
|
]],
|
||||||
|
])->assertOk()
|
||||||
|
->assertJsonPath('data.nombre', 'Acme Nueva')
|
||||||
|
->assertJsonPath('data.footer_color', 'primary')
|
||||||
|
->assertJsonPath('data.banner_images.0.crop.crop_horizontal.start_percentage', 10);
|
||||||
|
|
||||||
|
$original = Attachment::query()->where('key', $response->json('data.banner_images.0.key'))->firstOrFail();
|
||||||
|
$this->assertSame([$original->id], $tenant->websiteExtras()
|
||||||
|
->where('website_type_extra_id', $carousel->id)->firstOrFail()->config);
|
||||||
|
$this->assertStringContainsString($original->path, $response->json('data.banner_images.0.original_url'));
|
||||||
|
$this->assertNotSame($response->json('data.banner_images.0.original_url'),
|
||||||
|
$response->json('data.banner_images.0.url'));
|
||||||
|
|
||||||
|
$updated = $this->putJson('/api/v1/adminapp/tenant/brand', [
|
||||||
|
'nombre' => 'Acme Nueva', 'primary_color' => '#abcdef',
|
||||||
|
'footer_bg_color' => '#abcdef', 'header_logo' => null, 'footer_logo' => null,
|
||||||
|
'banner_images' => [[
|
||||||
|
'image' => $original->key,
|
||||||
|
'crop_horizontal' => ['start_percentage' => 20, 'end_percentage' => 80],
|
||||||
|
'crop_vertical' => ['start_percentage' => 0, 'end_percentage' => 100],
|
||||||
|
]],
|
||||||
|
])->assertOk()
|
||||||
|
->assertJsonPath('data.banner_images.0.key', $original->key)
|
||||||
|
->assertJsonPath('data.banner_images.0.crop.crop_horizontal.start_percentage', 20);
|
||||||
|
$this->assertSame([$original->id], $tenant->websiteExtras()
|
||||||
|
->where('website_type_extra_id', $carousel->id)->firstOrFail()->config);
|
||||||
|
$this->assertNotSame($response->json('data.banner_images.0.url'),
|
||||||
|
$updated->json('data.banner_images.0.url'));
|
||||||
|
|
||||||
|
$this->assertSame('#abcdef', $tenant->refresh()->footer_bg_color);
|
||||||
|
$tenant->update(['primary_color' => '#123456']);
|
||||||
|
$this->assertSame('#abcdef', $tenant->refresh()->footer_bg_color);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_footer_color_is_mapped_to_a_supported_option(): void
|
public function test_footer_color_is_mapped_to_a_supported_option(): void
|
||||||
{
|
{
|
||||||
$websiteType = $this->websiteType('shopit');
|
$websiteType = $this->websiteType('shopit');
|
||||||
|
|||||||
Reference in New Issue
Block a user