feat: verify active opportunity management and prepare versioned resources
This commit is contained in:
@@ -4,26 +4,31 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityImageProcessor;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityResourceInstallation;
|
||||
use App\Services\OpportunityResources;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
|
||||
class OpportunityResourceController extends Controller
|
||||
{
|
||||
public function installation(Request $request, OpportunityResourceInstallation $service)
|
||||
public function installation(Request $request, OpportunityResourceInstallation $service): JsonResponse|Response
|
||||
{
|
||||
$preview = [...$service->preview(), 'actionUrl' => '/management/opportunity-resources-install'];
|
||||
|
||||
return $request->expectsJson() ? response()->json($preview) : Inertia::render('management/opportunity-install', $preview);
|
||||
}
|
||||
|
||||
public function applyInstallation(Request $request, OpportunityResourceInstallation $service)
|
||||
public function applyInstallation(Request $request, OpportunityResourceInstallation $service): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['hash' => 'required|string|size:64', 'confirm' => 'required|accepted']);
|
||||
$service->apply($request->user(), $data['hash']);
|
||||
@@ -31,7 +36,7 @@ class OpportunityResourceController extends Controller
|
||||
return back()->with('success', 'Recursos instalados; nenhum ficheiro publicado ou oportunidade submetida.');
|
||||
}
|
||||
|
||||
public function index(int $id, OpportunityResources $service)
|
||||
public function index(int $id, OpportunityResources $service): Response
|
||||
{
|
||||
$opportunity = Opportunity::withTrashed()->findOrFail($id);
|
||||
abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.');
|
||||
@@ -43,11 +48,19 @@ class OpportunityResourceController extends Controller
|
||||
$p = $location->payload;
|
||||
$location->territoryPath = app(InvestmentGeography::class)->path($p['locality_id'] ?? $p['admin_post_id'] ?? $p['district_id'] ?? $p['province_id'] ?? null, true);
|
||||
}
|
||||
foreach ($resources['images'] as $image) {
|
||||
$image->derivativePreviews = [];
|
||||
foreach ($image->payload['derivatives'] ?? [] as $derived) {
|
||||
if (DB::table('documents')->where('id', $derived['document_id'])->where('status', 'active')->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists()) {
|
||||
$image->derivativePreviews[] = [...$derived, 'url' => URL::temporarySignedRoute('opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image->id, 'documentId' => $derived['document_id']])];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'assessment' => $this->assessment($resources)]);
|
||||
}
|
||||
|
||||
public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null)
|
||||
public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null): JsonResponse
|
||||
{
|
||||
$input = $request->all();
|
||||
if (is_string($input['payload'] ?? null)) {
|
||||
@@ -58,14 +71,37 @@ class OpportunityResourceController extends Controller
|
||||
return response()->json(['id' => $record->id, 'message' => 'Recurso guardado no servidor. Não publicado; validação final e scan são independentes.', 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id))], $resourceId ? 200 : 201);
|
||||
}
|
||||
|
||||
public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service)
|
||||
public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service): JsonResponse
|
||||
{
|
||||
$service->lifecycle($request->user(), $id, $kind, $resourceId, $request->all());
|
||||
|
||||
return response()->json(['message' => 'Exclusão lógica/restauração registada; original e versões preservados.']);
|
||||
}
|
||||
|
||||
public function download(int $id, string $kind, string $resourceId, OpportunityResources $service)
|
||||
public function prepareImage(Request $request, int $id, string $resourceId, OpportunityImageProcessor $service): JsonResponse
|
||||
{
|
||||
$service->prepare($request->user(), $id, $resourceId, $request->all());
|
||||
|
||||
return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']);
|
||||
}
|
||||
|
||||
public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response
|
||||
{
|
||||
$o = Opportunity::findOrFail($id);
|
||||
abort_if($o->status === 'archived', 404);
|
||||
$image = DB::table('opportunity_images')->where('opportunity_id', $id)->where('id', $resourceId)->whereNull('deleted_at')->first();
|
||||
abort_unless($image !== null, 404);
|
||||
$metadata = json_decode($image->payload, true);
|
||||
abort_unless(in_array($documentId, array_column($metadata['derivatives'] ?? [], 'document_id'), true), 404);
|
||||
$doc = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first();
|
||||
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 423, 'Scan da derivada pendente.');
|
||||
$bytes = Crypt::decryptString(Storage::disk('local')->get($doc->storage_key));
|
||||
abort_unless(hash_equals(trim($doc->sha256), hash('sha256', $bytes)), 409, 'Integridade divergente.');
|
||||
|
||||
return response($bytes)->header('Content-Type', 'image/webp')->header('X-Content-Type-Options', 'nosniff')->header('Cache-Control', 'private,no-store');
|
||||
}
|
||||
|
||||
public function download(int $id, string $kind, string $resourceId, OpportunityResources $service): \Illuminate\Http\Response
|
||||
{
|
||||
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
|
||||
$opportunity = Opportunity::findOrFail($id);
|
||||
@@ -81,7 +117,7 @@ class OpportunityResourceController extends Controller
|
||||
return response($bytes)->header('Content-Type', $record->media_type)->header('Content-Disposition', HeaderUtils::makeDisposition('attachment', $record->file_name, 'document'))->header('X-Content-Type-Options', 'nosniff')->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
/** @param array<string,list<object>> $resources
|
||||
/** @param array<string,list<\stdClass>> $resources
|
||||
* @return list<string>
|
||||
*/
|
||||
private function assessment(array $resources): array
|
||||
@@ -127,6 +163,9 @@ class OpportunityResourceController extends Controller
|
||||
if (count($districts) > 1 && ! array_filter($locations, fn ($r) => mb_strlen($r->payload['multi_district_rationale'] ?? '') >= 50)) {
|
||||
$issues[] = 'Multidistrito/interprovincial exige fundamento operacional de 50–1000 caracteres.';
|
||||
}
|
||||
if ($locations) {
|
||||
$issues[] = 'Modo preparado: '.(count($locations) > 1 ? 'multiple' : 'single').'; âmbito derivado: '.(count($provinces) > 1 ? 'interprovincial' : (count($districts) > 1 ? 'multi_district' : 'district')).'. Sem conversão de contexto em implantação.';
|
||||
}
|
||||
foreach (['images', 'documents'] as $kind) {
|
||||
foreach ($resources[$kind] as $r) {
|
||||
if ($r->deleted_at) {
|
||||
@@ -142,7 +181,21 @@ class OpportunityResourceController extends Controller
|
||||
}
|
||||
}
|
||||
if ($kind === 'images') {
|
||||
$issues[] = 'Imagem '.$r->file->file_name.': revisão, derivadas sem EXIF e aprovação de divulgação ainda pendentes.';
|
||||
if (empty($r->payload['derivatives'])) {
|
||||
$issues[] = 'Imagem '.$r->file->file_name.': derivadas sem EXIF ainda por preparar (GD/WebP e scan necessários).';
|
||||
}
|
||||
$issues[] = 'Imagem '.$r->file->file_name.': revisão de dados pessoais/direitos e aprovação de divulgação ainda pendentes.';
|
||||
foreach (['title' => 5, 'caption' => 10, 'alt_text' => 10, 'author_source' => 2] as $field => $minimum) {
|
||||
if (mb_strlen($r->payload[$field] ?? '') < $minimum) {
|
||||
$issues[] = 'Imagem '.$r->file->file_name.': '.$field.' abaixo do mínimo para validação final.';
|
||||
}
|
||||
}
|
||||
if (($r->payload['role'] ?? null) === 'location' && empty($r->payload['location_id'])) {
|
||||
$issues[] = 'Imagem de localização exige ligação a um local ativo da mesma oportunidade.';
|
||||
}
|
||||
if (in_array($r->payload['role'] ?? null, ['asset', 'product', 'evidence'], true)) {
|
||||
$issues[] = 'Imagem de ativo/produto/evidência: módulo e associação específica ainda pendentes.';
|
||||
}
|
||||
if (in_array($r->payload['rights_status'] ?? null, ['licensed', 'authorized'], true) && empty($r->payload['rights_document_id'])) {
|
||||
$issues[] = 'Imagem licenciada/autorizada exige comprovativo.';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user