preview(), 'imageProcessingAvailable' => app(OpportunityImageProcessor::class)->available(), 'actionUrl' => '/management/opportunity-resources-install']; $preview['changes'] = [...$preview['changes'], 'Código reconciliado: catálogo ISO comum; residência e constituição distintas; origem declarada guardada no payload existente e estatísticas internas autorizadas. Não converte países históricos nem usa IP.', 'Seletores territoriais reutilizados com pesquisa; filtros contextuais do catálogo e fichas territoriais consultáveis no percurso do mapa. Zero geometrias não permite desenhar estruturas sem dados.', 'Não reinstala a gestão básica nem acrescenta associações territoriais. Revisão transversal, cadastro completo em todos os campos e percursos educativos continuam parciais; consultar a matriz por módulo.']; return $request->expectsJson() ? response()->json($preview) : Inertia::render('management/opportunity-install', $preview); } 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']); return back()->with('success', 'Recursos instalados; nenhum ficheiro publicado ou oportunidade submetida.'); } public function organizationCountryPreview(Request $request, OrganizationCountryInstallation $service): JsonResponse|Response { $plan = $service->preview($request->user()); $scope = $this->countryOperationScope($request, $plan); $operation = $request->session()->get('portal.country_installation_operation'); if (! is_array($operation) || ($operation['scope'] ?? null) !== $scope) { $operation = ['scope' => $scope, 'key' => (string) Str::uuid()]; $request->session()->put('portal.country_installation_operation', $operation); } $plan = [...$plan, 'operationKey' => $operation['key'], 'actionUrl' => '/management/opportunity-resources-install/organization-country', 'extension' => true]; return $request->expectsJson() ? response()->json($plan) : Inertia::render('management/opportunity-install', $plan); } public function organizationCountryApply(Request $request, OrganizationCountryInstallation $service): JsonResponse|RedirectResponse { // Preview authorizes the actor; the UUID remains stable until this scope changes or logout. $plan = $service->preview($request->user()); $operation = $request->session()->get('portal.country_installation_operation'); $data = $request->validate(['idempotency_key' => 'required|uuid']); abort_unless(is_array($operation) && ($operation['scope'] ?? null) === $this->countryOperationScope($request, $plan) && hash_equals($operation['key'], $data['idempotency_key']), 409, 'A operação não corresponde a esta prévia. Atualize a página antes de tentar novamente.'); $result = $service->apply($request->user(), $request->all()); return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País mestre instalado; nenhuma submissão anterior alterada.'); } /** @param array $plan */ private function countryOperationScope(Request $request, array $plan): string { return hash('sha256', json_encode([$request->user()->getAuthIdentifier(), $plan['target'], $plan['migration'], $plan['code_hash']], JSON_THROW_ON_ERROR)); } public function index(int $id, OpportunityResources $service): \Symfony\Component\HttpFoundation\Response { $opportunity = Opportunity::withTrashed()->findOrFail($id); app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity); Inertia::share(['basePath' => str_starts_with(request()->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities', 'readOnly' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity)]); abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.'); $resources = []; Inertia::share('imageProcessingAvailable', app(OpportunityImageProcessor::class)->available()); Inertia::share('countries', app(CountryCatalogue::class)->all()); foreach (OpportunityResources::KINDS as $kind) { $resources[$kind] = $service->listing($id, $kind); } foreach ($resources['locations'] as $location) { $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(str_starts_with(request()->path(), 'minhas-oportunidades') ? 'promoter.opportunity-image-preview' : 'management.opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image->id, 'documentId' => $derived['document_id']])]; } } } Inertia::encryptHistory(); return tap(Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'technicalIncidents' => app(OpportunityTechnicalIncidents::class)->recent(request()->user(), $opportunity), 'librarySources' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(OpportunityLibrarySupport::class)->choices(request()->user()), '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']), 'locationReference' => app(OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)])->toResponse(request()), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); } public function reuseLibrary(Request $request, int $id): JsonResponse { $row = app(OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all()); return response()->json(['id' => $row->id, 'message' => 'Original associado como suporte privado, sem copiar ficheiros ou publicar.']); } public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null): JsonResponse { $input = $request->all(); if (is_string($input['payload'] ?? null)) { $input['payload'] = json_decode($input['payload'], true); } $operation = function () use ($request, $id, $kind, $input, $resourceId, $service) { $record = $service->save($request->user(), $id, $kind, $input, $request->file('file'), $resourceId); 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); }; if (! $request->hasFile('file') || ! in_array($kind, ['documents', 'images'], true)) { return $operation(); } $before = Opportunity::withTrashed()->findOrFail($id); app(PortalProfileAccess::class)->authorize($request->user(), 'edit', $before); return app(OpportunityTechnicalExecution::class)->run($request->user(), $before, $kind === 'images' ? 'upload_image' : 'upload_document', $input, $operation); } 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 prepareImage(Request $request, int $id, string $resourceId, OpportunityImageProcessor $service): JsonResponse { $before = Opportunity::withTrashed()->findOrFail($id); app(PortalProfileAccess::class)->authorize($request->user(), 'edit', $before); return app(OpportunityTechnicalExecution::class)->run($request->user(), $before, 'prepare_image', $request->all(), function () use ($request, $id, $resourceId, $service) { $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|BinaryFileResponse { $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.'); return app(VerifiedDocumentBinary::class)->respond($doc, true); } public function download(int $id, string $kind, string $resourceId, OpportunityResources $service): \Illuminate\Http\Response|BinaryFileResponse { abort_unless(in_array($kind, ['documents', 'images'], true), 404); $opportunity = Opportunity::findOrFail($id); app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity); abort_if($opportunity->status === 'archived', 404); $record = DB::table($service->table($kind).' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $resourceId)->where('r.opportunity_id', $id)->whereNull('r.deleted_at')->whereNull('d.deleted_at')->first(['d.*']); abort_unless($record !== null, 404, 'Recurso indisponível ou excluído logicamente.'); abort_unless($record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.'); $support = DB::table($service->table($kind))->where('id', $resourceId)->first(); app(OpportunityLibrarySupport::class)->assertAvailable($support, $record); if (request()->filled('source_sha256')) { abort_unless(hash_equals(trim($record->sha256), (string) request('source_sha256')), 409, 'O original já não corresponde à versão consultada.'); } return app(VerifiedDocumentBinary::class)->respond($record); } /** @param array> $resources * @return list */ private function assessment(array $resources): array { $issues = []; $locations = array_values(array_filter($resources['locations'], fn ($r) => ! $r->deleted_at)); if (count($locations) < 1) { $issues[] = 'Localização operacional ainda não preparada; ligações históricas não são convertidas automaticamente.'; } if (count(array_filter($locations, fn ($r) => $r->payload['is_primary'] ?? false)) !== 1) { $issues[] = 'Escolha exatamente uma localização principal.'; } $allocated = array_filter($locations, fn ($r) => isset($r->payload['allocation_percentage'])); if ($allocated && (count($allocated) !== count($locations) || abs(array_sum(array_map(fn ($r) => (float) $r->payload['allocation_percentage'], $allocated)) - 100) > 0.01)) { $issues[] = 'A distribuição deve abranger todas as localizações e somar 100% (tolerância 0,01).'; } $provinces = []; $districts = []; foreach ($locations as $location) { $p = $location->payload; $mozambican = empty($p['country_code']) || $p['country_code'] === 'MZ'; if ($mozambican && (empty($p['province_id']) || empty($p['district_id']))) { $issues[] = 'Uma localização em Moçambique tem província ou distrito pendente.'; } if (! $mozambican && array_filter(array_intersect_key($p, array_flip(['province_id', 'district_id', 'admin_post_id', 'locality_id'])))) { $issues[] = 'Uma localização noutro país conserva uma seleção territorial de Moçambique incompatível; corrigir explicitamente sem converter o histórico.'; } if (empty($p['role'])) { $issues[] = 'Uma localização tem papel pendente.'; } if ($mozambican && ! empty($p['district_id'])) { $districts[$p['district_id']] = true; } if ($mozambican && ! empty($p['province_id'])) { $provinces[$p['province_id']] = true; } if (($p['role'] ?? null) === 'other' && empty($p['role_description'])) { $issues[] = 'Papel outro exige descrição.'; } if (empty($p['source_reference'])) { $issues[] = 'Localização com rastreabilidade por recuperar; aceitação oficial anterior preservada.'; } if (! empty($p['geometry'])) { $issues[] = 'Compatibilidade espacial depende de limite oficial correspondente; geometria não confirma disponibilidade.'; } if (isset($p['area_value']) !== isset($p['area_unit'])) { $issues[] = 'Área exige valor e unidade juntos.'; } } 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 administrativo moçambicano derivado: '.(count($provinces) > 1 ? 'interprovincial' : (count($districts) > 1 ? 'multi_district' : ($districts ? 'district' : 'sem seleção moçambicana'))).'. Sem conversão de contexto em implantação.'; } foreach (['images', 'documents'] as $kind) { foreach ($resources[$kind] as $r) { if ($r->deleted_at) { continue; } if ($r->file->malware_scan_status !== 'clean') { $issues[] = 'Anexo '.$r->file->file_name.': scan '.$r->file->malware_scan_status.'; não é evidência de segurança.'; } $required = $kind === 'images' ? ['title', 'caption', 'alt_text', 'author_source', 'rights_status'] : ['category', 'issuer', 'edition', 'classification']; foreach ($required as $field) { if (empty($r->payload[$field])) { $issues[] = 'Anexo '.$r->file->file_name.': '.$field.' pendente.'; } } if ($kind === 'images') { 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.'; } } } } $issues[] = 'Esta conferência não submete nem publica; workflow final, revisão humana e derivados seguros permanecem separados.'; return array_values(array_unique($issues)); } }