210 lines
13 KiB
PHP
210 lines
13 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Models\Opportunity;
|
||
use App\Services\CountryCatalogue;
|
||
use App\Services\InvestmentGeography;
|
||
use App\Services\OpportunityImageProcessor;
|
||
use App\Services\OpportunityManagement;
|
||
use App\Services\OpportunityResourceInstallation;
|
||
use App\Services\OpportunityResources;
|
||
use App\Services\PortalProfileAccess;
|
||
use App\Services\VerifiedDocumentBinary;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\URL;
|
||
use Inertia\Inertia;
|
||
use Inertia\Response;
|
||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||
|
||
class OpportunityResourceController extends Controller
|
||
{
|
||
public function installation(Request $request, OpportunityResourceInstallation $service): JsonResponse|Response
|
||
{
|
||
$preview = [...$service->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 index(int $id, OpportunityResources $service): 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']])];
|
||
}
|
||
}
|
||
}
|
||
|
||
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): JsonResponse
|
||
{
|
||
$input = $request->all();
|
||
if (is_string($input['payload'] ?? null)) {
|
||
$input['payload'] = json_decode($input['payload'], true);
|
||
}
|
||
$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);
|
||
}
|
||
|
||
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
|
||
{
|
||
$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);
|
||
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.');
|
||
|
||
return app(VerifiedDocumentBinary::class)->respond($record);
|
||
}
|
||
|
||
/** @param array<string,list<\stdClass>> $resources
|
||
* @return list<string>
|
||
*/
|
||
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;
|
||
if (empty($p['province_id']) || empty($p['district_id']) || empty($p['role'])) {
|
||
$issues[] = 'Uma localização tem província, distrito ou papel pendente.';
|
||
}
|
||
if (! empty($p['district_id'])) {
|
||
$districts[$p['district_id']] = true;
|
||
}
|
||
if (! 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 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) {
|
||
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));
|
||
}
|
||
}
|