feat(authorization): implement WebsiteExtra management with controller, request, resource, service, routes, middleware, and tests
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtrasRequest;
|
||||
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtrasResource;
|
||||
use App\Domains\Tenant\Services\TenantInformationService;
|
||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebsiteExtraController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected WebsiteExtraService $websiteExtraService,
|
||||
protected TenantInformationService $tenantInformationService,
|
||||
) {}
|
||||
|
||||
public function show(Request $request): WebsiteExtrasResource
|
||||
{
|
||||
return WebsiteExtrasResource::make(
|
||||
$this->loadTenant($request->user())
|
||||
);
|
||||
}
|
||||
|
||||
public function update(UpdateWebsiteExtrasRequest $request): WebsiteExtrasResource
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
$this->websiteExtraService->replaceForTenant(
|
||||
$tenant,
|
||||
$request->validated('extras', [])
|
||||
);
|
||||
|
||||
return WebsiteExtrasResource::make(
|
||||
$this->loadTenant($request->user())
|
||||
);
|
||||
}
|
||||
|
||||
private function loadTenant(User $user): Tenant
|
||||
{
|
||||
$tenant = $user->tenant()->firstOrFail();
|
||||
|
||||
return $this->tenantInformationService->load($tenant, [
|
||||
'websiteType.extras',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Requests\AdminApp;
|
||||
|
||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateWebsiteExtrasRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return app(WebsiteExtraService::class)->requestRules(
|
||||
$this->user()?->tenant?->website_type_code
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Resources\AdminApp;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Tenant
|
||||
*/
|
||||
class WebsiteExtrasResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$websiteExtras = $this->websiteExtras->keyBy(
|
||||
fn ($extra) => $extra->websiteTypeExtra->nombre
|
||||
);
|
||||
|
||||
return [
|
||||
'website_type' => $this->websiteType ? [
|
||||
'codigo' => $this->websiteType->codigo,
|
||||
'nombre' => $this->websiteType->nombre,
|
||||
] : null,
|
||||
'definitions' => $this->websiteType?->extras
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$definition->nombre => [
|
||||
'descripcion' => $definition->descripcion,
|
||||
'is_required' => $definition->is_required,
|
||||
'request_rules' => $definition->config_schema['request_rules'] ?? [],
|
||||
],
|
||||
]) ?? [],
|
||||
'extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
|
||||
$extra->websiteTypeExtra->nombre => $this->formatConfig(
|
||||
$extra->resolvedConfig(),
|
||||
fn (Attachment $attachment): string => $attachment->key
|
||||
),
|
||||
]),
|
||||
'resolved_extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
|
||||
$extra->websiteTypeExtra->nombre => $this->formatConfig(
|
||||
$extra->resolvedConfig(),
|
||||
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatConfig(mixed $value, callable $formatAttachment): mixed
|
||||
{
|
||||
if ($value instanceof Attachment) {
|
||||
return $formatAttachment($value);
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (mixed $item): mixed => $this->formatConfig($item, $formatAttachment),
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
use App\Domains\Tenant\Models\WebsiteTypeExtra;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -118,6 +119,21 @@ class WebsiteExtraService
|
||||
$tenant->unsetRelation('websiteExtras');
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all configured extras for a tenant.
|
||||
*
|
||||
* @param array<string, mixed> $extras
|
||||
*/
|
||||
public function replaceForTenant(Tenant $tenant, array $extras): void
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $extras): void {
|
||||
$tenant->websiteExtras()->delete();
|
||||
$this->createForTenant($tenant, $extras);
|
||||
});
|
||||
|
||||
$tenant->unsetRelation('websiteExtras');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, WebsiteTypeExtra>
|
||||
*/
|
||||
|
||||
13
app/Domains/Tenant/routes/adminapp.php
Normal file
13
app/Domains/Tenant/routes/adminapp.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Tenant\Controllers\AdminApp\WebsiteExtraController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->group(function (): void {
|
||||
Route::get('website-extras', [WebsiteExtraController::class, 'show'])
|
||||
->name('adminapp.tenant.website-extras.show');
|
||||
Route::put('website-extras', [WebsiteExtraController::class, 'update'])
|
||||
->name('adminapp.tenant.website-extras.update');
|
||||
});
|
||||
@@ -8,3 +8,5 @@ Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
|
||||
->where('dominio', '.*');
|
||||
|
||||
Route::apiResource('tenants', TenantController::class);
|
||||
|
||||
require __DIR__.'/adminapp.php';
|
||||
|
||||
30
app/Http/Middleware/EnsureAdminAppTenant.php
Normal file
30
app/Http/Middleware/EnsureAdminAppTenant.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use Closure;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAdminAppTenant
|
||||
{
|
||||
/**
|
||||
* Ensure the authenticated user is an AdminApp user bound to a tenant.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (
|
||||
! $user
|
||||
|| $user->rol_codigo !== RoleCode::AdminApp->value
|
||||
|| ! $user->tenant_codigo
|
||||
) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||
use App\Http\Middleware\EnsureAdminAppTenant;
|
||||
use App\Http\Middleware\SetApiLocale;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
@@ -21,6 +22,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'adminapp.tenant' => EnsureAdminAppTenant::class,
|
||||
]);
|
||||
$middleware->encryptCookies(except: [
|
||||
'guest_token',
|
||||
]);
|
||||
|
||||
155
tests/Feature/Tenant/AdminAppWebsiteExtraControllerTest.php
Normal file
155
tests/Feature/Tenant/AdminAppWebsiteExtraControllerTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
use Database\Seeders\AuthorizationSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminAppWebsiteExtraControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private WebsiteType $websiteType;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(AuthorizationSeeder::class);
|
||||
|
||||
$this->websiteType = WebsiteType::query()->create([
|
||||
'codigo' => 'test-store',
|
||||
'nombre' => 'Test Store',
|
||||
]);
|
||||
|
||||
$this->websiteType->extras()->create([
|
||||
'nombre' => 'contactConfig',
|
||||
'descripcion' => 'Datos de contacto visibles en la tienda.',
|
||||
'is_required' => false,
|
||||
'config_schema' => [
|
||||
'request_rules' => [
|
||||
'$' => 'required|array',
|
||||
'phone' => 'required|string|max:30',
|
||||
],
|
||||
'transforms' => [],
|
||||
'database_rules' => [
|
||||
'$' => 'required|array',
|
||||
'phone' => 'required|string|max:30',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_authentication_is_required(): void
|
||||
{
|
||||
$this->getJson('/api/v1/adminapp/tenant/website-extras')
|
||||
->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_a_customer_cannot_access_adminapp_website_extras(): void
|
||||
{
|
||||
$customer = User::factory()->create([
|
||||
'rol_codigo' => RoleCode::User->value,
|
||||
'tenant_codigo' => null,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($customer);
|
||||
|
||||
$this->getJson('/api/v1/adminapp/tenant/website-extras')
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_adminapp_user_can_read_definitions_and_current_values(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$definition = $this->websiteType->extras()->firstOrFail();
|
||||
$tenant->websiteExtras()->create([
|
||||
'website_type_extra_id' => $definition->id,
|
||||
'config' => ['phone' => '+54 341 555 0101'],
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->getJson('/api/v1/adminapp/tenant/website-extras')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.website_type.codigo', 'test-store')
|
||||
->assertJsonPath('data.definitions.contactConfig.is_required', false)
|
||||
->assertJsonPath('data.extras.contactConfig.phone', '+54 341 555 0101')
|
||||
->assertJsonPath('data.resolved_extras.contactConfig.phone', '+54 341 555 0101');
|
||||
}
|
||||
|
||||
public function test_adminapp_user_replaces_only_its_tenant_extras(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$otherTenant = $this->createTenant('other');
|
||||
$definition = $this->websiteType->extras()->firstOrFail();
|
||||
|
||||
$tenant->websiteExtras()->create([
|
||||
'website_type_extra_id' => $definition->id,
|
||||
'config' => ['phone' => 'old'],
|
||||
]);
|
||||
$otherTenant->websiteExtras()->create([
|
||||
'website_type_extra_id' => $definition->id,
|
||||
'config' => ['phone' => 'untouched'],
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->putJson('/api/v1/adminapp/tenant/website-extras', [
|
||||
'extras' => [
|
||||
'contactConfig' => [
|
||||
'phone' => '+54 341 555 9999',
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.extras.contactConfig.phone', '+54 341 555 9999');
|
||||
|
||||
$this->assertSame(
|
||||
['phone' => '+54 341 555 9999'],
|
||||
$tenant->websiteExtras()->firstOrFail()->config
|
||||
);
|
||||
$this->assertSame(
|
||||
['phone' => 'untouched'],
|
||||
$otherTenant->websiteExtras()->firstOrFail()->config
|
||||
);
|
||||
}
|
||||
|
||||
public function test_update_rejects_extras_not_supported_by_the_website_type(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->putJson('/api/v1/adminapp/tenant/website-extras', [
|
||||
'extras' => [
|
||||
'unknown' => ['enabled' => true],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('extras');
|
||||
}
|
||||
|
||||
private function createTenant(string $code): Tenant
|
||||
{
|
||||
return Tenant::query()->create([
|
||||
'codigo' => $code,
|
||||
'nombre' => ucfirst($code),
|
||||
'dominio' => "{$code}.test",
|
||||
'website_type_code' => $this->websiteType->codigo,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createAdminAppUser(Tenant $tenant): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'rol_codigo' => RoleCode::AdminApp->value,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user