@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\AiRun;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\Ai\AiGateway;
|
||||
@@ -25,6 +26,7 @@ final class AiController extends Controller
|
||||
'controls' => DB::table('ai_controls')->orderBy('capability')->get(),
|
||||
'sources' => DB::table('ai_sources')->orderByDesc('created_at')->limit(50)->get(['id', 'title', 'language', 'status', 'nature', 'valid_until', 'submitted_by']),
|
||||
'documents' => DB::table('documents')->where('status', 'active')->whereNull('deleted_at')->where('media_type', 'text/plain')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')
|
||||
->whereIn('data_classification', ['public', 'internal'])->limit(100)->get(['id', 'file_name']),
|
||||
'runs' => AiRun::orderByDesc('created_at')->limit(20)->get(['id', 'capability', 'mode', 'review_status', 'review_requested', 'created_at']),
|
||||
'today' => now()->toDateString(),
|
||||
@@ -118,7 +120,8 @@ final class AiController extends Controller
|
||||
DB::transaction(function () use ($request, $file, $text, $id, $key): void {
|
||||
DB::table('documents')->insert(['id' => $id, 'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => 'text/plain', 'storage_key' => $key, 'sha256' => hash('sha256', $text),
|
||||
'status' => 'active', 'data_classification' => 'internal']);
|
||||
'size_bytes' => strlen($text), 'uploaded_by' => $request->user()->id,
|
||||
'status' => 'quarantined', 'data_classification' => 'internal']);
|
||||
$this->audit('documents', $id, 'plain_text_registered', $request->user()->id);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
@@ -126,6 +129,8 @@ final class AiController extends Controller
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($id)->afterCommit();
|
||||
|
||||
return $request->expectsJson() ? response()->json(['document_id' => $id], 201)
|
||||
: back()->with('success', 'Texto guardado no cadastro documental. Registe a fonte e solicite revisão antes de o pesquisar.');
|
||||
}
|
||||
@@ -140,6 +145,7 @@ final class AiController extends Controller
|
||||
if ($data['status'] === 'approved') {
|
||||
abort_if((int) $source->submitted_by === $request->user()->id, 403, 'Outra pessoa deve validar a autorização.');
|
||||
$valid = DB::table('documents')->where('id', $source->document_id)->where('status', 'active')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')
|
||||
->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])
|
||||
->where('version_no', $source->document_version)->where('sha256', $source->document_checksum)->exists();
|
||||
abort_unless($valid && $source->valid_until >= now()->toDateString(), 422, 'Fonte alterada, expirada ou sem autorização.');
|
||||
|
||||
@@ -15,7 +15,7 @@ class ReferenceCatalogController extends Controller
|
||||
|
||||
public function index(Request $request, string $catalog): JsonResponse
|
||||
{
|
||||
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
|
||||
$catalog = $this->validatedCatalog($catalog);
|
||||
|
||||
$rules = [
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
@@ -71,7 +71,7 @@ class ReferenceCatalogController extends Controller
|
||||
};
|
||||
|
||||
$page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString();
|
||||
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, $row))->values();
|
||||
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, (array) $row))->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
@@ -156,32 +156,47 @@ class ReferenceCatalogController extends Controller
|
||||
return $query;
|
||||
}
|
||||
|
||||
/** @param list<string> $columns */
|
||||
/** @param list<literal-string> $columns */
|
||||
private function search(Builder $query, string $search, array $columns): void
|
||||
{
|
||||
$term = '%'.addcslashes($search, '%_\\').'%';
|
||||
$query->where(function (Builder $nested) use ($columns, $term): void {
|
||||
foreach ($columns as $index => $column) {
|
||||
$method = $index === 0 ? 'whereRaw' : 'orWhereRaw';
|
||||
$nested->{$method}($column.' ILIKE ?', [$term]);
|
||||
if ($index === 0) {
|
||||
$nested->whereRaw($column.' ILIKE ?', [$term]);
|
||||
} else {
|
||||
$nested->orWhereRaw($column.' ILIKE ?', [$term]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function present(string $catalog, object $row): array
|
||||
/** @return 'authorities'|'jurisdictions'|'economic-activities' */
|
||||
private function validatedCatalog(string $catalog): string
|
||||
{
|
||||
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
|
||||
|
||||
return $catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param 'authorities'|'jurisdictions'|'economic-activities' $catalog
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function present(string $catalog, array $row): array
|
||||
{
|
||||
return match ($catalog) {
|
||||
'jurisdictions' => ['id' => $row->id, 'value' => $row->id, 'code' => $row->code, 'name' => $row->name,
|
||||
'label' => $row->name, 'locale' => 'pt-MZ', 'parent_id' => $row->parent_id, 'type' => $row->location_type,
|
||||
'source_code' => $row->source_code, 'version_no' => $row->version_no],
|
||||
'economic-activities' => ['id' => $row->id, 'value' => $row->code, 'code' => $row->code, 'name' => $row->name,
|
||||
'label' => $row->name, 'locale' => 'pt-MZ', 'parent_id' => $row->parent_id, 'sector_id' => $row->sector_id,
|
||||
'version_no' => $row->version_no],
|
||||
'authorities' => ['id' => $row->id, 'value' => $row->code, 'code' => $row->code, 'name' => $row->name,
|
||||
'label' => $row->name, 'locale' => 'pt-MZ', 'authority_type' => $row->authority_type,
|
||||
'organization_id' => $row->organization_id, 'jurisdiction_id' => $row->jurisdiction_id,
|
||||
'jurisdiction_name' => $row->jurisdiction_name, 'version_no' => $row->version_no],
|
||||
'jurisdictions' => ['id' => $row['id'], 'value' => $row['id'], 'code' => $row['code'], 'name' => $row['name'],
|
||||
'label' => $row['name'], 'locale' => 'pt-MZ', 'parent_id' => $row['parent_id'], 'type' => $row['location_type'],
|
||||
'source_code' => $row['source_code'], 'version_no' => $row['version_no']],
|
||||
'economic-activities' => ['id' => $row['id'], 'value' => $row['code'], 'code' => $row['code'], 'name' => $row['name'],
|
||||
'label' => $row['name'], 'locale' => 'pt-MZ', 'parent_id' => $row['parent_id'], 'sector_id' => $row['sector_id'],
|
||||
'version_no' => $row['version_no']],
|
||||
'authorities' => ['id' => $row['id'], 'value' => $row['code'], 'code' => $row['code'], 'name' => $row['name'],
|
||||
'label' => $row['name'], 'locale' => 'pt-MZ', 'authority_type' => $row['authority_type'],
|
||||
'organization_id' => $row['organization_id'], 'jurisdiction_id' => $row['jurisdiction_id'],
|
||||
'jurisdiction_name' => $row['jurisdiction_name'], 'version_no' => $row['version_no']],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreSimulationRequest;
|
||||
use App\Http\Requests\UpdateSimulationScenarioRequest;
|
||||
use App\Models\QuestionnaireVersion;
|
||||
use App\Models\Simulation;
|
||||
use App\Models\SimulationJob;
|
||||
use App\Policies\SimulationPolicy;
|
||||
use App\Services\SimulationExecution;
|
||||
@@ -65,6 +66,56 @@ class SimulationController extends Controller
|
||||
return response()->json(['data' => $simulation])->header('ETag', $execution->etag($simulation));
|
||||
}
|
||||
|
||||
public function history(Request $request, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($user !== null, 401);
|
||||
abort_unless($user->hasVerifiedEmail(), 403, 'Verifique o correio electrónico para consultar o histórico.');
|
||||
$perPage = min(50, max(1, $request->integer('per_page', 20)));
|
||||
$page = max(1, $request->integer('page', 1));
|
||||
|
||||
$history = DB::transaction(function () use ($execution, $user, $perPage, $page) {
|
||||
$execution->setContext($user->id, null);
|
||||
|
||||
return Simulation::query()
|
||||
->select([
|
||||
'simulations.id', 'simulations.organization_id', 'visible_organization.name as organization_name',
|
||||
'simulations.status', 'simulations.version_no', 'simulations.created_at', 'simulations.updated_at',
|
||||
])
|
||||
->leftJoin('organizations as visible_organization', 'visible_organization.id', '=', 'simulations.organization_id')
|
||||
->where(function ($scope) use ($user) {
|
||||
$scope->where(fn ($personal) => $personal->whereNull('simulations.organization_id')->where('simulations.owner_user_id', $user->id))
|
||||
->orWhere(fn ($organization) => $organization->whereNotNull('simulations.organization_id')->whereExists(
|
||||
fn ($membership) => $membership->selectRaw('1')->from('memberships')
|
||||
->whereColumn('memberships.organization_id', 'simulations.organization_id')
|
||||
->where('memberships.user_id', $user->id)->whereNull('memberships.deleted_at'),
|
||||
));
|
||||
})
|
||||
->withExists(['reports as has_ready_report' => fn ($query) => $query->where('status', 'ready')])
|
||||
->orderByDesc('simulations.created_at')->orderByDesc('simulations.id')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $history->getCollection()->map(fn (Simulation $simulation): array => [
|
||||
'id' => $simulation->id,
|
||||
'scope' => $simulation->organization_id === null ? 'personal' : 'organization',
|
||||
'organization_name' => $simulation->getAttribute('organization_name'),
|
||||
'status' => $simulation->status,
|
||||
'version_no' => $simulation->version_no,
|
||||
'created_at' => $simulation->created_at?->toISOString(),
|
||||
'updated_at' => $simulation->updated_at?->toISOString(),
|
||||
'has_ready_report' => (bool) $simulation->getAttribute('has_ready_report'),
|
||||
]),
|
||||
'meta' => [
|
||||
'current_page' => $history->currentPage(),
|
||||
'last_page' => $history->lastPage(),
|
||||
'per_page' => $history->perPage(),
|
||||
'total' => $history->total(),
|
||||
],
|
||||
])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function updateScenario(UpdateSimulationScenarioRequest $request, string $id, string $scenarioId, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && Str::isUuid($scenarioId), 404);
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AttachSimulationToDraftRequest;
|
||||
use App\Http\Requests\ClaimSimulationFormDraftRequest;
|
||||
use App\Http\Requests\LinkSimulationDraftBusinessRequest;
|
||||
use App\Http\Requests\StoreSimulationFormDraftRequest;
|
||||
use App\Models\SimulationFormDraft;
|
||||
use App\Policies\SimulationPolicy;
|
||||
use App\Services\SimulationExecution;
|
||||
use App\Services\SimulationFormDrafts;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SimulationFormDraftController extends Controller
|
||||
@@ -16,13 +21,65 @@ class SimulationFormDraftController extends Controller
|
||||
{
|
||||
$key = $request->header('Idempotency-Key');
|
||||
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
|
||||
$result = $drafts->create($request->validated(), $request, $key);
|
||||
$result = $drafts->create($request->snapshots(), $request, $key);
|
||||
$draft = $result['draft'];
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']]], $result['replayed'] ? 200 : 201)
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']], 'expires_at' => $draft->expires_at?->toISOString()], $result['replayed'] ? 200 : 201)
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function index(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless($request->user() !== null, 401);
|
||||
|
||||
return response()->json(['data' => $drafts->ownedDrafts($request->user())])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function claimPreview(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless($request->user() !== null, 401);
|
||||
|
||||
return response()->json(['data' => $drafts->claimPreview($request)])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function claim(ClaimSimulationFormDraftRequest $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$result = $drafts->claim($request, $request->validated('choice'), $request->validated('existing_draft_id'));
|
||||
$draft = $result['draft'];
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft, true), 'choice' => $result['choice'], 'replayed' => $result['replayed'] ?? false, 'expires_at' => $draft->expires_at?->toISOString()])
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function linkableBusinesses(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless($request->user()?->hasVerifiedEmail(), 403);
|
||||
|
||||
return response()->json(['data' => $drafts->linkableBusinesses($request->user())])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function businessLink(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && $request->user()?->hasVerifiedEmail(), 404);
|
||||
|
||||
return response()->json(['data' => $drafts->activeBusinessLink($request->user(), $id)])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function linkBusiness(LinkSimulationDraftBusinessRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$result = $drafts->linkBusiness($request->user(), $id, $request->validated('business_id'));
|
||||
|
||||
return response()->json(['data' => $result], isset($result['replayed']) ? 200 : 201)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function unlinkBusiness(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id) && $request->user()?->hasVerifiedEmail(), 404);
|
||||
|
||||
return response()->json(['data' => $drafts->unlinkBusiness($request->user(), $id)])->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function current(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$draft = $drafts->current($request);
|
||||
@@ -30,7 +87,14 @@ class SimulationFormDraftController extends Controller
|
||||
return response()->json(['data' => null], 200)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
return response()->json(['data' => $drafts->present($draft, $request->user() !== null)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function forgetCurrent(Request $request, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
$drafts->forgetCurrent($request);
|
||||
|
||||
return response()->json([], 204)->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
@@ -38,18 +102,82 @@ class SimulationFormDraftController extends Controller
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->find($id, $request);
|
||||
|
||||
return response()->json(['data' => $drafts->present($draft)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
return response()->json(['data' => $drafts->present($draft, $request->user() !== null)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function update(StoreSimulationFormDraftRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->update($id, $request->validated(), $request);
|
||||
$draft = $drafts->update($id, $request->snapshots(), $request);
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no]])
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no], 'expires_at' => $draft->expires_at?->toISOString()])
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function renew(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$draft = $drafts->renew($id, $request);
|
||||
|
||||
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no], 'expires_at' => $draft->expires_at?->toISOString()])
|
||||
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function attachSimulation(AttachSimulationToDraftRequest $request, string $id, SimulationFormDrafts $drafts, SimulationExecution $execution): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
$validated = $request->validated();
|
||||
$simulation = $execution->find($validated['simulation_id'], $request);
|
||||
$tokenOwned = $simulation->anonymous_token_hash !== null
|
||||
&& hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request));
|
||||
if ($request->user()) {
|
||||
$policy = app(SimulationPolicy::class);
|
||||
$canTransfer = $simulation->organization_id === null
|
||||
? $policy->view($request->user(), $simulation)
|
||||
: $policy->update($request->user(), $simulation);
|
||||
abort_unless($canTransfer || $tokenOwned, 404);
|
||||
} else {
|
||||
abort_unless($tokenOwned, 404);
|
||||
}
|
||||
abort_unless($simulation->status === 'completed' && is_array($simulation->result_snapshot), 409, 'Só é possível reutilizar uma simulação concluída.');
|
||||
$report = $simulation->reports->where('status', 'ready')->sortByDesc('created_at')->first();
|
||||
abort_unless($report !== null && is_array($report->report_snapshot), 409, 'O relatório versionado da simulação ainda não está disponível.');
|
||||
abort_unless(preg_match('/^[a-f0-9]{64}$/D', $report->report_sha256) === 1, 409,
|
||||
'O relatório da simulação não tem um hash verificável.');
|
||||
$canonicalSnapshots = DB::selectOne('select CAST(? AS jsonb)::text as report_canonical, CAST(? AS jsonb)::text as result_canonical', [
|
||||
json_encode($report->report_snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
json_encode($simulation->result_snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
abort_unless(hash_equals($report->report_sha256, hash('sha256', $canonicalSnapshots->report_canonical))
|
||||
&& hash_equals($canonicalSnapshots->report_canonical, $canonicalSnapshots->result_canonical), 409,
|
||||
'O relatório não corresponde ao resultado versionado da simulação.');
|
||||
$inputSnapshot = array_intersect_key($simulation->input_snapshot, array_flip(['base', 'rate', 'reduction']));
|
||||
|
||||
$sourceSnapshot = [
|
||||
'simulation_id' => $simulation->id,
|
||||
'source_version_no' => $simulation->version_no,
|
||||
'source_created_at' => $simulation->created_at?->toISOString(),
|
||||
'source_updated_at' => $simulation->updated_at?->toISOString(),
|
||||
'input_snapshot' => $inputSnapshot,
|
||||
'calculation_snapshot' => $simulation->calculation_snapshot,
|
||||
'result_snapshot' => $simulation->result_snapshot,
|
||||
'result_sha256' => $report->report_sha256,
|
||||
'report_id' => $report->id,
|
||||
'report_version_no' => $report->version_no,
|
||||
'report_sha256' => $report->report_sha256,
|
||||
'report_snapshot' => $report->report_snapshot,
|
||||
'consented_at' => now()->toISOString(),
|
||||
];
|
||||
$attached = $drafts->attachSimulation($id, $sourceSnapshot, $request);
|
||||
$draft = $attached['draft'];
|
||||
|
||||
return response()->json([
|
||||
'data' => $drafts->present($draft, $request->user() !== null),
|
||||
'replayed' => $attached['replayed'],
|
||||
'expires_at' => $draft->expires_at?->toISOString(),
|
||||
])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
|
||||
{
|
||||
abort_unless(Str::isUuid($id), 404);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ResolveSimulationTaxParametersRequest;
|
||||
use App\Services\SimulationTaxParameterResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class SimulationTaxParameterController extends Controller
|
||||
{
|
||||
public function resolve(ResolveSimulationTaxParametersRequest $request, SimulationTaxParameterResolver $resolver): JsonResponse
|
||||
{
|
||||
$input = $request->validated();
|
||||
$data = $resolver->resolvePublishedForPeriod(
|
||||
$input['component'],
|
||||
$input['period_start'],
|
||||
$input['period_end'],
|
||||
$input['context'] ?? [],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'component' => $input['component'],
|
||||
'period' => ['start' => $input['period_start'], 'end' => $input['period_end']],
|
||||
'assumptions' => $input['context'] ?? [],
|
||||
'parameters' => $data,
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DocumentMalwareController extends Controller
|
||||
{
|
||||
public function retry(Request $request, string $id): RedirectResponse
|
||||
{
|
||||
DB::transaction(function () use ($id): void {
|
||||
$document = DB::table('documents')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
|
||||
abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422,
|
||||
'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
|
||||
|
||||
DB::table('documents')->where('id', $id)->update([
|
||||
'status' => 'quarantined', 'malware_scan_status' => 'pending', 'malware_failure_reason' => null, 'updated_at' => now(),
|
||||
]);
|
||||
ScanDocumentForMalware::dispatch($id)->afterCommit();
|
||||
});
|
||||
|
||||
return back()->with('success', 'Nova análise colocada na fila. O ficheiro permanece privado até haver resultado.');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Models\Skill;
|
||||
use App\Models\TrainingCapacity;
|
||||
use App\Models\TrainingProvider;
|
||||
use App\Services\EducationGapCalculator;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -28,17 +29,22 @@ class EducationController extends Controller
|
||||
'q' => ['nullable', 'string', 'max:150'],
|
||||
'status' => ['nullable', Rule::in(['submitted', 'reviewed', 'rejected', 'expired'])],
|
||||
'geo_location_id' => ['nullable', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
'demand_page' => ['nullable', 'integer', 'min:1'],
|
||||
'capacity_page' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
$demands = LabourDemand::query();
|
||||
$capacities = TrainingCapacity::query();
|
||||
$territoryIds = [];
|
||||
foreach ([$demands, $capacities] as $query) {
|
||||
if ($term = trim($filters['q'] ?? '')) {
|
||||
$query->whereLike('qualification', '%'.addcslashes($term, '%_\\').'%');
|
||||
}
|
||||
if ($territory = $filters['geo_location_id'] ?? null) {
|
||||
$query->where('geo_location_id', $territory);
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
$territory = $filters['geo_location_id'] ?? ($path !== [] ? $path[array_key_last($path)]->id : null);
|
||||
if ($territory) {
|
||||
$territoryIds = app(InvestmentGeography::class)->descendants($territory, includeNonPublic: true);
|
||||
$query->whereIn('geo_location_id', $territoryIds);
|
||||
}
|
||||
if (($filters['status'] ?? null) === 'expired') {
|
||||
$query->where('valid_until', '<', now()->toDateString());
|
||||
@@ -47,6 +53,17 @@ class EducationController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$territorialCapacities = clone $capacities;
|
||||
$providers = TrainingProvider::where('status', '!=', 'inactive');
|
||||
$courses = Course::where('status', '!=', 'retired')
|
||||
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'));
|
||||
if ($territoryIds !== []) {
|
||||
// Provider and course location is evidenced by their submitted training capacity.
|
||||
// Do not infer locations from provider names, addresses, or unrelated records.
|
||||
$courses->whereIn('id', $territorialCapacities->select('course_id'));
|
||||
$providers->whereIn('id', (clone $courses)->select('provider_id'));
|
||||
}
|
||||
|
||||
return Inertia::render('education/index', [
|
||||
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString(),
|
||||
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString(),
|
||||
@@ -59,8 +76,8 @@ class EducationController extends Controller
|
||||
],
|
||||
'skills' => Skill::orderBy('name')->get(['id', 'code', 'name']),
|
||||
'occupations' => Occupation::orderBy('name')->get(['id', 'code', 'name']),
|
||||
'providers' => TrainingProvider::where('status', '!=', 'inactive')->orderBy('name')->get(['id', 'name']),
|
||||
'courses' => Course::where('status', '!=', 'retired')->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'))->orderBy('title')->get(['id', 'title', 'provider_id']),
|
||||
'providers' => $providers->orderBy('name')->get(['id', 'name']),
|
||||
'courses' => $courses->orderBy('title')->get(['id', 'title', 'provider_id']),
|
||||
'projects' => Project::orderBy('name')->get(['id', 'code', 'name']),
|
||||
'territories' => DB::table('geo_locations')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])
|
||||
@@ -98,7 +115,8 @@ class EducationController extends Controller
|
||||
$data = $request->validate([
|
||||
'occupation_id' => ['required', 'uuid', Rule::exists('occupations', 'id')->whereNull('deleted_at')],
|
||||
'skill_id' => ['required', 'uuid', Rule::exists('skills', 'id')->whereNull('deleted_at')],
|
||||
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
|
||||
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])],
|
||||
$demand ? 'project_id' : 'course_id' => ['required', 'uuid', $demand
|
||||
? Rule::exists('projects', 'id')->whereNull('deleted_at')
|
||||
: Rule::exists('courses', 'id')->whereNull('deleted_at')->whereNot('status', 'retired')
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use LogicException;
|
||||
|
||||
class EducationInvestmentController extends Controller
|
||||
{
|
||||
@@ -44,9 +45,13 @@ class EducationInvestmentController extends Controller
|
||||
return;
|
||||
}
|
||||
$territory = app(InvestmentGeography::class)->selection($data, true);
|
||||
$lastTerritoryKey = array_key_last($territory);
|
||||
if ($lastTerritoryKey === null) {
|
||||
throw new LogicException('A proposta educativa exige uma localização territorial.');
|
||||
}
|
||||
$province = $territory[0];
|
||||
$district = $territory[1] ?? null;
|
||||
$primaryTerritory = $territory[array_key_last($territory)];
|
||||
$primaryTerritory = $territory[$lastTerritoryKey];
|
||||
$reference = 'EDU-'.Str::uuid();
|
||||
$opportunity = new Opportunity;
|
||||
PortalAudit::save($opportunity, [
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Services\ExistingBusinessDrafts;
|
||||
use App\Services\ExistingBusinessIntake;
|
||||
use App\Services\ExistingBusinessObjects;
|
||||
use App\Services\ExistingBusinessVerification;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\Mne\BusinessRepository;
|
||||
use App\Services\Mne\CompatibilityShadow;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -100,6 +101,7 @@ class ExistingBusinessController extends Controller
|
||||
'id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id', 'public_title', 'public_slug',
|
||||
'activity_code', 'stage', 'objective', 'currency', 'amount_min', 'amount_max', 'available_percentage',
|
||||
'funds_use', 'submitted_at', 'published_at', 'expires_at', 'next_action', 'next_action_due_at', 'assigned_to',
|
||||
]), 'payload' => $business->payload];
|
||||
]), 'payload' => $business->payload,
|
||||
'territory_paths' => app(InvestmentGeography::class)->displayPaths($business->payload)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class InvestmentDiscoveryController extends Controller
|
||||
$filters['education'] = true;
|
||||
}
|
||||
|
||||
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(),
|
||||
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
|
||||
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
|
||||
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
|
||||
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
|
||||
@@ -41,11 +41,7 @@ class InvestmentDiscoveryController extends Controller
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($path !== []) {
|
||||
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
||||
$query->where(function ($query) use ($ids) {
|
||||
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $foreign => $table) {
|
||||
$query->orWhereIn($foreign, DB::table($table)->whereNull('deleted_at')->whereIn('primary_territory_id', $ids)->select('id'));
|
||||
}
|
||||
});
|
||||
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $ids);
|
||||
}
|
||||
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
|
||||
return $query->whereRaw('false')->get();
|
||||
@@ -92,7 +88,11 @@ class InvestmentDiscoveryController extends Controller
|
||||
abort_unless($origin !== null, 404);
|
||||
}
|
||||
|
||||
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin]);
|
||||
$returnFilters = $request->query();
|
||||
unset($returnFilters['zone_id']);
|
||||
|
||||
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
|
||||
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
|
||||
}
|
||||
|
||||
public function detailApi(string $slug, InvestmentCatalogue $catalogue): JsonResponse
|
||||
|
||||
@@ -43,6 +43,8 @@ class InvestmentLegalSourceController extends Controller
|
||||
$source = InvestmentLegalSource::whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
abort_if((int) $source->version_no !== (int) $data['version_no'], 409, 'Versão alterada.');
|
||||
if ($action === 'validate') {
|
||||
abort_if(str_contains(mb_strtolower($source->article), 'pending') || str_contains(mb_strtolower($source->article), 'pendente')
|
||||
|| str_contains(mb_strtolower($source->article), 'a rever'), 422, 'Indique o artigo e confirme o texto oficial antes da validação.');
|
||||
abort_if((int) $source->owner_id === $request->user()->id, 403, 'Outra pessoa deve validar a fonte.');
|
||||
abort_unless($source->validation_status === 'pending', 422, 'Crie nova referência para substituir fonte revogada.');
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Project;
|
||||
use App\Models\InvestmentSubmission;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\PortalAudit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -19,7 +23,7 @@ class InvestmentPortfolioController extends Controller
|
||||
{
|
||||
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
|
||||
foreach ([[$first, $second], [$second, $first]] as [$a, $b]) {
|
||||
if ($a->$left && $b->$right && DB::table($table)->where($left, $a->$left)->where($right, $b->$right)->whereNull('deleted_at')->exists()) {
|
||||
if ($a->$left && $b->$right && self::currentRelation(DB::table($table)->where($left, $a->$left)->where($right, $b->$right))->exists()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +32,13 @@ class InvestmentPortfolioController extends Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function currentRelation(QueryBuilder $query): QueryBuilder
|
||||
{
|
||||
return $query->whereNull('deleted_at')->where('validation_status', 'validated')
|
||||
->whereDate('valid_from', '<=', today()->toDateString())
|
||||
->where(fn ($q) => $q->whereNull('valid_until')->orWhereDate('valid_until', '>=', today()->toDateString()));
|
||||
}
|
||||
|
||||
/** @return Builder<InvestmentDossier> */
|
||||
public static function visible(): Builder
|
||||
{
|
||||
@@ -42,7 +53,8 @@ class InvestmentPortfolioController extends Controller
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$management = $request->routeIs('management.portfolio*');
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project']]);
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'kind' => ['nullable', 'in:corridor,cluster,project'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
$query = $management ? InvestmentDossier::query() : self::visible();
|
||||
if (! empty($filters['kind'])) {
|
||||
$query->whereNotNull($filters['kind'].'_id');
|
||||
@@ -50,9 +62,14 @@ class InvestmentPortfolioController extends Controller
|
||||
if (! empty($filters['q'])) {
|
||||
$query->where('title', 'ilike', '%'.$filters['q'].'%');
|
||||
}
|
||||
$territorySelection = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($territorySelection !== []) {
|
||||
$territoryIds = app(InvestmentGeography::class)->descendants($territorySelection[array_key_last($territorySelection)]->id);
|
||||
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $territoryIds);
|
||||
}
|
||||
|
||||
return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
|
||||
'management' => $management, 'filters' => $filters]);
|
||||
'management' => $management, 'filters' => $filters, 'territorySelection' => $territorySelection]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id): Response
|
||||
@@ -63,8 +80,8 @@ class InvestmentPortfolioController extends Controller
|
||||
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
|
||||
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
|
||||
if ($record->$origin) {
|
||||
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], DB::table($table)
|
||||
->where($origin, $record->$origin)->whereNull('deleted_at')->pluck($destination)->all());
|
||||
$relatedIds[$destination] = array_merge($relatedIds[$destination] ?? [], self::currentRelation(DB::table($table)
|
||||
->where($origin, $record->$origin))->pluck($destination)->all());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +115,16 @@ class InvestmentPortfolioController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$anchorProject = $record->project_id
|
||||
? Project::whereKey($record->project_id)->whereNull('deleted_at')->where('status', '!=', 'draft')->first(['id', 'code', 'name', 'status', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])
|
||||
: null;
|
||||
$existingSubmissions = ! $management && $request->user()
|
||||
? InvestmentSubmission::where('user_id', $request->user()->id)->where('dossier_id', $record->id)->where('status', 'draft')
|
||||
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
|
||||
: collect();
|
||||
|
||||
return Inertia::render('portfolio/show', ['record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
||||
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
|
||||
'origin' => $origin ? ['id' => $origin->id, 'title' => $origin->title] : null, 'opportunities' => $opportunities, 'chains' => $chains]);
|
||||
}
|
||||
|
||||
@@ -114,7 +140,7 @@ class InvestmentPortfolioController extends Controller
|
||||
'publication_status' => 'draft', 'published_at' => null, 'approval_evidence' => null, 'approval_basis' => 'manager'], $request->user()->id);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Rascunho guardado. Pode aprovar e publicar a ficha.');
|
||||
return back()->with('success', 'Rascunho guardado. A publicacao aguarda um revisor diferente do editor.');
|
||||
}
|
||||
|
||||
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
|
||||
@@ -123,6 +149,8 @@ class InvestmentPortfolioController extends Controller
|
||||
DB::transaction(function () use ($request, $dossier, $data) {
|
||||
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
|
||||
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de publicar.');
|
||||
abort_unless($dossier->editor_id !== null, 422, 'A ficha deve ser assumida e revista antes da publicacao.');
|
||||
abort_if((int) $dossier->editor_id === (int) $request->user()->id, 403, 'A publicacao exige um revisor diferente do editor.');
|
||||
PortalAudit::save($dossier, [...$data, 'version_no' => $dossier->version_no + 1, 'approval_basis' => 'manager', 'reviewer_id' => $request->user()->id, 'publication_status' => 'published', 'published_at' => now()], $request->user()->id);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ClusterProject;
|
||||
use App\Models\DevelopmentCorridor;
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Models\InvestmentGap;
|
||||
use App\Models\Project;
|
||||
@@ -21,7 +22,7 @@ class InvestmentStructureController extends Controller
|
||||
public function index(Request $request, string $structure): Response
|
||||
{
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]);
|
||||
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
|
||||
$model = $this->model($structure);
|
||||
$query = $model::query();
|
||||
if ($term = $filters['q'] ?? null) {
|
||||
$query->where(function ($q) use ($term): void {
|
||||
@@ -33,9 +34,9 @@ class InvestmentStructureController extends Controller
|
||||
'records' => $query->orderBy('name')->orderBy('id')->paginate(20)->withQueryString()]);
|
||||
}
|
||||
|
||||
public function edit(string $structure, ?string $id = null): Response
|
||||
public function edit(Request $request, string $structure, ?string $id = null): Response
|
||||
{
|
||||
$model = $structure === 'projects' ? Project::class : EconomicCluster::class;
|
||||
$model = $this->model($structure);
|
||||
$record = $id ? $model::findOrFail($id) : null;
|
||||
|
||||
return Inertia::render('structures/editor', ['structure' => $structure, 'record' => $record,
|
||||
@@ -49,30 +50,36 @@ class InvestmentStructureController extends Controller
|
||||
->whereNull('origins.deleted_at')->whereNull('destinations.deleted_at')->whereNull('value_chain_links.deleted_at')
|
||||
->orderBy('origins.code')->orderBy('destinations.code')
|
||||
->get(['value_chain_links.id', 'origins.name as origin', 'destinations.name as destination', 'link_type']) : [],
|
||||
'viewerId' => $request->user()->id,
|
||||
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
|
||||
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
|
||||
->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at')
|
||||
->orderBy('projects.name')->get(['cluster_projects.id', 'project_id', 'role', 'projects.name']) : [],
|
||||
->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name']) : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request, string $structure, ?string $id = null): RedirectResponse
|
||||
{
|
||||
$isProject = $structure === 'projects';
|
||||
$model = $isProject ? Project::class : EconomicCluster::class;
|
||||
$isCluster = $structure === 'clusters';
|
||||
$isCorridor = $structure === 'corridors';
|
||||
$model = $this->model($structure);
|
||||
$record = $id ? $model::findOrFail($id) : new $model;
|
||||
$rules = [
|
||||
'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())],
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ['planned', 'active', 'inactive'])],
|
||||
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))],
|
||||
];
|
||||
if ($isProject || $isCorridor) {
|
||||
$rules['description'] = ['nullable', 'string', 'max:10000'];
|
||||
}
|
||||
if ($isProject) {
|
||||
$rules += ['description' => ['nullable', 'string', 'max:10000'],
|
||||
$rules += [
|
||||
'budget_amount' => ['nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/'],
|
||||
'budget_currency' => ['nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/'],
|
||||
'start_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d', ...($request->filled('start_date') ? ['after_or_equal:start_date'] : [])]];
|
||||
} else {
|
||||
} elseif ($isCluster) {
|
||||
$rules['sector'] = ['required', 'string', 'max:200'];
|
||||
}
|
||||
if (! $id) {
|
||||
@@ -92,14 +99,52 @@ class InvestmentStructureController extends Controller
|
||||
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
|
||||
}
|
||||
|
||||
/** @return class-string<Project|EconomicCluster|DevelopmentCorridor> */
|
||||
private function model(string $structure): string
|
||||
{
|
||||
return match ($structure) {
|
||||
'projects' => Project::class,
|
||||
'clusters' => EconomicCluster::class,
|
||||
'corridors' => DevelopmentCorridor::class,
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
public function linkProject(Request $request, EconomicCluster $cluster): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')],
|
||||
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]]);
|
||||
'version_no' => ['required', 'integer', 'min:0'],
|
||||
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])],
|
||||
'association_type' => ['required', Rule::in(['official', 'influence', 'proximity', 'potential', 'historical'])],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
|
||||
'valid_from' => ['required', 'date_format:Y-m-d'],
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from']]);
|
||||
DB::transaction(function () use ($cluster, $data, $request) {
|
||||
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
|
||||
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->first() ?? new ClusterProject;
|
||||
PortalAudit::save($link, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
|
||||
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
if ($link) {
|
||||
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
|
||||
$changed = false;
|
||||
foreach (['role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until'] as $field) {
|
||||
$current = $link->getAttribute($field);
|
||||
$current = $current instanceof \DateTimeInterface ? $current->format('Y-m-d') : $current;
|
||||
if (($current ?: null) !== (($data[$field] ?? null) ?: null)) {
|
||||
$changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $changed) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
abort_if((int) $data['version_no'] !== 0, 409, 'A associacao foi alterada. Actualize a pagina.');
|
||||
$link = new ClusterProject;
|
||||
}
|
||||
unset($data['version_no']);
|
||||
$nextVersion = $link->exists ? ((int) $link->version_no + 1) : 1;
|
||||
PortalAudit::save($link, [...$data, 'cluster_id' => $cluster->id, 'validation_status' => 'pending',
|
||||
'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => $nextVersion], $request->user()->id);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Associação ao projecto guardada.');
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class InvestmentStructureRelationController extends Controller
|
||||
{
|
||||
private const TABLES = [
|
||||
'cluster-project' => ['cluster_projects', 'cluster_id', 'project_id', 'economic_clusters', 'projects'],
|
||||
'corridor-project' => ['corridor_projects', 'corridor_id', 'project_id', 'development_corridors', 'projects'],
|
||||
'corridor-cluster' => ['corridor_clusters', 'corridor_id', 'cluster_id', 'development_corridors', 'economic_clusters'],
|
||||
];
|
||||
|
||||
public function store(Request $request, string $kind): RedirectResponse
|
||||
{
|
||||
[$table, $left, $right, $leftTable, $rightTable] = $this->definition($kind);
|
||||
$data = $request->validate([
|
||||
$left => ['required', 'uuid', Rule::exists($leftTable, 'id')->whereNull('deleted_at')],
|
||||
$right => ['required', 'uuid', Rule::exists($rightTable, 'id')->whereNull('deleted_at')],
|
||||
'association_type' => ['required', Rule::in(['official', 'influence', 'proximity', 'potential', 'historical'])],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
|
||||
'valid_from' => ['required', 'date_format:Y-m-d'],
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
|
||||
...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []),
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request): void {
|
||||
DB::table($leftTable)->where('id', $data[$left])->lockForUpdate()->firstOrFail();
|
||||
$existing = DB::table($table)->where($left, $data[$left])->where($right, $data[$right])->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
abort_if($existing !== null, 409, 'Associacao ja registada.');
|
||||
$values = [...$data, 'association_type' => $data['association_type'], 'validation_status' => 'pending',
|
||||
'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => 1,
|
||||
'deleted_at' => null, 'updated_at' => now()];
|
||||
$id = (string) Str::uuid();
|
||||
DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']);
|
||||
$this->audit($request, $table, $id, 'submitted', 1);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Associacao submetida para revisao independente.');
|
||||
}
|
||||
|
||||
public function review(Request $request, string $kind, string $id, string $action): RedirectResponse
|
||||
{
|
||||
abort_unless(in_array($action, ['validate', 'revoke'], true), 404);
|
||||
[$table] = $this->definition($kind);
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
DB::transaction(function () use ($request, $table, $id, $action, $data): void {
|
||||
$relation = DB::table($table)->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($relation !== null, 404);
|
||||
abort_if((int) $relation->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
|
||||
if ($action === 'validate') {
|
||||
abort_if((int) $relation->editor_id === (int) $request->user()->id, 403, 'A associacao exige revisao independente.');
|
||||
abort_unless($relation->validation_status === 'pending' && $relation->source_reference && $relation->evidence_reference && $relation->valid_from, 422, 'Fonte, evidencia ou vigencia em falta.');
|
||||
}
|
||||
$nextVersion = (int) $relation->version_no + 1;
|
||||
DB::table($table)->where('id', $id)->update(['validation_status' => $action === 'validate' ? 'validated' : 'revoked',
|
||||
'validator_id' => $request->user()->id, 'version_no' => $nextVersion, 'updated_at' => now()]);
|
||||
$this->audit($request, $table, $id, $action, $nextVersion);
|
||||
});
|
||||
|
||||
return back()->with('success', $action === 'validate' ? 'Associacao validada.' : 'Associacao revogada.');
|
||||
}
|
||||
|
||||
/** @return array{string,string,string,string,string} */
|
||||
private function definition(string $kind): array
|
||||
{
|
||||
abort_unless(isset(self::TABLES[$kind]), 404);
|
||||
|
||||
return self::TABLES[$kind];
|
||||
}
|
||||
|
||||
private function audit(Request $request, string $table, string $id, string $action, int $version): void
|
||||
{
|
||||
DB::table('audit_events')->insert(['source_system' => 'investment-structure-relations', 'entity_type' => $table,
|
||||
'entity_id' => $id, 'actor_user_id' => $request->user()->id, 'action' => $action,
|
||||
'after_data' => json_encode(['version_no' => $version, 'validation_status' => $action === 'validate' ? 'validated' : null], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\InvestmentSpecificNeed;
|
||||
use App\Models\InvestmentSubmission;
|
||||
@@ -293,7 +294,8 @@ class InvestmentSubmissionController extends Controller
|
||||
abort_unless($key !== false, 500);
|
||||
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'investor-submission', 'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => hash_file('sha256', Storage::disk('local')->path($key)),
|
||||
'status' => 'active', 'data_classification' => 'confidential']);
|
||||
'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
|
||||
'status' => 'quarantined', 'data_classification' => 'confidential']);
|
||||
DB::table('investment_submission_attachments')->insert(['id' => (string) Str::uuid(), 'submission_id' => $id, 'document_id' => $documentId, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$row->increment('version');
|
||||
$this->event($row, $request, 'attachment_added');
|
||||
@@ -307,6 +309,8 @@ class InvestmentSubmissionController extends Controller
|
||||
throw $e;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
|
||||
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Anexo privado guardado.');
|
||||
}
|
||||
|
||||
@@ -314,13 +318,16 @@ class InvestmentSubmissionController extends Controller
|
||||
{
|
||||
$this->owned($request, $id);
|
||||
$doc = DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')->first(['d.file_name', 'd.storage_key', 'd.sha256']);
|
||||
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.malware_scanned_sha256']);
|
||||
abort_unless($doc !== null, 404);
|
||||
$root = realpath(Storage::disk('local')->path('investment-submissions/'.$id));
|
||||
$path = realpath(Storage::disk('local')->path($doc->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$hash = hash_file('sha256', $path);
|
||||
abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash), 404);
|
||||
abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash)
|
||||
&& hash_equals($doc->sha256, (string) $doc->malware_scanned_sha256), 404);
|
||||
|
||||
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InvestmentZone;
|
||||
use App\Services\InvestmentZoneAccess;
|
||||
use App\Services\InvestmentZones;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class InvestmentZoneAttachmentController extends Controller
|
||||
{
|
||||
private const ALLOWED_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
public function store(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$zone = InvestmentZone::whereKey($id)->firstOrFail();
|
||||
$data = $request->validate([
|
||||
'file' => ['required', 'file', 'mimetypes:application/pdf,image/jpeg,image/png,image/webp', 'max:20480'],
|
||||
'kind' => ['required', 'in:legal_instrument,map,photo,study,other'],
|
||||
'caption' => ['required', 'string', 'max:255'],
|
||||
'source_reference' => ['required', 'string', 'min:5', 'max:2000'],
|
||||
'source_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'rights_status' => ['required', 'in:pending,confirmed,restricted'],
|
||||
'rights_basis' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
$file = $request->file('file');
|
||||
abort_unless(in_array($file->getMimeType(), self::ALLOWED_MIME_TYPES, true), 422, 'Formato de ficheiro não permitido.');
|
||||
$this->validateFileContent($file->getRealPath(), $file->getMimeType());
|
||||
abort_if(DB::table('investment_zone_attachments')->where('zone_id', $id)->count() >= 20, 422, 'Máximo de vinte anexos por zona.');
|
||||
|
||||
$attachmentId = (string) Str::uuid();
|
||||
$documentId = (string) Str::uuid();
|
||||
$key = null;
|
||||
try {
|
||||
$key = $file->storeAs('investment-zones/'.$id, $documentId, 'local');
|
||||
abort_unless($key !== false, 500, 'Não foi possível guardar o ficheiro.');
|
||||
$hash = hash_file('sha256', Storage::disk('local')->path($key));
|
||||
abort_unless(is_string($hash), 500, 'Não foi possível verificar o ficheiro guardado.');
|
||||
|
||||
$attachment = DB::transaction(function () use ($request, $zone, $data, $file, $attachmentId, $documentId, $key, $hash): object {
|
||||
DB::table('documents')->insert([
|
||||
'id' => $documentId, 'source_system' => 'investment-zones', 'version_no' => 1,
|
||||
'data_classification' => 'confidential', 'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => $hash,
|
||||
// Follow the portal's existing file policy: no antivirus engine is configured, so uploads stay quarantined.
|
||||
'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
DB::table('investment_zone_attachments')->insert([
|
||||
'id' => $attachmentId, 'zone_id' => $zone->id, 'document_id' => $documentId,
|
||||
'kind' => $data['kind'], 'caption' => $data['caption'], 'source_reference' => $data['source_reference'],
|
||||
'source_date' => $data['source_date'] ?? null, 'rights_status' => $data['rights_status'],
|
||||
'rights_basis' => $data['rights_basis'] ?? null, 'personal_data_status' => 'unknown',
|
||||
'review_status' => 'pending', 'uploaded_by' => $request->user()->id, 'version_no' => 1,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$this->event($attachmentId, $request->user()->id, 'uploaded', 1, ['kind' => $data['kind'], 'sha256' => $hash]);
|
||||
|
||||
return DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $attachmentId)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
if (is_string($key)) {
|
||||
Storage::disk('local')->delete($key);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
|
||||
return response()->json($attachment, 201);
|
||||
}
|
||||
|
||||
public function review(Request $request, string $id, string $action): JsonResponse
|
||||
{
|
||||
abort_unless(in_array($action, ['review', 'select-public', 'withdraw-public'], true), 404);
|
||||
$rules = ['version_no' => ['required', 'integer', 'min:1']];
|
||||
if ($action === 'review') {
|
||||
$rules += ['decision' => ['required', 'in:reviewed,rejected'],
|
||||
'personal_data_status' => ['required', 'in:none,present,unknown'],
|
||||
'rights_status' => ['required', 'in:pending,confirmed,restricted'],
|
||||
'rights_basis' => ['nullable', 'string', 'max:2000']];
|
||||
}
|
||||
$data = $request->validate($rules);
|
||||
if ($action === 'select-public' || $action === 'withdraw-public') {
|
||||
InvestmentZoneAccess::requireRole($request->user(), 'publishers');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($request, $id, $action, $data): JsonResponse {
|
||||
$attachment = DB::table('investment_zone_attachments')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($attachment, 404);
|
||||
abort_if((int) $attachment->version_no !== (int) $data['version_no'], 409, 'O anexo foi alterado. Recarregue antes de continuar.');
|
||||
$version = (int) $attachment->version_no + 1;
|
||||
$changes = ['version_no' => $version, 'updated_at' => now()];
|
||||
$metadata = [];
|
||||
|
||||
if ($action === 'review') {
|
||||
abort_if($data['rights_status'] === 'confirmed' && trim((string) ($data['rights_basis'] ?? $attachment->rights_basis)) === '', 422, 'Registe o fundamento dos direitos antes de os confirmar.');
|
||||
$changes += [
|
||||
'review_status' => $data['decision'], 'reviewed_by' => $request->user()->id, 'reviewed_at' => now(),
|
||||
'independent_review' => (int) $attachment->uploaded_by !== (int) $request->user()->id,
|
||||
'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status'],
|
||||
'rights_basis' => $data['rights_basis'] ?? $attachment->rights_basis,
|
||||
'publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null,
|
||||
];
|
||||
$metadata = ['decision' => $data['decision'], 'independent_review' => $changes['independent_review'], 'personal_data_status' => $data['personal_data_status'], 'rights_status' => $data['rights_status']];
|
||||
} elseif ($action === 'select-public') {
|
||||
abort_unless($attachment->review_status === 'reviewed' && $attachment->rights_status === 'confirmed'
|
||||
&& trim((string) $attachment->rights_basis) !== '' && $attachment->personal_data_status === 'none', 422, 'O anexo precisa de revisão registada, fundamento dos direitos, confirmação de divulgação e verificação de ausência de dados pessoais.');
|
||||
abort_unless(DB::table('documents')->where('id', $attachment->document_id)->where('status', 'active')
|
||||
->where('malware_scan_status', 'clean')->whereColumn('malware_scanned_sha256', 'sha256')->exists(), 422,
|
||||
'A análise antimalware tem de terminar sem ameaça antes da divulgação.');
|
||||
abort_unless(app(InvestmentZones::class)->visible()->whereKey($attachment->zone_id)->exists(), 422, 'A zona deve estar publicada antes da divulgação do anexo.');
|
||||
$changes += ['publicly_selected' => true, 'publicly_selected_at' => now(), 'publicly_selected_by' => $request->user()->id];
|
||||
} else {
|
||||
$changes += ['publicly_selected' => false, 'publicly_selected_at' => null, 'publicly_selected_by' => null];
|
||||
}
|
||||
|
||||
DB::table('investment_zone_attachments')->where('id', $id)->update($changes);
|
||||
$this->event($id, $request->user()->id, $action, $version, $metadata);
|
||||
|
||||
return response()->json(DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $id)->first(['a.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.malware_scan_status', 'd.malware_failure_reason']));
|
||||
});
|
||||
}
|
||||
|
||||
public function downloadPrivate(string $zoneId, string $id): BinaryFileResponse
|
||||
{
|
||||
$record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('d.status', 'active')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
|
||||
abort_unless($record, 404);
|
||||
|
||||
return $this->verifiedDownload($zoneId, $record);
|
||||
}
|
||||
|
||||
public function downloadPublic(string $zoneId, string $id): BinaryFileResponse
|
||||
{
|
||||
abort_unless(app(InvestmentZones::class)->visible()->whereKey($zoneId)->exists(), 404);
|
||||
$record = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.id', $id)->where('a.zone_id', $zoneId)->where('a.publicly_selected', true)
|
||||
->where('a.review_status', 'reviewed')->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none')
|
||||
->where('d.status', 'active')->whereNull('d.deleted_at')->where('d.malware_scan_status', 'clean')
|
||||
->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.media_type', 'd.malware_scanned_sha256']);
|
||||
abort_unless($record, 404);
|
||||
|
||||
$extension = match ($record->media_type) {
|
||||
'application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', default => 'bin'
|
||||
};
|
||||
|
||||
return $this->verifiedDownload($zoneId, $record, str_starts_with($record->media_type, 'image/'), 'zone-attachment-'.$id.'.'.$extension);
|
||||
}
|
||||
|
||||
private function verifiedDownload(string $zoneId, object $document, bool $inline = false, ?string $downloadName = null): BinaryFileResponse
|
||||
{
|
||||
$root = realpath(Storage::disk('local')->path('investment-zones/'.$zoneId));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$hash = hash_file('sha256', $path);
|
||||
abort_unless(is_string($hash) && hash_equals(trim($document->sha256), $hash)
|
||||
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
|
||||
|
||||
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
|
||||
$safeName = $downloadName ?? basename($document->file_name);
|
||||
|
||||
return $inline
|
||||
? response()->file($path, $headers + ['Content-Type' => $document->media_type, 'Content-Disposition' => 'inline; filename="'.$safeName.'"'])
|
||||
: response()->download($path, $safeName, $headers);
|
||||
}
|
||||
|
||||
private function validateFileContent(string $path, string $mime): void
|
||||
{
|
||||
if ($mime === 'application/pdf') {
|
||||
$handle = fopen($path, 'rb');
|
||||
$signature = $handle ? fread($handle, 5) : false;
|
||||
if (is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
abort_unless($signature === '%PDF-', 422, 'O conteúdo não corresponde a um PDF válido.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$image = @getimagesize($path);
|
||||
abort_unless($image && $image[0] > 0 && $image[1] > 0 && $image[0] * $image[1] <= 40000000, 422, 'A imagem é inválida ou excede 40 megapíxeis.');
|
||||
$expected = ['image/jpeg' => IMAGETYPE_JPEG, 'image/png' => IMAGETYPE_PNG, 'image/webp' => IMAGETYPE_WEBP][$mime] ?? null;
|
||||
abort_unless($expected !== null && $image[2] === $expected, 422, 'O conteúdo da imagem não corresponde ao formato declarado.');
|
||||
}
|
||||
|
||||
private function event(string $attachmentId, int $actor, string $action, int $version, array $metadata = []): void
|
||||
{
|
||||
DB::table('investment_zone_attachment_events')->insert([
|
||||
'id' => (string) Str::uuid(), 'attachment_id' => $attachmentId, 'actor_id' => $actor,
|
||||
'action' => $action, 'version_no' => $version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ use App\Services\InvestmentZones;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -22,7 +24,14 @@ class InvestmentZoneController extends Controller
|
||||
{
|
||||
$management = $request->is('management/*', 'api/v2/investment/admin/*');
|
||||
$zone = ($management ? InvestmentZone::query() : $zones->visible())->findOrFail($id);
|
||||
$publicFields = ['id', 'code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'validation_status', 'updated_at'];
|
||||
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
|
||||
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
|
||||
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
|
||||
$zone->setAttribute('territory_path', $territoryPath);
|
||||
$publicFields = ['id', 'code', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'historical_territory', 'data_as_of', 'sources_consulted_at', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'territory_path', 'validation_status', 'updated_at'];
|
||||
$locations = DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
|
||||
->where('zl.zone_id', $id)->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
|
||||
->whereNull('g.deleted_at')->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'g.parent_id', 'zl.scope_type', 'zl.source_reference', 'zl.validation_status']);
|
||||
$source = DB::table('investment_legal_sources')->where('id', $zone->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'competent_entity', 'valid_from', 'valid_until', 'version_no']);
|
||||
$operator = DB::table('organizations')->where('id', $zone->operator_id)->whereNull('deleted_at')
|
||||
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))->value('name');
|
||||
@@ -33,7 +42,26 @@ class InvestmentZoneController extends Controller
|
||||
$relations = $zones->links(true)->where('zl.zone_id', $id)->whereNotNull('zl.'.$foreign);
|
||||
$related = $related->merge(InvestmentPortfolioController::visible()->whereIn($foreign, $relations->select('zl.'.$foreign))->get());
|
||||
}
|
||||
$attachmentColumns = $management
|
||||
? ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'a.rights_status', 'a.rights_basis', 'a.personal_data_status',
|
||||
'a.review_status', 'a.reviewed_by', 'a.reviewed_at', 'a.independent_review', 'a.publicly_selected', 'a.publicly_selected_by',
|
||||
'a.version_no', 'a.uploaded_by', 'a.created_at', 'd.id as document_id', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.sha256', 'd.status as file_status',
|
||||
'd.malware_scan_status', 'd.malware_failure_reason', 'd.malware_scanned_at']
|
||||
: ['a.id', 'a.kind', 'a.caption', 'a.source_reference', 'a.source_date', 'd.media_type', 'd.size_bytes'];
|
||||
$attachments = DB::table('investment_zone_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
|
||||
->where('a.zone_id', $id)->when(! $management, fn ($query) => $query->where('d.status', 'active'))
|
||||
->when(! $management, fn ($query) => $query->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256'))
|
||||
->whereNull('d.deleted_at')
|
||||
->when(! $management, fn ($query) => $query->where('a.publicly_selected', true)->where('a.review_status', 'reviewed')
|
||||
->where('a.rights_status', 'confirmed')->where('a.personal_data_status', 'none'))
|
||||
->orderBy('a.created_at')->get($attachmentColumns);
|
||||
$attachments->each(function ($attachment) use ($id, $management): void {
|
||||
$attachment->download_url = $management
|
||||
? url('/management/investment/zones/'.$id.'/attachments/'.$attachment->id)
|
||||
: url('/zonas/'.$id.'/anexos/'.$attachment->id);
|
||||
});
|
||||
$props = ['record' => $management ? $zone : $zone->only($publicFields), 'source' => $source, 'operator' => $operator, 'management' => $management,
|
||||
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence'),
|
||||
'related' => $related->unique('id')->values(),
|
||||
'relations' => $management ? DB::table('investment_zone_relations')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
'geometries' => $management ? DB::table('investment_zone_geometries')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
@@ -43,6 +71,8 @@ class InvestmentZoneController extends Controller
|
||||
'legalSources' => $management ? DB::table('investment_legal_sources')->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
'operators' => $management ? DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->limit(200)->get(['id', 'name']) : [],
|
||||
'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
'locations' => $locations,
|
||||
'attachments' => $attachments,
|
||||
'revisions' => $management ? DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->limit(20)->get(['version_no', 'action', 'sha256', 'created_at']) : []];
|
||||
|
||||
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zone', $props);
|
||||
@@ -50,31 +80,132 @@ class InvestmentZoneController extends Controller
|
||||
|
||||
public function index(Request $request, InvestmentZones $zones): Response|JsonResponse
|
||||
{
|
||||
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
|
||||
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
|
||||
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
|
||||
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
$management = $request->is('management/*', 'api/v2/investment/admin/*');
|
||||
$query = $management ? InvestmentZone::query() : $zones->visible();
|
||||
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
|
||||
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($path !== []) {
|
||||
$query->whereIn('primary_territory_id', app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id));
|
||||
}
|
||||
$rows = $query->when($filters['category'] ?? null, fn ($q, $v) => $q->where('category', $v))
|
||||
->when($filters['q'] ?? null, fn ($q, $v) => $q->where(fn ($q) => $q->where('name', 'ilike', '%'.$v.'%')->orWhere('code', 'ilike', '%'.$v.'%')))
|
||||
->orderBy('name')->orderBy('id')->paginate(12, $management ? ['*'] : ['id', 'code', 'name', 'name_en', 'category', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'])->withQueryString();
|
||||
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path];
|
||||
$publicColumns = ['id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'];
|
||||
$publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
|
||||
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
|
||||
->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString();
|
||||
$rows->getCollection()->transform(function ($zone) use ($management) {
|
||||
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
|
||||
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
|
||||
$zone->setAttribute('district', collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district);
|
||||
$zone->setAttribute('territory_path', $territoryPath);
|
||||
$zone->setAttribute('locations', DB::table('investment_zone_locations as zl')->join('geo_locations as g', 'g.id', '=', 'zl.geo_location_id')
|
||||
->where('zl.zone_id', $zone->id)->whereNull('g.deleted_at')
|
||||
->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
|
||||
->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'zl.scope_type']));
|
||||
|
||||
return $zone;
|
||||
});
|
||||
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path,
|
||||
'zoneExtensionsAvailable' => Schema::hasColumn('investment_zones', 'source_evidence')];
|
||||
|
||||
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
|
||||
}
|
||||
|
||||
public function export(Request $request, InvestmentZones $zones)
|
||||
{
|
||||
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
|
||||
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'], 'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
|
||||
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
|
||||
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
|
||||
$query = $zones->search($filters)->orderBy('name')->orderBy('id');
|
||||
|
||||
return response()->streamDownload(function () use ($query): void {
|
||||
$geography = app(InvestmentGeography::class);
|
||||
$stream = fopen('php://output', 'wb');
|
||||
fwrite($stream, "\xEF\xBB\xBF");
|
||||
fputcsv($stream, ['Código', 'Nome', 'Categoria física', 'Regime', 'Desenvolvimento', 'Disponibilidade', 'Província', 'Distrito', 'Área declarada', 'Unidade', 'Fontes'], ';');
|
||||
$query->chunk(200, function ($zones) use ($stream): void {
|
||||
foreach ($zones as $zone) {
|
||||
$territoryPath = $geography->path($zone->primary_territory_id);
|
||||
$province = collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province;
|
||||
$district = collect($territoryPath)->firstWhere('location_type', 'district')->name ?? $zone->district;
|
||||
$sources = collect($zone->source_evidence ?? [])->map(fn ($source) => ($source['title'] ?? '').' — '.($source['url'] ?? '').' — '.($source['source_date'] ?? 'data não informada'))->implode(' | ');
|
||||
$values = [$zone->code, $zone->name, $zone->physical_category, $zone->legal_regime, $zone->development_status,
|
||||
$zone->availability_status, $province, $district, $zone->area_value, $zone->area_unit, $sources];
|
||||
$values = array_map(fn ($value) => is_string($value) && preg_match('/^[=+@\-\t\r]/u', $value) ? "'".$value : $value, $values);
|
||||
fputcsv($stream, $values, ';');
|
||||
}
|
||||
});
|
||||
fclose($stream);
|
||||
}, 'zonas-e-parques.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function save(Request $request, InvestmentZones $zones, ?string $id = null): JsonResponse
|
||||
{
|
||||
if ($request->has('aliases_text')) {
|
||||
$request->merge(['aliases' => array_values(array_unique(array_filter(preg_split('/[\r\n,;]+/u', (string) $request->input('aliases_text')) ?: [], fn ($value) => trim($value) !== '')))]);
|
||||
}
|
||||
if ($request->filled('historical_name')) {
|
||||
$request->merge(['historical_territory' => [
|
||||
'name' => $request->input('historical_name'), 'district' => $request->input('historical_district'),
|
||||
'province' => $request->input('historical_province'), 'source_reference' => $request->input('historical_source_reference'),
|
||||
]]);
|
||||
}
|
||||
if ($request->filled('activity_name')) {
|
||||
$request->merge(['activities' => [['name' => $request->input('activity_name'), 'status' => $request->input('activity_status', 'potential')]]]);
|
||||
}
|
||||
$data = $request->validate(['version_no' => [$id ? 'required' : 'nullable', 'integer', 'min:1'],
|
||||
'code' => ['required', 'string', 'max:80', Rule::unique('investment_zones')->ignore($id)],
|
||||
'category' => ['required', 'in:zee,zfi,industrial_park,pending'], 'name' => ['required', 'string', 'max:255'],
|
||||
'name_en' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:15000'],
|
||||
'description_en' => ['nullable', 'string', 'max:15000'], 'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'],
|
||||
'operator_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
|
||||
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
|
||||
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id'],
|
||||
'physical_category' => ['nullable', 'in:economic_zone,industrial_park,agro_industrial_park,industrial_logistics_park,industrial_free_zone,integrated_tourism_area,other,unknown'],
|
||||
'legal_regime' => ['nullable', 'in:zee,zfi,general,pending,not_applicable'],
|
||||
'operational_status' => ['nullable', 'in:operational,implementation,planned,suspended,unknown'],
|
||||
'development_status' => ['nullable', 'in:announced,planned,in_development,partially_operational,operational,suspended,unknown'],
|
||||
'availability_status' => ['nullable', 'in:confirmed,unavailable,reported_unverified,not_informed'],
|
||||
'area_components' => ['sometimes', 'array', 'max:30'], 'area_components.*' => ['array:type,value,unit,source_reference,source_date,status'],
|
||||
'area_components.*.type' => ['required', 'in:total,legal,developed,pilot,available'], 'area_components.*.value' => ['nullable', 'numeric', 'min:0'],
|
||||
'area_components.*.unit' => ['required', 'in:ha,km2,m2'], 'area_components.*.source_reference' => ['required', 'string', 'min:5', 'max:2000'],
|
||||
'area_components.*.source_date' => ['nullable', 'date_format:Y-m-d'], 'area_components.*.status' => ['required', 'in:reported,confirmed,disputed'],
|
||||
'source_evidence' => ['sometimes', 'array', 'max:30'], 'source_evidence.*' => ['array:title,url,source_type,supports,source_date'],
|
||||
'source_evidence.*.title' => ['required', 'string', 'max:255'], 'source_evidence.*.url' => ['required', 'url:https', 'max:2000'],
|
||||
'source_evidence.*.source_type' => ['required', 'in:government,institutional,promoter,other'], 'source_evidence.*.supports' => ['required', 'string', 'max:500'],
|
||||
'source_evidence.*.source_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'area_value' => ['nullable', 'numeric', 'min:0'], 'area_unit' => ['nullable', 'string', 'max:24'],
|
||||
'aliases' => ['sometimes', 'array', 'max:20'], 'aliases.*' => ['string', 'max:120'],
|
||||
'activities' => ['sometimes', 'array', 'max:50'], 'activities.*' => ['array:name,status,source_reference'],
|
||||
'activities.*.name' => ['required', 'string', 'max:300'], 'activities.*.status' => ['required', 'in:proposed,authorized,conditional,excluded,potential'],
|
||||
'activities.*.source_reference' => ['nullable', 'string', 'max:2000'],
|
||||
'resources' => ['sometimes', 'array', 'max:50'], 'resources.*' => ['array:name,status,source_reference'],
|
||||
'resources.*.name' => ['required', 'string', 'max:120'], 'resources.*.status' => ['required', 'in:existing,planned,unconfirmed'],
|
||||
'resources.*.source_reference' => ['nullable', 'string', 'max:2000'], 'data_as_of' => ['nullable', 'date_format:Y-m-d'],
|
||||
'sources_consulted_at' => ['nullable', 'date_format:Y-m-d'], 'parent_zone_id' => ['nullable', 'uuid', 'exists:investment_zones,id'],
|
||||
'historical_territory' => ['nullable', 'array:name,district,province,source_reference'],
|
||||
'historical_territory.name' => ['required_with:historical_territory', 'string', 'max:255'],
|
||||
'historical_territory.district' => ['nullable', 'string', 'max:120'], 'historical_territory.province' => ['nullable', 'string', 'max:120'],
|
||||
'historical_territory.source_reference' => ['nullable', 'string', 'max:2000'],
|
||||
'locations' => ['sometimes', 'array', 'max:20'], 'locations.*.geo_location_id' => ['required', 'uuid', 'exists:geo_locations,id'],
|
||||
'locations.*.scope_type' => ['required', 'in:legal_scope,administrative,site,influence'],
|
||||
'locations.*.source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'locations.*.legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
|
||||
|
||||
foreach (['development_status', 'availability_status', 'area_components', 'source_evidence'] as $column) {
|
||||
if (! Schema::hasColumn('investment_zones', $column)) {
|
||||
unset($data[$column]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data['activities'] ?? [] as $index => $activity) {
|
||||
if (! in_array($activity['status'], ['potential', 'proposed'], true) && trim((string) ($activity['source_reference'] ?? '')) === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'activities.'.$index.'.source_reference' => 'Indique a fonte da actividade documentada antes de a classificar como autorizada, condicionada ou excluída.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $id && $request->has('province_id')) {
|
||||
$geo = $request->validate(['province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
@@ -105,4 +236,12 @@ class InvestmentZoneController extends Controller
|
||||
|
||||
return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at']));
|
||||
}
|
||||
|
||||
public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse
|
||||
{
|
||||
InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers');
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
return response()->json($zones->validateLocation($id, $data['version_no'], $request->user()->id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -98,9 +99,15 @@ class InvestmentZoneGeometryController extends Controller
|
||||
|
||||
public function map(Request $request, InvestmentZones $zones): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric']]);
|
||||
$data = $request->validate(['zone_id' => ['nullable', 'uuid'], 'bbox' => ['nullable', 'array', 'size:4'], 'bbox.*' => ['required', 'numeric'],
|
||||
'category' => ['nullable', 'in:zee,zfi,industrial_park'], 'physical_category' => ['nullable', 'string', 'max:40'], 'legal_regime' => ['nullable', 'string', 'max:30'],
|
||||
'development_status' => ['nullable', 'string', 'max:32'], 'availability_status' => ['nullable', 'string', 'max:32'], 'activity' => ['nullable', 'string', 'max:120'], 'q' => ['nullable', 'string', 'max:120'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
|
||||
abort_if((! empty($data['development_status']) || ! empty($data['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
|
||||
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
|
||||
$eligibleZones = $zones->search($data)->select('investment_zones.id');
|
||||
$query = DB::table('investment_zone_geometries as l')->join('geo_geometries as g', 'g.id', '=', 'l.geometry_id')
|
||||
->whereIn('l.zone_id', $zones->visible()->select('investment_zones.id'))->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
|
||||
->whereIn('l.zone_id', $eligibleZones)->where('l.publication_authorized', true)->where('l.validation_status', 'validated')
|
||||
->whereNull('g.deleted_at')->where('g.data_classification', 'public')->when($data['zone_id'] ?? null, fn ($q, $v) => $q->where('l.zone_id', $v))
|
||||
->where(fn ($q) => $q->whereNull('l.legal_source_id')->orWhereExists(fn ($s) => $s->selectRaw('1')->from('investment_legal_sources as ls')
|
||||
->whereColumn('ls.id', 'l.legal_source_id')->where('ls.validation_status', 'validated')->whereDate('ls.valid_from', '<=', today())
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Http\Requests\StoreLandAssistanceCaseRequest;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\LandAssistanceCase;
|
||||
use App\Services\InterestSubmission;
|
||||
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\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class LandAssistanceController extends Controller
|
||||
{
|
||||
private const DISCLAIMER = 'Este documento apoia a preparação e o acompanhamento. Não constitui requerimento oficialmente recebido, título de DUAT, confirmação de disponibilidade da terra ou decisão da autoridade competente.';
|
||||
|
||||
public function guide(): Response
|
||||
{
|
||||
return Inertia::render('land-assistance/guide', ['disclaimer' => self::DISCLAIMER]);
|
||||
}
|
||||
|
||||
public function blank(): Response
|
||||
{
|
||||
return Inertia::render('land-assistance/blank', ['disclaimer' => self::DISCLAIMER]);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$cases = LandAssistanceCase::where('user_id', $request->user()->id)
|
||||
->latest('updated_at')->paginate(20)->through(fn (LandAssistanceCase $case): array => [
|
||||
'id' => $case->id,
|
||||
'reference' => $case->reference,
|
||||
'status' => $case->status,
|
||||
'version' => $case->version,
|
||||
'updated_at' => $case->updated_at,
|
||||
]);
|
||||
|
||||
return Inertia::render('land-assistance/workspace', ['cases' => $cases]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('land-assistance/form', [
|
||||
'case' => null,
|
||||
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'modelVersion' => 'land-assistance-v1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, string $id): Response
|
||||
{
|
||||
$case = $this->owned($request, $id);
|
||||
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido não está aberto para edição.');
|
||||
|
||||
return Inertia::render('land-assistance/form', [
|
||||
'case' => $case->only(['id', 'reference', 'version', 'status', 'payload']),
|
||||
'user' => ['name' => $request->user()->name, 'email' => $request->user()->email],
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'modelVersion' => 'land-assistance-v1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreLandAssistanceCaseRequest $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$payload = $request->payload();
|
||||
$payload['full_name'] = $payload['full_name'] ?? $request->user()->name;
|
||||
$payload['email'] = $payload['email'] ?? $request->user()->email;
|
||||
$case = DB::transaction(function () use ($request, $payload): LandAssistanceCase {
|
||||
$case = LandAssistanceCase::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'reference' => 'TERRA-'.now()->format('Y').'-'.Str::upper(Str::random(8)),
|
||||
'status' => 'draft',
|
||||
'version' => 1,
|
||||
'payload' => $payload,
|
||||
'crm_submission_key' => (string) Str::uuid(),
|
||||
]);
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'draft_created');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version], 201)
|
||||
: redirect()->route('land-assistance.show', $case->id);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $id): Response|JsonResponse
|
||||
{
|
||||
$case = $this->owned($request, $id);
|
||||
$data = [
|
||||
'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']),
|
||||
'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
|
||||
->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')
|
||||
->get(['link.id', 'link.category', 'document.file_name', 'document.status']),
|
||||
'events' => DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')
|
||||
->get(['action', 'version', 'metadata', 'created_at']),
|
||||
'supportTicket' => $case->interest_request_id
|
||||
? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at'])
|
||||
: null,
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'management' => false,
|
||||
];
|
||||
|
||||
return $request->expectsJson() ? response()->json($data) : Inertia::render('land-assistance/case', $data);
|
||||
}
|
||||
|
||||
public function save(StoreLandAssistanceCaseRequest $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$payload = $request->payload();
|
||||
$case = DB::transaction(function () use ($request, $id, $data, $payload): LandAssistanceCase {
|
||||
$case = $this->owned($request, $id, true);
|
||||
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido está bloqueado para edição pelo requerente.');
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$payload['full_name'] = $payload['full_name'] ?? $case->payload['full_name'] ?? $request->user()->name;
|
||||
$payload['email'] = $payload['email'] ?? $case->payload['email'] ?? $request->user()->email;
|
||||
$case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'draft_updated');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'version' => $case->version])
|
||||
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Rascunho guardado.');
|
||||
}
|
||||
|
||||
public function requestSupport(StoreLandAssistanceCaseRequest $request, string $id, InterestSubmission $submission): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$submittedPayload = $request->payload();
|
||||
$case = DB::transaction(function () use ($request, $id, $data, $submission, $submittedPayload): LandAssistanceCase {
|
||||
$case = $this->owned($request, $id, true);
|
||||
abort_unless(in_array($case->status, ['draft', 'waiting_applicant'], true), 409, 'Este pedido já não pode ser submetido neste estado.');
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$payload = [...$case->payload, ...$submittedPayload];
|
||||
$payload['full_name'] = $payload['full_name'] ?? $request->user()->name;
|
||||
if (! array_key_exists('email', $payload)) {
|
||||
$payload['email'] = $request->user()->email;
|
||||
}
|
||||
$payload['email'] = is_string($payload['email']) && trim($payload['email']) !== '' ? strtolower(trim($payload['email'])) : null;
|
||||
abort_if(empty($payload['email']) && empty($payload['phone']), 422, 'Indique pelo menos um contacto valido.');
|
||||
$message = trim(($payload['request_description'] ?? 'Pedido de apoio sobre Terra e DUAT.')."\nReferência interna: {$case->reference}. Tipo: ".($payload['support_kind'] ?? 'não indicado').'.');
|
||||
$interest = $submission->receive([
|
||||
'kind' => 'support', 'fullName' => $payload['full_name'], 'email' => $payload['email'],
|
||||
'phone' => $payload['phone'] ?? null, 'country' => $payload['country'] ?? 'Não indicado',
|
||||
'sectorInterest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000),
|
||||
'privacyConsent' => true, 'portal_user_id' => $request->user()->id,
|
||||
'land_case_id' => $case->id, 'land_case_reference' => $case->reference,
|
||||
], $case->crm_submission_key, fn () => InterestRequest::create([
|
||||
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $payload['full_name'],
|
||||
'email' => $payload['email'], 'phone' => $payload['phone'] ?? null,
|
||||
'country' => $payload['country'] ?? 'Não indicado', 'investor_type' => 'individual',
|
||||
'sector_interest' => 'Terra e DUAT', 'message' => mb_substr($message, 0, 2000),
|
||||
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/terra-duat',
|
||||
'metadata' => ['kind' => 'support', 'portal_user_id' => $request->user()->id,
|
||||
'land_case_id' => $case->id, 'land_case_reference' => $case->reference],
|
||||
]));
|
||||
$case->forceFill([
|
||||
'payload' => $payload,
|
||||
'status' => 'support_requested',
|
||||
'support_requested_at' => now(),
|
||||
'version' => $case->version + 1,
|
||||
'interest_request_id' => $interest->id,
|
||||
])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'support_requested');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['id' => $case->id, 'reference' => $case->reference, 'status' => $case->status, 'version' => $case->version], 202)
|
||||
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Pedido de apoio registado no InvestMoz. Isto não é submissão oficial de DUAT.');
|
||||
}
|
||||
|
||||
public function reportUpdate(Request $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version' => ['required', 'integer', 'min:1'],
|
||||
'reported_status' => ['required', 'string', 'max:1000'],
|
||||
'official_reference' => ['nullable', 'string', 'max:160'],
|
||||
'receiving_entity' => ['nullable', 'string', 'max:200'],
|
||||
'reported_at' => ['nullable', 'date_format:Y-m-d'],
|
||||
]);
|
||||
$case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase {
|
||||
$case = $this->owned($request, $id, true);
|
||||
abort_unless(in_array($case->status, ['support_requested', 'internal_review', 'waiting_applicant', 'referral_prepared'], true), 409);
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$payload = $case->payload;
|
||||
$updates = $payload['reported_updates'] ?? [];
|
||||
$updates[] = ['reported_status' => $data['reported_status'], 'official_reference' => $data['official_reference'] ?? null,
|
||||
'receiving_entity' => $data['receiving_entity'] ?? null, 'reported_at' => $data['reported_at'] ?? null, 'recorded_at' => now()->toIso8601String()];
|
||||
$payload['reported_updates'] = array_slice($updates, -20);
|
||||
$case->forceFill(['payload' => $payload, 'version' => $case->version + 1])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'applicant_update_recorded');
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['version' => $case->version, 'reported_by_applicant' => true])
|
||||
: redirect()->route('land-assistance.show', $case->id)->with('status', 'Actualização registada como informação comunicada por si.');
|
||||
}
|
||||
|
||||
public function attach(Request $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version' => ['required', 'integer', 'min:1'],
|
||||
'category' => ['required', Rule::in(['sketch', 'prior_right', 'exploration_plan', 'official_receipt', 'consultation_minutes', 'photo', 'other'])],
|
||||
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
|
||||
]);
|
||||
$case = $this->owned($request, $id, true);
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
abort_unless(! in_array($case->status, ['closed'], true), 409);
|
||||
abort_if(DB::table('land_assistance_documents')->where('case_id', $id)->count() >= 20, 422, 'Máximo de vinte documentos por pedido.');
|
||||
|
||||
$file = $request->file('file');
|
||||
$documentId = (string) Str::uuid();
|
||||
$key = 'land-assistance/'.$id.'/'.$documentId;
|
||||
$stored = $file->storeAs('land-assistance/'.$id, $documentId, 'local');
|
||||
if (! is_string($stored) || $stored === '') {
|
||||
throw ValidationException::withMessages(['file' => 'Não foi possível guardar o ficheiro em armazenamento privado.']);
|
||||
}
|
||||
try {
|
||||
DB::transaction(function () use ($request, $case, $data, $file, $documentId, $stored): void {
|
||||
$locked = $this->owned($request, $case->id, true);
|
||||
$this->checkVersion($locked, (int) $data['version']);
|
||||
DB::table('documents')->insert([
|
||||
'id' => $documentId,
|
||||
'source_system' => 'land-assistance',
|
||||
'data_classification' => 'restricted',
|
||||
'file_name' => basename($file->getClientOriginalName()),
|
||||
'media_type' => (string) $file->getMimeType(),
|
||||
'storage_key' => $stored,
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)),
|
||||
// No antivirus engine is configured in this project; fail closed until an operator scans it.
|
||||
'status' => 'quarantined',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('land_assistance_documents')->insert([
|
||||
'id' => (string) Str::uuid(), 'case_id' => $locked->id, 'document_id' => $documentId,
|
||||
'category' => $data['category'], 'created_at' => now(),
|
||||
]);
|
||||
$locked->forceFill(['version' => $locked->version + 1])->save();
|
||||
$this->revision($locked);
|
||||
$this->event($locked, $request, 'attachment_added', ['document_id' => $documentId, 'category' => $data['category']]);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
Storage::disk('local')->delete($stored);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['uploaded' => true], 201)
|
||||
: back()->with('status', 'Documento privado anexado; a revisão ainda está pendente.');
|
||||
}
|
||||
|
||||
public function download(Request $request, string $id, string $attachment): BinaryFileResponse
|
||||
{
|
||||
$this->owned($request, $id);
|
||||
$document = DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
|
||||
->where('link.case_id', $id)->where('link.id', $attachment)->where('document.status', 'active')->whereNull('document.deleted_at')
|
||||
->where('document.malware_scan_status', 'clean')->whereColumn('document.malware_scanned_sha256', 'document.sha256')
|
||||
->first(['document.file_name', 'document.storage_key', 'document.sha256', 'document.malware_scanned_sha256']);
|
||||
abort_if($document === null, 404);
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path), 404);
|
||||
$checksum = hash_file('sha256', $path);
|
||||
abort_unless(is_string($checksum) && hash_equals($document->sha256, $checksum)
|
||||
&& hash_equals($document->sha256, (string) $document->malware_scanned_sha256), 404);
|
||||
|
||||
return response()->download($path, basename($document->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
}
|
||||
|
||||
public function print(Request $request, string $id): Response
|
||||
{
|
||||
$case = $this->owned($request, $id);
|
||||
|
||||
return Inertia::render('land-assistance/print', [
|
||||
'case' => $case->only(['reference', 'version', 'payload', 'updated_at']),
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'modelVersion' => 'land-assistance-v1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function manage(Request $request): Response
|
||||
{
|
||||
$cases = LandAssistanceCase::query()->latest('updated_at')->paginate(30)->through(fn (LandAssistanceCase $case): array => [
|
||||
'id' => $case->id, 'reference' => $case->reference, 'status' => $case->status,
|
||||
'version' => $case->version, 'updated_at' => $case->updated_at,
|
||||
'applicant' => $case->payload['full_name'] ?? null,
|
||||
]);
|
||||
|
||||
return Inertia::render('land-assistance/management', ['cases' => $cases]);
|
||||
}
|
||||
|
||||
public function manageShow(Request $request, string $id): Response
|
||||
{
|
||||
$case = LandAssistanceCase::findOrFail($id);
|
||||
$events = DB::table('land_assistance_events')->where('case_id', $id)->orderBy('created_at')->get(['action', 'version', 'metadata', 'created_at']);
|
||||
|
||||
return Inertia::render('land-assistance/case', [
|
||||
'case' => $case->only(['id', 'reference', 'status', 'version', 'payload', 'updated_at']),
|
||||
'documents' => DB::table('land_assistance_documents as link')->join('documents as document', 'document.id', '=', 'link.document_id')
|
||||
->where('link.case_id', $id)->whereIn('document.status', ['active', 'quarantined'])->whereNull('document.deleted_at')->get(['link.id', 'link.category', 'document.file_name', 'document.status']),
|
||||
'events' => $events,
|
||||
'supportTicket' => $case->interest_request_id
|
||||
? InterestRequest::query()->whereKey($case->interest_request_id)->first(['reference', 'status', 'created_at'])
|
||||
: null,
|
||||
'disclaimer' => self::DISCLAIMER,
|
||||
'management' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function changeStatus(Request $request, string $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version' => ['required', 'integer', 'min:1'],
|
||||
'status' => ['required', Rule::in(['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'])],
|
||||
'evidence_reference' => ['nullable', 'string', 'max:300'],
|
||||
]);
|
||||
$case = DB::transaction(function () use ($request, $id, $data): LandAssistanceCase {
|
||||
$case = LandAssistanceCase::whereKey($id)->lockForUpdate()->firstOrFail();
|
||||
$this->checkVersion($case, (int) $data['version']);
|
||||
$allowed = [
|
||||
'support_requested' => ['internal_review', 'waiting_applicant', 'referral_prepared', 'closed'],
|
||||
'internal_review' => ['waiting_applicant', 'referral_prepared', 'closed'],
|
||||
'waiting_applicant' => ['internal_review', 'referral_prepared', 'closed'],
|
||||
'referral_prepared' => ['internal_review', 'waiting_applicant', 'closed'],
|
||||
];
|
||||
abort_unless(in_array($data['status'], $allowed[$case->status] ?? [], true), 409, 'Transição de estado não permitida.');
|
||||
if ($data['status'] === 'referral_prepared' && empty($data['evidence_reference'])) {
|
||||
throw ValidationException::withMessages(['evidence_reference' => 'Registe a referência interna do material preparado para encaminhamento.']);
|
||||
}
|
||||
$previousStatus = $case->status;
|
||||
$case->forceFill(['status' => $data['status'], 'version' => $case->version + 1])->save();
|
||||
$this->revision($case);
|
||||
$this->event($case, $request, 'status_changed', [
|
||||
'from' => $previousStatus, 'to' => $data['status'],
|
||||
'evidence_reference' => $data['evidence_reference'] ?? null,
|
||||
'external_submission_confirmed' => false,
|
||||
]);
|
||||
|
||||
return $case;
|
||||
});
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['status' => $case->status, 'version' => $case->version])
|
||||
: redirect()->route('land-assistance.manage.show', $case->id)->with('status', 'Estado interno actualizado. Nenhuma recepção pública foi presumida.');
|
||||
}
|
||||
|
||||
private function owned(Request $request, string $id, bool $lock = false): LandAssistanceCase
|
||||
{
|
||||
$query = LandAssistanceCase::whereKey($id)->where('user_id', $request->user()->id);
|
||||
|
||||
return ($lock ? $query->lockForUpdate() : $query)->firstOrFail();
|
||||
}
|
||||
|
||||
private function checkVersion(LandAssistanceCase $case, int $expected): void
|
||||
{
|
||||
abort_unless($case->version === $expected, 412, 'Este rascunho foi alterado. Recarregue antes de continuar.');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $metadata */
|
||||
private function event(LandAssistanceCase $case, Request $request, string $action, array $metadata = []): void
|
||||
{
|
||||
DB::table('land_assistance_events')->insert([
|
||||
'case_id' => $case->id, 'actor_id' => $request->user()->id,
|
||||
'action' => $action, 'version' => $case->version, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function revision(LandAssistanceCase $case): void
|
||||
{
|
||||
DB::table('land_assistance_revisions')->insert([
|
||||
'case_id' => $case->id, 'version' => $case->version,
|
||||
'snapshot' => Crypt::encryptString(json_encode(['status' => $case->status, 'payload' => $case->payload], JSON_THROW_ON_ERROR)),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -133,21 +133,41 @@ class PortalManagementController extends Controller
|
||||
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
|
||||
DB::transaction(function () use ($request, $interest, $data) {
|
||||
$consent = DB::table('consents')->where('interest_request_id', $interest->id)
|
||||
->where('purpose', 'share_contact_with_proponent')->where('status', 'granted')->lockForUpdate()->first();
|
||||
$this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent');
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
|
||||
: back()->with('success', 'Consentimento de partilha revogado.');
|
||||
}
|
||||
|
||||
public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
|
||||
$this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest');
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'contact_consent' => 'withdrawn'])
|
||||
: back()->with('success', 'Consentimento de contacto revogado.');
|
||||
}
|
||||
|
||||
private function withdrawConsent(Request $request, InterestRequest $interest, string $reason, string $purpose): void
|
||||
{
|
||||
DB::transaction(function () use ($request, $interest, $reason, $purpose) {
|
||||
$lockedInterest = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id);
|
||||
$consent = DB::table('consents')->where('interest_request_id', $lockedInterest->id)
|
||||
->where('purpose', $purpose)->where('status', 'granted')->whereNull('deleted_at')
|
||||
->lockForUpdate()->first();
|
||||
if (! $consent) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('consents')->where('id', $consent->id)->update(['status' => 'withdrawn', 'withdrawn_at' => now(), 'updated_at' => now()]);
|
||||
$correlationId = (string) Str::uuid();
|
||||
DB::table('business_workflow_transitions')->insert([
|
||||
'id' => (string) Str::uuid(), 'interest_request_id' => $interest->id,
|
||||
'from_status' => $interest->status, 'to_status' => $interest->status,
|
||||
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($data['reason']),
|
||||
'id' => (string) Str::uuid(), 'interest_request_id' => $lockedInterest->id,
|
||||
'from_status' => $lockedInterest->status, 'to_status' => $lockedInterest->status,
|
||||
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($reason),
|
||||
'correlation_id' => $correlationId,
|
||||
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
|
||||
'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
|
||||
'purpose' => $purpose], JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
DB::table('outbox_events')->insertOrIgnore([
|
||||
@@ -156,14 +176,12 @@ class PortalManagementController extends Controller
|
||||
'event_type' => 'InterestConsentWithdrawn', 'data_classification' => 'confidential',
|
||||
'correlation_id' => $correlationId, 'status' => 'pending',
|
||||
'payload' => json_encode(['schema_version' => 1, 'consent_id' => $consent->id,
|
||||
'interest_id' => $interest->integration_id, 'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
|
||||
'interest_id' => $lockedInterest->integration_id, 'purpose' => $purpose], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
});
|
||||
|
||||
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
|
||||
: back()->with('success', 'Consentimento de partilha revogado.');
|
||||
}
|
||||
|
||||
/** @return Builder<User> */
|
||||
private function interestAssignees(): Builder
|
||||
{
|
||||
$emails = array_unique(array_merge(config('portal.managers', []), ...array_map(
|
||||
@@ -181,9 +199,16 @@ class PortalManagementController extends Controller
|
||||
'q' => ['nullable', 'string', 'max:150'],
|
||||
'status' => ['nullable', Rule::in($resource === 'sectors' ? ['active', 'inactive'] : ['draft', 'published', 'archived'])],
|
||||
'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')],
|
||||
'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
$query = $model::query();
|
||||
if ($resource === 'opportunities') {
|
||||
$query->with('primaryTerritory:id,name,location_type,source_system,source_code');
|
||||
if (isset($filters['geography_scope'])) {
|
||||
$query->where('geography_scope', $filters['geography_scope']);
|
||||
}
|
||||
}
|
||||
if ($term = trim($filters['q'] ?? '')) {
|
||||
$query->where(function ($query) use ($term, $resource) {
|
||||
$query->whereLike($resource === 'sectors' ? 'name' : 'title', "%{$term}%")
|
||||
@@ -211,6 +236,9 @@ class PortalManagementController extends Controller
|
||||
'records' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
|
||||
'filters' => $filters,
|
||||
'sectors' => $resource === 'opportunities' ? Sector::orderBy('name')->get(['id', 'name']) : [],
|
||||
'geographyReviewCount' => $resource === 'opportunities'
|
||||
? Opportunity::where('geography_scope', 'unspecified')->count()
|
||||
: 0,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -258,6 +286,7 @@ class PortalManagementController extends Controller
|
||||
'is_featured' => ['required', 'boolean'],
|
||||
];
|
||||
if ($resource === 'opportunities') {
|
||||
$rules['geography_scope'] = ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])];
|
||||
foreach (InvestmentGeography::FIELDS as $field => $type) {
|
||||
$rules[$field] = ['nullable', 'uuid'];
|
||||
}
|
||||
@@ -285,8 +314,14 @@ class PortalManagementController extends Controller
|
||||
abort_unless((int) $current->territory_version === (int) $data['territory_version'], 409, 'Localização alterada. Recarregue a ficha.');
|
||||
}
|
||||
$path = app(InvestmentGeography::class)->selection($data, true);
|
||||
if (($data['geography_scope'] ?? null) === 'national' && $path !== []) {
|
||||
throw ValidationException::withMessages(['geography_scope' => 'O âmbito nacional não pode ter uma localização territorial específica.']);
|
||||
}
|
||||
$data = array_diff_key($data, InvestmentGeography::FIELDS);
|
||||
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
|
||||
if ($path !== [] && empty($data['geography_scope'])) {
|
||||
$data['geography_scope'] = 'specific';
|
||||
}
|
||||
$data['territory_version'] = (int) ($data['territory_version'] ?? 0) + 1;
|
||||
$data['province'] = $path[0]->name ?? null;
|
||||
$data['district'] = $path[1]->name ?? null;
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\ScanDocumentForMalware;
|
||||
use App\Models\InterestRequest;
|
||||
use App\Models\Sector;
|
||||
use App\Services\IdempotencyConflict;
|
||||
use App\Services\InterestSubmission;
|
||||
use App\Services\Malware\MalwareScanner;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -20,21 +24,40 @@ class PortalServicesController extends Controller
|
||||
{
|
||||
public function home(): Response
|
||||
{
|
||||
return Inertia::render('PortalInvestidor', ['essentialResources' => $this->resources()->where('r.essential', true)
|
||||
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title'])]);
|
||||
try {
|
||||
$essentialResources = $this->resources()->where('r.essential', true)
|
||||
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title']);
|
||||
} catch (QueryException $exception) {
|
||||
// A catalogue schema mismatch must not take down unrelated home content.
|
||||
// Keep the document list empty and retain the full exception in internal logs.
|
||||
report($exception);
|
||||
$essentialResources = collect();
|
||||
}
|
||||
|
||||
return Inertia::render('PortalInvestidor', ['essentialResources' => $essentialResources]);
|
||||
}
|
||||
|
||||
private function resources(): Builder
|
||||
{
|
||||
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where('r.published', true)->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at');
|
||||
->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
|
||||
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
|
||||
}
|
||||
|
||||
public function page(Request $request): Response
|
||||
{
|
||||
$page = $request->route()->defaults['page'];
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
|
||||
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100']]);
|
||||
$filters = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
|
||||
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100'],
|
||||
'document_type' => ['nullable', 'string', 'max:40'], 'language' => ['nullable', 'string', 'max:12'],
|
||||
'issuer' => ['nullable', 'string', 'max:180'], 'legal_status' => ['nullable', 'string', 'max:24'],
|
||||
'source_confirmation' => ['nullable', 'string', 'max:24'], 'territory_id' => ['nullable', 'uuid'],
|
||||
'activity' => ['nullable', 'array', 'max:10'], 'activity.*' => ['string', 'max:180'],
|
||||
'stage' => ['nullable', 'array', 'max:10'], 'stage.*' => ['string', 'max:80'],
|
||||
'module' => ['nullable', 'array', 'max:10'], 'module.*' => ['string', 'max:80'],
|
||||
]);
|
||||
$props = ['section' => $page, 'filters' => $filters];
|
||||
if ($page === 'sectors') {
|
||||
$props['sectors'] = Sector::where('is_active', true)->withCount(['opportunities' => fn ($q) => $q->published()])->orderBy('sort_order')->get(['id', 'name', 'slug', 'short_description']);
|
||||
@@ -46,15 +69,67 @@ class PortalServicesController extends Controller
|
||||
}
|
||||
$props['categories'] = (clone $query)->distinct()->orderBy('r.category')->pluck('r.category');
|
||||
$props['topics'] = (clone $query)->distinct()->orderBy('r.topic')->pluck('r.topic');
|
||||
foreach (['category', 'topic'] as $field) {
|
||||
foreach (['category', 'topic', 'document_type', 'language', 'legal_status', 'source_confirmation'] as $field) {
|
||||
if (! empty($filters[$field])) {
|
||||
$query->where('r.'.$field, $filters[$field]);
|
||||
}
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$query->where('r.title', 'ilike', '%'.$filters['q'].'%');
|
||||
if (! empty($filters['issuer'])) {
|
||||
$query->where('d.issuer', 'ilike', '%'.$filters['issuer'].'%');
|
||||
}
|
||||
if (! empty($filters['territory_id'])) {
|
||||
$territoryIds = $this->territoryScopeIds($filters['territory_id']);
|
||||
$query->where(function (Builder $territorial) use ($territoryIds): void {
|
||||
$territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $territoryIds)
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
|
||||
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'territory')->whereIn('l.target_key', $territoryIds));
|
||||
});
|
||||
}
|
||||
foreach (['activity', 'stage', 'module'] as $dimension) {
|
||||
if (! empty($filters[$dimension])) {
|
||||
$query->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
|
||||
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', $dimension)
|
||||
->whereIn('l.target_key', array_values($filters[$dimension])));
|
||||
}
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$term = '%'.$filters['q'].'%';
|
||||
$query->where(fn (Builder $search) => $search->where('r.title', 'ilike', $term)->orWhere('r.description', 'ilike', $term)
|
||||
->orWhere('r.document_number', 'ilike', $term)->orWhere('d.issuer', 'ilike', $term)
|
||||
->orWhere('r.source_reference', 'ilike', $term)->orWhere('r.category', 'ilike', $term)->orWhere('r.topic', 'ilike', $term));
|
||||
}
|
||||
$pageResult = $query->orderByRaw("CASE WHEN r.document_type IN ('official_form','digital_adaptation','guidance_template') THEN 1 ELSE 0 END")
|
||||
->orderByDesc('r.essential')->orderBy('r.title')->select([
|
||||
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type', 'r.document_number', 'r.document_date',
|
||||
'r.applicable_from', 'r.applicable_until',
|
||||
'r.source_url', 'r.language', 'r.version_label', 'r.document_group_id', 'r.territorial_scope', 'r.territory_id',
|
||||
'r.source_confirmation', 'r.legal_status', 'r.rights_status', 'r.last_reviewed_at', 'r.last_reviewed_by', 'r.independent_review', 'r.metadata_version', 'r.source_reference', 'r.essential',
|
||||
'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
|
||||
])->paginate(20)->withQueryString();
|
||||
$pageResult->getCollection()->transform(function ($resource) {
|
||||
$resource->links = DB::table('portal_resource_links')->where('resource_id', $resource->id)
|
||||
->orderBy('target_type')->orderBy('target_key')->get(['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference', 'review_status']);
|
||||
$resource->download_url = route('services.download', ['id' => $resource->id]);
|
||||
$resource->view_url = in_array($resource->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true)
|
||||
? route('services.document.view', ['id' => $resource->id]) : null;
|
||||
|
||||
return $resource;
|
||||
});
|
||||
$props['resources'] = $pageResult;
|
||||
$props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type');
|
||||
$props['languages'] = (clone $this->resources())->distinct()->orderBy('r.language')->pluck('r.language');
|
||||
$props['issuers'] = (clone $this->resources())->whereNotNull('d.issuer')->distinct()->orderBy('d.issuer')->pluck('d.issuer');
|
||||
$props['legalStatuses'] = (clone $this->resources())->distinct()->orderBy('r.legal_status')->pluck('r.legal_status');
|
||||
$props['territories'] = DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type', 'parent_id']);
|
||||
foreach (['activities' => 'activity', 'stages' => 'stage', 'modules' => 'module'] as $key => $type) {
|
||||
$props[$key] = DB::table('portal_resource_links as l')->join('portal_resources as r', 'r.id', '=', 'l.resource_id')
|
||||
->join('documents as d', 'd.id', '=', 'r.document_id')->where('l.target_type', $type)->where('r.published', true)
|
||||
->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
|
||||
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
|
||||
->distinct()->orderBy('l.target_key')->pluck('l.target_key');
|
||||
}
|
||||
$props['resources'] = $query->orderByDesc('r.essential')->orderBy('r.title')->select(['r.id', 'r.title', 'r.category', 'r.topic', 'r.source_reference', 'r.essential'])->paginate(20)->withQueryString();
|
||||
}
|
||||
if ($page === 'partnerships') {
|
||||
$query = DB::table('portal_partnerships')->where('status', 'published');
|
||||
@@ -77,6 +152,55 @@ class PortalServicesController extends Controller
|
||||
return Inertia::render('portal/services', $props);
|
||||
}
|
||||
|
||||
public function recommendations(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'activity_id' => ['nullable', 'string', 'max:180'], 'province_id' => ['nullable', 'uuid'],
|
||||
'stage' => ['nullable', 'string', 'max:80'], 'module' => ['nullable', 'string', 'max:80'],
|
||||
]);
|
||||
$module = $data['module'] ?? 'simulator';
|
||||
$query = $this->resources()->whereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')
|
||||
->whereColumn('l.resource_id', 'r.id')->where('l.target_type', 'module')->where('l.target_key', $module));
|
||||
$matches = [['type' => 'module', 'key' => $module]];
|
||||
foreach (['activity_id' => 'activity', 'stage' => 'stage'] as $input => $type) {
|
||||
if (! empty($data[$input])) {
|
||||
$key = $data[$input];
|
||||
$query->where(function (Builder $contextual) use ($type, $key): void {
|
||||
$contextual->whereNotExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as general')
|
||||
->whereColumn('general.resource_id', 'r.id')->where('general.target_type', $type))
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as matching')
|
||||
->whereColumn('matching.resource_id', 'r.id')->where('matching.target_type', $type)->where('matching.target_key', $key));
|
||||
});
|
||||
$matches[] = ['type' => $type, 'key' => $data[$input]];
|
||||
}
|
||||
}
|
||||
if (! empty($data['province_id'])) {
|
||||
$ids = $this->territoryScopeIds($data['province_id']);
|
||||
$query->where(fn (Builder $territorial) => $territorial->where('r.territorial_scope', 'national')->orWhereIn('r.territory_id', $ids)
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('portal_resource_links as l')->whereColumn('l.resource_id', 'r.id')
|
||||
->where('l.target_type', 'territory')->whereIn('l.target_key', $ids)));
|
||||
$matches = [...$matches, ...array_map(fn ($id) => ['type' => 'territory', 'key' => $id], $ids)];
|
||||
}
|
||||
$resources = $query->orderByDesc('r.essential')->orderBy('r.title')->limit(10)
|
||||
->get(['r.id', 'r.title', 'r.description', 'r.document_type', 'r.legal_status', 'r.source_confirmation', 'r.source_url', 'r.source_reference']);
|
||||
$resources->transform(function ($resource) use ($matches): array {
|
||||
$matched = DB::table('portal_resource_links')->where('resource_id', $resource->id)->whereIn('target_type', array_column($matches, 'type'))
|
||||
->whereIn('target_key', array_column($matches, 'key'))->orderBy('target_type')->first(['target_type', 'target_key', 'applicability', 'condition_text', 'basis_reference']);
|
||||
$evidence = DB::table('portal_resource_links')->where('resource_id', $resource->id)->where('target_type', 'requirement')
|
||||
->where('review_status', 'confirmed')->orderBy('target_key')->get(['target_key', 'basis_reference', 'review_status']);
|
||||
return [...(array) $resource,
|
||||
'download_url' => route('services.download', ['id' => $resource->id]),
|
||||
'recommendation_reason' => $matched?->condition_text ?? ('Relacionado com '.$matched?->target_type.' '.$matched?->target_key.'; confirme a aplicabilidade ao seu projecto.'),
|
||||
'applicability' => $matched?->applicability ?? 'support',
|
||||
'basis_reference' => $matched?->basis_reference,
|
||||
'documentary_evidence' => $evidence->map(fn ($link) => ['key' => $link->target_key,
|
||||
'label' => config('document-requirements.catalogue.'.$link->target_key.'.label', $link->target_key),
|
||||
'citation' => $link->basis_reference, 'status' => 'citation_checked_application_pending'])->values()];
|
||||
});
|
||||
|
||||
return response()->json(['data' => $resources]);
|
||||
}
|
||||
|
||||
public function submit(Request $request, InterestSubmission $submission): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
@@ -133,49 +257,198 @@ class PortalServicesController extends Controller
|
||||
return response()->download($path, basename($resource->file_name), ['X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function manage(): Response
|
||||
public function showDocument(Request $request, int $id): Response
|
||||
{
|
||||
return Inertia::render('portal/manage', ['resources' => DB::table('portal_resources')->latest()->limit(100)->get(),
|
||||
$resource = $this->resources()->where('r.id', $id)->first([
|
||||
'r.id', 'r.document_id', 'r.title', 'r.description', 'r.category', 'r.topic', 'r.document_type',
|
||||
'r.document_number', 'r.document_date', 'r.applicable_from', 'r.applicable_until', 'r.source_url', 'r.language', 'r.version_label',
|
||||
'r.territorial_scope', 'r.territory_id', 'r.source_confirmation', 'r.legal_status', 'r.source_reference',
|
||||
'r.essential', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.issued_on', 'd.valid_until', 'd.version_no',
|
||||
]);
|
||||
abort_unless($resource !== null, 404);
|
||||
$resource->links = DB::table('portal_resource_links')->where('resource_id', $id)
|
||||
->orderBy('target_type')->orderBy('target_key')
|
||||
->get(['target_type', 'target_key', 'relationship', 'applicability', 'condition_text', 'basis_reference', 'review_status']);
|
||||
$territory = $resource->territory_id
|
||||
? DB::table('geo_locations')->where('id', $resource->territory_id)->value('name')
|
||||
: null;
|
||||
$resource->territory_name = $territory;
|
||||
$resource->view_url = in_array($resource->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true)
|
||||
? route('services.document.view', ['id' => $id]) : null;
|
||||
$back = $request->query('back');
|
||||
$backUrl = is_string($back) && (str_starts_with($back, '/documentos') || str_starts_with($back, '/legislacao')) && ! str_starts_with($back, '//')
|
||||
? $back : '/documentos';
|
||||
|
||||
return Inertia::render('portal/document', [
|
||||
'resource' => $resource,
|
||||
'backUrl' => $backUrl,
|
||||
'downloadUrl' => route('services.download', ['id' => $id]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function viewDocument(int $id): BinaryFileResponse
|
||||
{
|
||||
$document = $this->resources()->where('r.id', $id)->first([
|
||||
'd.storage_key', 'd.file_name', 'd.sha256', 'd.media_type',
|
||||
]);
|
||||
abort_unless($document && in_array($document->media_type, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true), 404);
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$checksum = hash_file('sha256', $path);
|
||||
abort_unless($checksum !== false && hash_equals($document->sha256, $checksum), 404);
|
||||
|
||||
return response()->file($path, [
|
||||
'Content-Type' => $document->media_type,
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Content-Security-Policy' => "sandbox; default-src 'none'; style-src 'unsafe-inline'",
|
||||
'Cache-Control' => 'private, no-store',
|
||||
])->setContentDisposition('inline', basename($document->file_name));
|
||||
}
|
||||
|
||||
public function downloadForManager(string $id): BinaryFileResponse
|
||||
{
|
||||
$document = DB::table('documents')->where('id', $id)->whereNull('deleted_at')->first([
|
||||
'storage_key', 'file_name', 'sha256', 'status', 'malware_scan_status', 'malware_scanned_sha256',
|
||||
]);
|
||||
abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean'
|
||||
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
|
||||
$root = realpath(Storage::disk('local')->path(''));
|
||||
$path = realpath(Storage::disk('local')->path($document->storage_key));
|
||||
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
|
||||
$checksum = hash_file('sha256', $path);
|
||||
abort_unless($checksum !== false && hash_equals($document->sha256, $checksum), 404);
|
||||
|
||||
return response()->download($path, basename($document->file_name), [
|
||||
'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
public function manage(MalwareScanner $scanner): Response
|
||||
{
|
||||
$resources = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->orderByDesc('r.updated_at')->limit(200)
|
||||
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']);
|
||||
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
|
||||
$resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values());
|
||||
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
|
||||
$resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values());
|
||||
|
||||
$malwareHealth = $scanner->health();
|
||||
$malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM);
|
||||
|
||||
return Inertia::render('portal/manage', ['resources' => $resources,
|
||||
'requirementCatalogue' => collect(config('document-requirements.catalogue'))->map(fn ($item, $key) => ['key' => $key, ...$item])->values(),
|
||||
'partnerships' => DB::table('portal_partnerships')->latest()->limit(100)->get(),
|
||||
'documents' => DB::table('documents')->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->get(['id', 'file_name'])]);
|
||||
'documents' => DB::table('documents')->where('status', '!=', 'deleted')->whereNull('deleted_at')->latest()->limit(250)
|
||||
->get(['id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until', 'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256', 'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason', 'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts']),
|
||||
'malwareHealth' => $malwareHealth,
|
||||
'malwareCounts' => DB::table('documents')->whereNull('deleted_at')->where('status', '!=', 'deleted')
|
||||
->select('malware_scan_status', DB::raw('count(*) as total'))->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'),
|
||||
'territories' => DB::table('geo_locations')->where('data_classification', 'public')->whereNull('deleted_at')
|
||||
->whereIn('location_type', ['country', 'province', 'district', 'municipality', 'site'])->orderBy('name')->get(['id', 'name', 'location_type']),
|
||||
'incompleteCount' => DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where(fn ($q) => $q->whereNull('r.description')->orWhereNull('d.issuer')->orWhereNull('r.source_url')
|
||||
->orWhere('r.source_confirmation', 'unconfirmed')->orWhere('r.legal_status', 'unknown')->orWhere('r.rights_status', 'unconfirmed'))
|
||||
->count(),
|
||||
'possibleDuplicates' => DB::table('portal_resources')->whereNotNull('duplicate_of_resource_id')->count()]);
|
||||
}
|
||||
|
||||
public function upload(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240'], 'publication_consent' => ['accepted']]);
|
||||
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain,text/csv,image/jpeg,image/png,image/webp,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.text', 'max:10240'],
|
||||
'original_source' => ['nullable', 'url:https', 'max:2000'], 'issuer' => ['nullable', 'string', 'max:180']]);
|
||||
$file = $request->file('file');
|
||||
$checksum = hash_file('sha256', $file->getRealPath());
|
||||
if (DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists()) {
|
||||
return back()->withErrors(['file' => 'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.']);
|
||||
}
|
||||
$id = (string) Str::uuid();
|
||||
$key = $file->storeAs('portal-documents', $id, 'local');
|
||||
abort_unless($key !== false, 500);
|
||||
try {
|
||||
DB::transaction(function () use ($file, $id, $key, $request) {
|
||||
DB::transaction(function () use ($file, $id, $key, $request, $checksum) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['investmoz-document-content:'.$checksum]);
|
||||
abort_unless(! DB::table('documents')->where('sha256', $checksum)->whereNull('deleted_at')->exists(), 409,
|
||||
'Já existe um ficheiro com o mesmo conteúdo. Verifique se corresponde a uma nova versão ou a um duplicado.');
|
||||
DB::table('documents')->insert(['id' => $id, 'source_system' => 'portal-services',
|
||||
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($key)), 'status' => 'active', 'data_classification' => 'public']);
|
||||
'sha256' => $checksum, 'size_bytes' => $file->getSize(), 'issuer' => $request->input('issuer'),
|
||||
'original_source' => $request->input('original_source'), 'uploaded_by' => $request->user()->id,
|
||||
'status' => 'quarantined', 'data_classification' => 'internal', 'rights_status' => 'unconfirmed']);
|
||||
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => 'documents', 'entity_id' => $id,
|
||||
'actor_user_id' => $request->user()->id, 'action' => 'public_document_uploaded']);
|
||||
'actor_user_id' => $request->user()->id, 'action' => 'document_uploaded_to_quarantine']);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
Storage::disk('local')->delete($key);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return back()->with('success', 'Documento carregado. Seleccione-o no catálogo e registe a fonte para publicar.');
|
||||
ScanDocumentForMalware::dispatch($id)->afterCommit();
|
||||
|
||||
return back()->with('success', 'Documento recebido em armazenamento privado e quarentena. Não aparece na biblioteca pública até validação e publicação explícitas.');
|
||||
}
|
||||
|
||||
public function resource(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
|
||||
'category' => ['required', 'string', 'max:80'], 'topic' => ['required', 'string', 'max:80'],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'], 'published' => ['required', 'boolean']]);
|
||||
DB::transaction(function () use ($data, $request) {
|
||||
abort_unless(DB::table('documents')->where('id', $data['document_id'])->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->lockForUpdate()->exists(), 422, 'Seleccione um documento público activo.');
|
||||
$id = DB::table('portal_resources')->insertGetId([...$data, 'approved_by' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->audit($request, 'resource_registered', 'portal_resources', $id);
|
||||
$data = $this->validateResource($request);
|
||||
$id = DB::transaction(function () use ($data, $request): int {
|
||||
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
|
||||
$identity = $this->identityKey($data);
|
||||
if ($identity) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
|
||||
}
|
||||
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null;
|
||||
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true);
|
||||
$this->assertPublicClassificationEligible($data, $document);
|
||||
$id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->syncResourceLinks($id, $data['associations'] ?? []);
|
||||
$this->recordResourceRevision($id, 'created', $request->user()->id, 1);
|
||||
DB::table('documents')->where('id', $document->id)->update(['issuer' => $data['issuer'] ?: $document->issuer,
|
||||
'issued_on' => $data['document_date'] ?: $document->issued_on, 'rights_status' => $data['rights_status'],
|
||||
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
|
||||
$this->audit($request, $duplicate ? 'resource_registered_possible_duplicate' : 'resource_registered', 'portal_resources', $id);
|
||||
|
||||
return $id;
|
||||
});
|
||||
|
||||
return back()->with('success', 'Documento catalogado.');
|
||||
return back()->with('success', 'Documento registado como rascunho privado da biblioteca. A publicação requer uma acção separada.');
|
||||
}
|
||||
|
||||
public function updateResource(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$data = $this->validateResource($request);
|
||||
$expectedVersion = (int) $request->validate(['metadata_version' => ['required', 'integer', 'min:1']])['metadata_version'];
|
||||
DB::transaction(function () use ($data, $request, $id, $expectedVersion): void {
|
||||
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($resource !== null, 404);
|
||||
abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
|
||||
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
|
||||
$identity = $this->identityKey($data);
|
||||
if ($identity) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
|
||||
}
|
||||
$duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null;
|
||||
$fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false);
|
||||
$this->assertPublicClassificationEligible($data, $document);
|
||||
$retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized'
|
||||
&& $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url'])
|
||||
&& (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed')
|
||||
&& $document->status === 'active' && ($data['data_classification'] ?? $document->data_classification) === 'public' && $document->rights_status === 'authorized'
|
||||
&& $document->malware_scan_status === 'clean' && hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256));
|
||||
DB::table('portal_resources')->where('id', $id)->update([...$fields, 'approved_by' => $retainPublication ? $resource->approved_by : null, 'published' => $retainPublication,
|
||||
'metadata_version' => $expectedVersion + 1, 'updated_at' => now()]);
|
||||
DB::table('portal_resource_links')->where('resource_id', $id)->delete();
|
||||
$this->syncResourceLinks($id, $data['associations'] ?? []);
|
||||
$this->recordResourceRevision($id, 'metadata_updated', $request->user()->id, $expectedVersion + 1);
|
||||
DB::table('documents')->where('id', $document->id)->update(['issuer' => $data['issuer'] ?: $document->issuer,
|
||||
'issued_on' => $data['document_date'] ?: $document->issued_on, 'rights_status' => $data['rights_status'],
|
||||
'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]);
|
||||
$this->audit($request, 'resource_metadata_updated', 'portal_resources', $id);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.');
|
||||
}
|
||||
|
||||
public function moderate(Request $request, string $type, int $id): RedirectResponse
|
||||
@@ -184,9 +457,33 @@ class PortalServicesController extends Controller
|
||||
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
|
||||
DB::transaction(function () use ($request, $data, $table, $type, $id) {
|
||||
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
|
||||
if ($type === 'resources' && $data['published']) {
|
||||
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where('r.id', $id)->first(['r.document_type', 'r.legislation', 'r.source_confirmation', 'r.legal_status', 'r.rights_status',
|
||||
'r.source_url', 'd.status', 'd.data_classification', 'd.deleted_at', 'd.rights_status as file_rights_status',
|
||||
'd.malware_scan_status', 'd.malware_scanned_sha256', 'd.sha256']);
|
||||
abort_unless($resource && $resource->status === 'active' && $resource->data_classification === 'public' && $resource->deleted_at === null,
|
||||
422, 'O ficheiro continua privado/em quarentena e não pode ser publicado.');
|
||||
abort_unless($resource->rights_status === 'authorized' && $resource->file_rights_status === 'authorized',
|
||||
422, 'Confirme e registe os direitos de utilização antes de publicar.');
|
||||
abort_unless($resource->source_confirmation !== 'unconfirmed' && $resource->source_url,
|
||||
422, 'Identifique e confirme a fonte documental antes de publicar.');
|
||||
abort_unless($resource->malware_scan_status === 'clean'
|
||||
&& hash_equals(trim($resource->sha256), trim((string) $resource->malware_scanned_sha256)), 422,
|
||||
'A análise antimalware tem de terminar antes da publicação.');
|
||||
if ($resource->document_type === 'legislation' || (bool) $resource->legislation) {
|
||||
abort_unless($resource->source_confirmation === 'official_confirmed',
|
||||
422, 'Diplomas exigem confirmação da fonte oficial. Se a vigência não estiver esclarecida, publique-a como por confirmar.');
|
||||
}
|
||||
}
|
||||
DB::table($table)->where('id', $id)->update($type === 'resources'
|
||||
? ['published' => $data['published'], 'approved_by' => $request->user()->id, 'updated_at' => now()]
|
||||
? ['published' => $data['published'], 'approved_by' => $data['published'] ? $request->user()->id : null,
|
||||
'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'metadata_version' => DB::raw('metadata_version + 1'), 'updated_at' => now()]
|
||||
: ['status' => $data['published'] ? 'published' : 'withdrawn', 'reviewed_by' => $request->user()->id, 'updated_at' => now()]);
|
||||
if ($type === 'resources') {
|
||||
$this->recordResourceRevision($id, $data['published'] ? 'published' : 'unpublished', $request->user()->id,
|
||||
(int) DB::table('portal_resources')->where('id', $id)->value('metadata_version'));
|
||||
}
|
||||
$this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id);
|
||||
});
|
||||
|
||||
@@ -198,4 +495,133 @@ class PortalServicesController extends Controller
|
||||
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => $table,
|
||||
'actor_user_id' => $request->user()->id, 'action' => $action, 'after_data' => json_encode(['record_id' => $id], JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
|
||||
private function validateResource(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
|
||||
'description' => ['nullable', 'string', 'max:4000'], 'category' => ['required', 'string', 'max:80'],
|
||||
'topic' => ['required', 'string', 'max:80'], 'document_type' => ['required', 'in:legislation,procedure,official_form,digital_adaptation,guidance_template,guide,study,reference_data,unknown'],
|
||||
'issuer' => ['nullable', 'string', 'max:180'], 'document_number' => ['nullable', 'string', 'max:120'],
|
||||
'document_date' => ['nullable', 'date_format:Y-m-d'], 'source_url' => ['nullable', 'url:https', 'max:2000'],
|
||||
'applicable_from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'applicable_until' => ['nullable', 'date_format:Y-m-d'],
|
||||
'language' => ['required', 'string', 'max:12'], 'version_label' => ['nullable', 'string', 'max:120'],
|
||||
'document_group_id' => ['nullable', 'uuid'], 'territorial_scope' => ['required', 'in:national,provincial,district,locality,multiple,unknown'],
|
||||
'territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id'],
|
||||
'source_confirmation' => ['required', 'in:unconfirmed,identified,official_confirmed'],
|
||||
'legal_status' => ['required', 'in:not_applicable,in_force,historical,proposed,repealed,amended,unknown'],
|
||||
'rights_status' => ['required', 'in:unconfirmed,authorized,restricted'], 'independent_review' => ['required', 'boolean'],
|
||||
'data_classification' => ['nullable', 'in:internal,public'],
|
||||
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'],
|
||||
'associations' => ['nullable', 'array', 'max:40'], 'associations.*.target_type' => ['required', 'in:theme,module,activity,stage,procedure,entity,territory,document,investment_type,legal_source,requirement'],
|
||||
'associations.*.target_key' => ['required', 'string', 'max:180'],
|
||||
'associations.*.review_status' => ['nullable', 'in:pending,confirmed'],
|
||||
'associations.*.relationship' => ['required', 'in:applies_to,supports,version_of,translation_of,amends,repeals,implements,cites,related_to'],
|
||||
'associations.*.applicability' => ['required', 'in:required,conditional,support,optional_template,evidence'],
|
||||
'associations.*.condition_text' => ['nullable', 'string', 'max:2000'], 'associations.*.basis_reference' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
if (! empty($data['applicable_from']) && ! empty($data['applicable_until']) && $data['applicable_until'] < $data['applicable_from']) {
|
||||
throw ValidationException::withMessages(['applicable_until' => 'A data final deve ser igual ou posterior à data inicial.']);
|
||||
}
|
||||
foreach ($data['associations'] ?? [] as $index => $association) {
|
||||
if (($association['target_type'] ?? null) === 'requirement'
|
||||
&& ! array_key_exists($association['target_key'], config('document-requirements.catalogue', []))) {
|
||||
throw ValidationException::withMessages(["associations.$index.target_key" => 'Seleccione um identificador técnico de requisito existente.']);
|
||||
}
|
||||
if (mb_strlen(trim($association['basis_reference'] ?? '')) < 5) {
|
||||
throw ValidationException::withMessages(["associations.$index.basis_reference" => 'Indique o artigo, secção, página ou outro trecho que esta associação sustenta.']);
|
||||
}
|
||||
if ($association['applicability'] === 'conditional' && mb_strlen(trim($association['condition_text'] ?? '')) < 5) {
|
||||
throw ValidationException::withMessages(["associations.$index.condition_text" => 'Descreva a condição de aplicação.']);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function resourceFields(array $data, int $actor, ?string $identity, ?int $duplicate, bool $creating): array
|
||||
{
|
||||
unset($data['associations'], $data['issuer'], $data['data_classification']);
|
||||
$data['document_identity_key'] = $identity;
|
||||
$data['duplicate_of_resource_id'] = $duplicate;
|
||||
$data['last_reviewed_at'] = $data['source_confirmation'] !== 'unconfirmed' || $data['rights_status'] === 'authorized' ? now() : null;
|
||||
$data['last_reviewed_by'] = $actor;
|
||||
if ($creating) {
|
||||
$data['created_by'] = $actor;
|
||||
} else {
|
||||
unset($data['metadata_version']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function syncResourceLinks(int $resourceId, array $links): void
|
||||
{
|
||||
foreach ($links as $link) {
|
||||
if ($link['target_type'] === 'document') {
|
||||
abort_unless(DB::table('portal_resources')->where('id', $link['target_key'])->exists(), 422, 'A relação documental seleccionada não existe.');
|
||||
}
|
||||
DB::table('portal_resource_links')->insert([
|
||||
'resource_id' => $resourceId, 'target_type' => $link['target_type'], 'target_key' => $link['target_key'],
|
||||
'relationship' => $link['relationship'], 'applicability' => $link['applicability'],
|
||||
'condition_text' => $link['condition_text'] ?? null, 'basis_reference' => $link['basis_reference'] ?? null,
|
||||
'review_status' => $link['review_status'] ?? 'pending',
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertPublicClassificationEligible(array $data, object $document): void
|
||||
{
|
||||
if (($data['data_classification'] ?? $document->data_classification) !== 'public') {
|
||||
return;
|
||||
}
|
||||
|
||||
abort_unless($document->status === 'active' && $document->malware_scan_status === 'clean'
|
||||
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 422,
|
||||
'A análise antimalware e a integridade do ficheiro têm de estar confirmadas antes de o classificar como público.');
|
||||
abort_unless($data['rights_status'] === 'authorized', 422,
|
||||
'Registe a autorização de utilização antes de classificar o ficheiro como público.');
|
||||
abort_unless($data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url']), 422,
|
||||
'Identifique e confirme a fonte antes de classificar o ficheiro como público.');
|
||||
if (($data['legislation'] ?? false) || ($data['document_type'] ?? null) === 'legislation') {
|
||||
abort_unless($data['source_confirmation'] === 'official_confirmed', 422,
|
||||
'Diplomas exigem confirmação da fonte oficial antes de serem classificados como públicos.');
|
||||
}
|
||||
}
|
||||
|
||||
private function identityKey(array $data): ?string
|
||||
{
|
||||
if (empty($data['issuer']) || empty($data['document_number'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hash('sha256', mb_strtolower(implode('|', [trim($data['issuer']), trim($data['document_number']),
|
||||
$data['document_date'] ?? '', $data['language'], $data['version_label'] ?? ''])));
|
||||
}
|
||||
|
||||
private function recordResourceRevision(int $id, string $changeType, int $actor, int $revision): void
|
||||
{
|
||||
$resource = DB::table('portal_resources')->where('id', $id)->first();
|
||||
$links = DB::table('portal_resource_links')->where('resource_id', $id)->orderBy('id')->get();
|
||||
DB::table('portal_resource_revisions')->insert(['resource_id' => $id, 'revision_no' => $revision, 'change_type' => $changeType,
|
||||
'snapshot' => json_encode(['resource' => $resource, 'associations' => $links], JSON_THROW_ON_ERROR),
|
||||
'actor_user_id' => $actor, 'created_at' => now()]);
|
||||
}
|
||||
|
||||
private function territoryScopeIds(string $id): array
|
||||
{
|
||||
return collect(DB::select(<<<'SQL'
|
||||
WITH RECURSIVE territory_tree AS (
|
||||
SELECT id FROM geo_locations WHERE id = ? AND deleted_at IS NULL AND data_classification = 'public'
|
||||
UNION ALL
|
||||
SELECT child.id FROM geo_locations child
|
||||
INNER JOIN territory_tree parent ON child.parent_id = parent.id
|
||||
WHERE child.deleted_at IS NULL AND child.data_classification = 'public'
|
||||
)
|
||||
SELECT id FROM territory_tree
|
||||
SQL, [$id]))->pluck('id')->all() ?: [$id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Http\Resources\PublicBusinessOpportunityResource;
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Services\ExistingBusinessDrafts;
|
||||
use App\Services\InvestmentGeography;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -20,9 +21,22 @@ class PublicBusinessOpportunityController extends Controller
|
||||
'q' => ['nullable', 'string', 'max:120'],
|
||||
'sector' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'],
|
||||
'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
]);
|
||||
|
||||
$query = $this->publishedQuery();
|
||||
$path = app(InvestmentGeography::class)->selection($filters);
|
||||
if ($path !== []) {
|
||||
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
||||
$query->whereExists(fn ($locations) => $locations->selectRaw('1')->from('existing_business_locations as matching_location')
|
||||
->whereColumn('matching_location.existing_business_id', 'existing_businesses.id')->where('matching_location.is_public', true)
|
||||
->where(function ($match) use ($territoryIds): void {
|
||||
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id'] as $field) {
|
||||
$match->orWhereIn('matching_location.'.$field, $territoryIds);
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (! empty($filters['q'])) {
|
||||
$term = trim($filters['q']);
|
||||
$query->where(fn (Builder $builder) => $builder->whereLike('existing_businesses.public_title', '%'.$term.'%')
|
||||
@@ -38,7 +52,7 @@ class PublicBusinessOpportunityController extends Controller
|
||||
return $results->response();
|
||||
}
|
||||
|
||||
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters]);
|
||||
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters, 'territorySelection' => $path]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug): Response|JsonResponse
|
||||
@@ -49,16 +63,23 @@ class PublicBusinessOpportunityController extends Controller
|
||||
return $resource->response();
|
||||
}
|
||||
|
||||
return Inertia::render('investment/business-opportunity', ['record' => $resource]);
|
||||
$returnFilters = $request->query();
|
||||
|
||||
return Inertia::render('investment/business-opportunity', ['record' => $resource,
|
||||
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
|
||||
'interestUrl' => '/negocios/'.rawurlencode($slug).'/manifestar-interesse'.($returnFilters === [] ? '' : '?'.http_build_query($returnFilters))]);
|
||||
}
|
||||
|
||||
public function interest(Request $request, string $slug): Response
|
||||
{
|
||||
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
|
||||
|
||||
$returnFilters = $request->query();
|
||||
|
||||
return Inertia::render('investment/business-interest', [
|
||||
'record' => (new PublicBusinessOpportunityResource($record))->resolve($request),
|
||||
'modalities' => ExistingBusinessDrafts::MODALITIES,
|
||||
'returnUrl' => $returnFilters === [] ? '/negocios' : '/negocios?'.http_build_query($returnFilters),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,21 +5,28 @@ namespace App\Http\Controllers;
|
||||
use App\Services\InvestmentGeography;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PublicGeographyController extends Controller
|
||||
{
|
||||
public function options(Request $request, InvestmentGeography $geography): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'id' => ['nullable', 'uuid'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$query = $geography->publicReferences()->where('location_type', $data['type']);
|
||||
if (! empty($data['id'])) {
|
||||
$query->where('id', $data['id']);
|
||||
}
|
||||
if ($data['type'] !== 'province') {
|
||||
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']];
|
||||
$parent = $geography->publicReferences()->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->where('location_type', $parentType)->value('id');
|
||||
$query->where('parent_id', $parent ?? '00000000-0000-0000-0000-000000000000');
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$term = '%'.addcslashes($data['q'], '%_\\').'%';
|
||||
$query->where(fn ($q) => $q->whereRaw('name ILIKE ?', [$term])->orWhereRaw('code ILIKE ?', [$term])->orWhereRaw('source_code ILIKE ?', [$term]));
|
||||
$term = '%'.addcslashes(Str::ascii($data['q']), '%_\\').'%';
|
||||
$query->where(fn ($q) => $q
|
||||
->whereRaw("translate(lower(name), 'áàâãäåéèêëíìîïóòôõöúùûüçýÿ', 'aaaaaaeeeeiiiiooooouuuucyy') ILIKE ?", [$term])
|
||||
->orWhereRaw('code ILIKE ?', [$term])
|
||||
->orWhereRaw('source_code ILIKE ?', [$term]));
|
||||
}
|
||||
|
||||
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code']));
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\SimulationTaxParameterResolver;
|
||||
use App\Services\SimulationTaxRuleExpression;
|
||||
use App\Services\SimulationTaxRuleGovernance;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class SimulationTaxRuleGovernanceController extends Controller
|
||||
{
|
||||
public function index(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
|
||||
{
|
||||
$filters = $request->validate([
|
||||
'rule_code' => ['nullable', 'string', 'max:64'],
|
||||
'component' => ['nullable', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
|
||||
'scope_key' => ['nullable', 'string', 'max:160'],
|
||||
'status' => ['nullable', 'in:draft,submitted,reviewed,rejected,approved,published,suspended,archived'],
|
||||
'per_page' => ['nullable', 'integer', 'between:1,100'],
|
||||
]);
|
||||
|
||||
return response()->json($governance->listing($request->user(), $filters));
|
||||
}
|
||||
|
||||
public function store(Request $request, SimulationTaxRuleGovernance $governance): JsonResponse
|
||||
{
|
||||
$rule = $governance->create($request->user(), $this->validatedRule($request));
|
||||
|
||||
return response()->json($rule, 201)->header('Location', url('/api/v2/investment/admin/tax-rules/'.$rule->id));
|
||||
}
|
||||
|
||||
public function show(Request $request, SimulationTaxRuleGovernance $governance, string $id): JsonResponse
|
||||
{
|
||||
return response()->json($governance->show($request->user(), $id));
|
||||
}
|
||||
|
||||
public function update(Request $request, SimulationTaxRuleGovernance $governance, string $id): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
|
||||
|
||||
return response()->json($governance->update($request->user(), $id, (int) $data['version_no'], $this->validatedRule($request)));
|
||||
}
|
||||
|
||||
public function transition(Request $request, SimulationTaxRuleGovernance $governance, string $id, string $action): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'version_no' => ['required', 'integer', 'min:1'],
|
||||
'evidence' => ['required', 'string', 'min:20', 'max:2000'],
|
||||
]);
|
||||
|
||||
return response()->json($governance->transition($request->user(), $id, (int) $data['version_no'], $action, $data['evidence']));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function validatedRule(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'rule_code' => ['required', 'string', 'regex:/^[A-Z0-9][A-Z0-9._-]{2,63}$/'],
|
||||
'rule_version' => ['required', 'integer', 'min:1'],
|
||||
'component' => ['required', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
|
||||
'scope_key' => ['required', 'string', 'min:2', 'max:160'],
|
||||
'valid_from' => ['required', 'date_format:Y-m-d'],
|
||||
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
|
||||
'legal_source_id' => ['required', 'uuid', 'exists:investment_legal_sources,id'],
|
||||
'source_sha256' => ['required', 'regex:/^[a-f0-9]{64}$/'],
|
||||
'rule_definition' => ['required', 'array'],
|
||||
'parameters' => ['required', 'array'],
|
||||
'rounding_policy' => ['required', 'array'],
|
||||
]);
|
||||
foreach (['rule_definition', 'parameters', 'rounding_policy'] as $key) {
|
||||
$json = json_encode($data[$key], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
Validator::make(['json' => $json], ['json' => ['max:50000']])->validate();
|
||||
}
|
||||
|
||||
if (($data['rule_definition']['language'] ?? null) === 'investmoz.tax.v1') {
|
||||
try {
|
||||
app(SimulationTaxRuleExpression::class)->validate($data['rule_definition']);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages(['rule_definition' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
try {
|
||||
app(SimulationTaxParameterResolver::class)->validateParameters($data['parameters']);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages(['parameters' => $exception->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user