157 lines
8.8 KiB
PHP
157 lines
8.8 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Models\Opportunity;
|
||
use App\Services\InvestmentGeography;
|
||
use App\Services\OpportunityManagement;
|
||
use App\Services\OpportunityResourceInstallation;
|
||
use App\Services\OpportunityResources;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Crypt;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Inertia\Inertia;
|
||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||
|
||
class OpportunityResourceController extends Controller
|
||
{
|
||
public function installation(Request $request, OpportunityResourceInstallation $service)
|
||
{
|
||
$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)
|
||
{
|
||
$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)
|
||
{
|
||
$opportunity = Opportunity::withTrashed()->findOrFail($id);
|
||
abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.');
|
||
$resources = [];
|
||
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);
|
||
}
|
||
|
||
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)
|
||
{
|
||
$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)
|
||
{
|
||
$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)
|
||
{
|
||
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 && $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.');
|
||
$disk = Storage::disk('local');
|
||
$path = $disk->path($record->storage_key);
|
||
abort_unless($disk->exists($record->storage_key), 409, 'Ficheiro indisponível.');
|
||
$bytes = $record->storage_encryption === 'laravel' ? Crypt::decryptString($disk->get($record->storage_key)) : $disk->get($record->storage_key);
|
||
abort_unless(hash_equals(trim($record->sha256), hash('sha256', $bytes)), 409, 'Integridade do ficheiro divergente.');
|
||
|
||
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
|
||
* @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.';
|
||
}
|
||
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') {
|
||
$issues[] = 'Imagem '.$r->file->file_name.': revisão, derivadas sem EXIF e aprovação de divulgação 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));
|
||
}
|
||
}
|