@@ -6,7 +6,9 @@ use App\Models\InterestRequest;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Services\InvestorPath;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -29,12 +31,17 @@ class DashboardController extends Controller
|
||||
|
||||
$counts = InterestRequest::query()->selectRaw('status, count(*) as total')
|
||||
->groupBy('status')->pluck('total', 'status');
|
||||
$drafts = Opportunity::where('status', 'draft');
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
|
||||
}
|
||||
|
||||
return Inertia::render('dashboard', ['summary' => [
|
||||
'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
|
||||
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
|
||||
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]),
|
||||
'publishedOpportunities' => Opportunity::published()->count(),
|
||||
'draftOpportunities' => Opportunity::where('status', 'draft')->count(),
|
||||
'draftOpportunities' => $drafts->count(),
|
||||
'activeSectors' => Sector::where('is_active', true)->count(),
|
||||
'recentRequests' => InterestRequest::orderByDesc('id')->limit(5)
|
||||
->get(['id', 'reference', 'sector_interest', 'status', 'created_at']),
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\TerritoryGeometry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class InvestmentTerritoryController extends Controller
|
||||
@@ -14,9 +17,11 @@ class InvestmentTerritoryController extends Controller
|
||||
public function geometries(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['territory_id' => ['required', 'uuid']]);
|
||||
$management = $request->is('management/*');
|
||||
$profile = $request->is('minhas-oportunidades/*');
|
||||
$management = $request->is('management/*') || $profile;
|
||||
if ($management) {
|
||||
abort_unless($request->user()?->can('manage-portal'), 403);
|
||||
$access = app(PortalProfileAccess::class);
|
||||
abort_unless($profile ? ($access->grants($request->user()) !== [] || $access->superuser($request->user())) : $request->user()?->can('manage-portal'), 403);
|
||||
}
|
||||
|
||||
return response()->json(app(TerritoryGeometry::class)->context($data['territory_id'], $management));
|
||||
@@ -29,6 +34,9 @@ class InvestmentTerritoryController extends Controller
|
||||
$data = $request->validate(['kind' => ['required', 'in:'.implode(',', array_keys(self::TABLES))], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$column = $data['kind'] === 'opportunities' ? 'title' : 'name';
|
||||
$query = DB::table(self::TABLES[$data['kind']])->whereNull('deleted_at');
|
||||
if ($data['kind'] === 'opportunities' && Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$query->whereIn('id', app(PortalProfileAccess::class)->scope(Opportunity::query(), $request->user())->select('id'));
|
||||
}
|
||||
if (! empty($data['q'])) {
|
||||
$query->whereRaw($column.' ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
|
||||
}
|
||||
@@ -38,6 +46,10 @@ class InvestmentTerritoryController extends Controller
|
||||
|
||||
public function options(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->is('minhas-oportunidades/*')) {
|
||||
$access = app(PortalProfileAccess::class);
|
||||
abort_unless($access->grants($request->user()) !== [] || $access->superuser($request->user()), 403);
|
||||
}
|
||||
$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']]);
|
||||
$query = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $data['type'])
|
||||
->whereIn('data_classification', ['public', 'internal']);
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\LandAssistanceCase;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\InterestSubmission;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -257,7 +258,9 @@ class LandAssistanceController extends Controller
|
||||
'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.
|
||||
'size_bytes' => $file->getSize(),
|
||||
'uploaded_by' => $request->user()->id,
|
||||
// The configured scanner releases only the exact original hash after a clean result.
|
||||
'status' => 'quarantined',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
@@ -282,22 +285,20 @@ class LandAssistanceController extends Controller
|
||||
: back()->with('status', 'Documento privado anexado; a revisão ainda está pendente.');
|
||||
}
|
||||
|
||||
public function download(Request $request, string $id, string $attachment): BinaryFileResponse
|
||||
public function download(Request $request, string $id, string $attachment): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$this->owned($request, $id);
|
||||
$case = $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']);
|
||||
->first(['document.*']);
|
||||
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);
|
||||
$response = app(VerifiedDocumentBinary::class)->respond($document);
|
||||
DB::table('audit_events')->insert(['source_system' => 'land-assistance', 'entity_type' => 'documents', 'entity_id' => $document->id,
|
||||
'actor_user_id' => $request->user()->id, 'action' => 'document_downloaded', 'data_classification' => 'restricted',
|
||||
'after_data' => json_encode(['case_id' => $case->id, 'attachment_id' => $attachment], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return response()->download($path, basename($document->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function print(Request $request, string $id): Response
|
||||
|
||||
@@ -6,14 +6,20 @@ use App\Domain\Opportunities\Enums\OpportunityStatus;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Sector;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityManagementInstallation;
|
||||
use App\Services\OpportunityMaturity;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\OpportunityResources;
|
||||
use App\Services\OpportunityWorkflowTasks;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -38,16 +44,32 @@ class OpportunityManagementController extends Controller
|
||||
if (! $service->installed()) {
|
||||
return app(PortalManagementController::class)->catalog($request, 'opportunities');
|
||||
}
|
||||
$filters = $request->validate(['q' => 'nullable|string|max:150', 'status' => ['nullable', Rule::in([...array_column(OpportunityStatus::cases(), 'value'), 'returned', 'auto_validation_failed', 'technical_review', 'legal_territorial_review', 'approval_pending', 'publication_pending'])], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])], 'category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)], 'deleted' => 'nullable|boolean', 'page' => 'nullable|integer|min:1']);
|
||||
$filters = $request->validate(['queue' => 'nullable|in:review,publication,appeal,assigned', 'q' => 'nullable|string|max:150', 'status' => ['nullable', Rule::in([...array_column(OpportunityStatus::cases(), 'value'), 'returned', 'auto_validation_failed', 'technical_review', 'legal_territorial_review', 'approval_pending', 'publication_pending'])], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'geography_scope' => ['nullable', Rule::in(['unspecified', 'specific', 'multiple', 'national', 'digital'])], 'category' => ['nullable', Rule::in(OpportunityManagement::CATEGORIES)], 'deleted' => 'nullable|boolean', 'page' => 'nullable|integer|min:1']);
|
||||
$query = Opportunity::query();
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$query = $access->scope($query, $request->user());
|
||||
if (str_starts_with($request->path(), 'minhas-oportunidades')) {
|
||||
$query->whereNotNull('owner_user_id');
|
||||
}
|
||||
}
|
||||
if ($filters['deleted'] ?? false) {
|
||||
$query->onlyTrashed();
|
||||
$query->withoutGlobalScope(SoftDeletingScope::class)->whereNotNull('opportunities.deleted_at');
|
||||
}
|
||||
if ($filters['q'] ?? null) {
|
||||
$query->where(fn ($q) => $q->whereLike('title', '%'.$filters['q'].'%')->orWhereLike('reference', '%'.$filters['q'].'%'));
|
||||
$query->where(function ($q) use ($filters) {
|
||||
$q->whereLike('title', '%'.$filters['q'].'%')->orWhereLike('reference', '%'.$filters['q'].'%');
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$q->orWhere(fn ($draft) => $draft->whereNotNull('owner_user_id')->whereRaw("management_draft::jsonb->'canonical'->>'official_title' ILIKE ?", ['%'.$filters['q'].'%']));
|
||||
}
|
||||
});
|
||||
}
|
||||
if ($filters['status'] ?? null) {
|
||||
$query->where('status', $filters['status']);
|
||||
if (Schema::hasColumn('opportunities', 'workflow_context')) {
|
||||
$query->whereRaw("COALESCE(workflow_context->>'working_state',status)=?", [$filters['status']]);
|
||||
} else {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
}
|
||||
if ($filters['category'] ?? null) {
|
||||
$query->where('opportunity_category', $filters['category']);
|
||||
@@ -59,7 +81,32 @@ class OpportunityManagementController extends Controller
|
||||
$query->where('geography_scope', $filters['geography_scope']);
|
||||
}
|
||||
|
||||
return Inertia::render('management/opportunities', ['records' => $query->orderByDesc('id')->paginate(20)->withQueryString(), 'filters' => $filters, 'counts' => Opportunity::selectRaw('status,count(*) AS total')->groupBy('status')->pluck('total', 'status'), 'categories' => OpportunityManagement::CATEGORIES, 'geographyReviewCount' => Opportunity::where('geography_scope', 'unspecified')->count(), 'sectors' => Sector::orderBy('name')->get(['id', 'name'])]);
|
||||
if ($filters['queue'] ?? null) {
|
||||
$queue = $filters['queue'];
|
||||
if ($queue === 'review') {
|
||||
$query->whereRaw("COALESCE(workflow_context->>'working_state',status) IN ('submitted','in_review','information_pending')");
|
||||
} elseif ($queue === 'publication') {
|
||||
$query->whereRaw("COALESCE(workflow_context->>'working_state',status)='approved'");
|
||||
} elseif ($queue === 'appeal') {
|
||||
$query->whereRaw("workflow_context->'appeal'->>'pending'='true'");
|
||||
} else {
|
||||
$query->whereExists(fn ($q) => $q->selectRaw('1')->from('tasks')->where('tasks.source_system', 'opportunity-workflow')->where('tasks.assignee_user_id', $request->user()->id)->whereIn('tasks.status', ['open', 'in_progress'])->whereRaw("tasks.external_id LIKE 'portal-opportunity:' || opportunities.id::text || ':%'"));
|
||||
}
|
||||
}
|
||||
|
||||
$base = str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities';
|
||||
$stateSql = Schema::hasColumn('opportunities', 'workflow_context') ? "COALESCE(workflow_context->>'working_state',status)" : 'status';
|
||||
$counts = (clone $query)->selectRaw($stateSql.' AS working_status,count(*) AS total')->groupByRaw($stateSql)->pluck('total', 'working_status');
|
||||
$geographyReviewCount = (clone $query)->where('geography_scope', 'unspecified')->count();
|
||||
$records = $query->orderByDesc('id')->paginate(20)->withQueryString();
|
||||
$records->getCollection()->each(function (Opportunity $r) use ($access, $service, $request) {
|
||||
$r->setAttribute('can_edit', $access->canEdit($request->user(), $r));
|
||||
$r->setAttribute('working_status', $access->state($r));
|
||||
$r->setAttribute('publication', $r->publicationPresentation());
|
||||
$r->setAttribute('draft_title', $service->values($r)['canonical']['official_title'] ?? $r->title);
|
||||
});
|
||||
|
||||
return Inertia::render('management/opportunities', ['basePath' => $base, 'canCreate' => $base === '/management/opportunities' ? $access->legacyWriteAllowed($request->user()) : (bool) ($access->navigation($request->user())['contexts'] ?? []), 'records' => $records, 'filters' => $filters, 'counts' => $counts, 'categories' => OpportunityManagement::CATEGORIES, 'geographyReviewCount' => $geographyReviewCount, 'sectors' => Sector::orderBy('name')->get(['id', 'name'])]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, OpportunityManagement $service, ?int $id = null): Response
|
||||
@@ -68,10 +115,27 @@ class OpportunityManagementController extends Controller
|
||||
return app(PortalManagementController::class)->edit('opportunities', $id);
|
||||
}
|
||||
$record = $id ? Opportunity::withTrashed()->findOrFail($id) : null;
|
||||
$access = app(PortalProfileAccess::class);
|
||||
if (! $record && ! str_starts_with($request->path(), 'minhas-oportunidades')) {
|
||||
abort_unless($access->legacyWriteAllowed($request->user()), 403);
|
||||
}
|
||||
if ($record) {
|
||||
$access->authorize($request->user(), 'view', $record);
|
||||
}
|
||||
Inertia::share(['basePath' => str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities',
|
||||
'canonicalOptions' => ($record?->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades')) ? app(OpportunityCanonicalContract::class)->options($request->user(), $record) : null,
|
||||
'workflowState' => $record ? $access->state($record) : null,
|
||||
'publicationState' => $record?->publicationPresentation(),
|
||||
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
|
||||
'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []],
|
||||
'workflowTasks' => $record && $record->owner_user_id !== null ? app(OpportunityWorkflowTasks::class)->listing($record) : [],
|
||||
'profileMode' => $record ? $record->owner_user_id !== null : str_starts_with($request->path(), 'minhas-oportunidades'),
|
||||
'profileActions' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->actions($request->user(), $record) : [], 'profileContext' => $record?->workflow_context, 'profileOrganization' => $record?->organization_id]);
|
||||
$values = $record ? $service->values($record) : [];
|
||||
Inertia::share('publicationPreview', app(OpportunityCanonicalContract::class)->publicProjection($values['canonical'] ?? []));
|
||||
$classification = $id ? DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->value('after_data') : null;
|
||||
|
||||
return Inertia::render('management/opportunity-draft', ['resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view'), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'categories' => OpportunityManagement::CATEGORIES,
|
||||
return Inertia::render('management/opportunity-draft', ['resourcesInstalled' => app(OpportunityResources::class)->installed(), 'record' => $record, 'values' => $values, 'etag' => $record ? $service->etag($record) : null, 'readOnly' => str_ends_with($request->path(), '/view') || ($record && ! $access->canEdit($request->user(), $record)), 'closedVersion' => $record ? $service->closed($record) : false, 'classification' => $classification ? json_decode($classification, true)['classification'] : 'undetermined', 'sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'categories' => OpportunityManagement::CATEGORIES,
|
||||
'maturityCatalogue' => ['version' => OpportunityMaturity::VERSION, 'stages' => OpportunityMaturity::STAGES, 'legacyVersion' => OpportunityMaturity::LEGACY, 'legacyLabels' => OpportunityMaturity::LEGACY_LABELS],
|
||||
'territoryPath' => app(InvestmentGeography::class)->path($values['primary_territory_id'] ?? null, true),
|
||||
'history' => $id ? DB::table('audit_events')->where('source_system', 'opportunity-management')->where('after_data->record_id', $id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at']) : []]);
|
||||
|
||||
@@ -3,21 +3,22 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\CountryCatalogue;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\OpportunityImageProcessor;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityResourceInstallation;
|
||||
use App\Services\OpportunityResources;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class OpportunityResourceController extends Controller
|
||||
{
|
||||
@@ -40,9 +41,12 @@ class OpportunityResourceController extends Controller
|
||||
public function index(int $id, OpportunityResources $service): Response
|
||||
{
|
||||
$opportunity = Opportunity::withTrashed()->findOrFail($id);
|
||||
app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
|
||||
Inertia::share(['basePath' => str_starts_with(request()->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities', 'readOnly' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity)]);
|
||||
abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.');
|
||||
$resources = [];
|
||||
Inertia::share('imageProcessingAvailable', app(OpportunityImageProcessor::class)->available());
|
||||
Inertia::share('countries', app(CountryCatalogue::class)->all());
|
||||
foreach (OpportunityResources::KINDS as $kind) {
|
||||
$resources[$kind] = $service->listing($id, $kind);
|
||||
}
|
||||
@@ -54,7 +58,7 @@ class OpportunityResourceController extends Controller
|
||||
$image->derivativePreviews = [];
|
||||
foreach ($image->payload['derivatives'] ?? [] as $derived) {
|
||||
if (DB::table('documents')->where('id', $derived['document_id'])->where('status', 'active')->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists()) {
|
||||
$image->derivativePreviews[] = [...$derived, 'url' => URL::temporarySignedRoute('management.opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image->id, 'documentId' => $derived['document_id']])];
|
||||
$image->derivativePreviews[] = [...$derived, 'url' => URL::temporarySignedRoute(str_starts_with(request()->path(), 'minhas-oportunidades') ? 'promoter.opportunity-image-preview' : 'management.opportunity-image-preview', now()->addMinutes(10), ['id' => $id, 'resourceId' => $image->id, 'documentId' => $derived['document_id']])];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +91,7 @@ class OpportunityResourceController extends Controller
|
||||
return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']);
|
||||
}
|
||||
|
||||
public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response
|
||||
public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$o = Opportunity::findOrFail($id);
|
||||
abort_if($o->status === 'archived', 404);
|
||||
@@ -97,26 +101,20 @@ class OpportunityResourceController extends Controller
|
||||
abort_unless(in_array($documentId, array_column($metadata['derivatives'] ?? [], 'document_id'), true), 404);
|
||||
$doc = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first();
|
||||
abort_unless($doc && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && hash_equals(trim($doc->sha256), (string) $doc->malware_scanned_sha256), 423, 'Scan da derivada pendente.');
|
||||
$bytes = Crypt::decryptString(Storage::disk('local')->get($doc->storage_key));
|
||||
abort_unless(hash_equals(trim($doc->sha256), hash('sha256', $bytes)), 409, 'Integridade divergente.');
|
||||
|
||||
return response($bytes)->header('Content-Type', 'image/webp')->header('X-Content-Type-Options', 'nosniff')->header('Cache-Control', 'private,no-store');
|
||||
return app(VerifiedDocumentBinary::class)->respond($doc, true);
|
||||
}
|
||||
|
||||
public function download(int $id, string $kind, string $resourceId, OpportunityResources $service): \Illuminate\Http\Response
|
||||
public function download(int $id, string $kind, string $resourceId, OpportunityResources $service): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
|
||||
$opportunity = Opportunity::findOrFail($id);
|
||||
abort_if($opportunity->status === 'archived', 404);
|
||||
$record = DB::table($service->table($kind).' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $resourceId)->where('r.opportunity_id', $id)->whereNull('r.deleted_at')->whereNull('d.deleted_at')->first(['d.*']);
|
||||
abort_unless($record && $record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.');
|
||||
$disk = Storage::disk('local');
|
||||
$path = $disk->path($record->storage_key);
|
||||
abort_unless($disk->exists($record->storage_key), 409, 'Ficheiro indisponível.');
|
||||
$bytes = $record->storage_encryption === 'laravel' ? Crypt::decryptString($disk->get($record->storage_key)) : $disk->get($record->storage_key);
|
||||
abort_unless(hash_equals(trim($record->sha256), hash('sha256', $bytes)), 409, 'Integridade do ficheiro divergente.');
|
||||
abort_unless($record !== null, 404, 'Recurso indisponível ou excluído logicamente.');
|
||||
abort_unless($record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.');
|
||||
|
||||
return response($bytes)->header('Content-Type', $record->media_type)->header('Content-Disposition', HeaderUtils::makeDisposition('attachment', $record->file_name, 'document'))->header('X-Content-Type-Options', 'nosniff')->header('Cache-Control', 'no-store');
|
||||
return app(VerifiedDocumentBinary::class)->respond($record);
|
||||
}
|
||||
|
||||
/** @param array<string,list<\stdClass>> $resources
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Services\InvestmentRelations;
|
||||
use App\Services\PartnershipExchange;
|
||||
use App\Services\PartnershipMatching;
|
||||
use App\Services\PartnershipSources;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -317,7 +318,7 @@ class PartnershipExchangeController extends Controller
|
||||
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409);
|
||||
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'partnership-exchange', 'data_classification' => 'restricted',
|
||||
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored,
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'status' => 'quarantined']);
|
||||
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id]);
|
||||
DB::table('partnership_documents')->insert(['room_id' => $id, 'document_id' => $documentId, 'owner_id' => $request->user()->id, 'shared' => $data['shared']]);
|
||||
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_added.v1', ['room_id' => $id, 'document_id' => $documentId, 'shared' => $data['shared']], 'Documento privado anexado');
|
||||
});
|
||||
@@ -330,21 +331,17 @@ class PartnershipExchangeController extends Controller
|
||||
return $request->expectsJson() ? response()->json(['uploaded' => true]) : back()->with('success', 'Documento privado guardado; análise antimalware pendente.');
|
||||
}
|
||||
|
||||
public function download(Request $request, int $id, int $attachment): BinaryFileResponse
|
||||
public function download(Request $request, int $id, int $attachment): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$context = app(PartnershipExchange::class)->room($request->user(), $id);
|
||||
$document = DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('link.room_id', $id)->where('link.id', $attachment)
|
||||
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))->where('d.status', 'active')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.storage_key', 'd.file_name', 'd.sha256']);
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')->first(['d.*']);
|
||||
abort_unless($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), 404);
|
||||
$response = app(VerifiedDocumentBinary::class)->respond($document);
|
||||
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_accessed.v1', ['room_id' => $id, 'attachment_id' => $attachment], 'Documento consultado');
|
||||
|
||||
return response()->download($path, basename($document->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function share(Request $request, int $id, int $attachment): RedirectResponse|JsonResponse
|
||||
|
||||
@@ -230,6 +230,9 @@ class PortalManagementController extends Controller
|
||||
]);
|
||||
$query = $model::query();
|
||||
if ($resource === 'opportunities') {
|
||||
if (Schema::hasColumn('opportunities', 'owner_user_id')) {
|
||||
$query = app(\App\Services\PortalProfileAccess::class)->scope(Opportunity::query(), $request->user());
|
||||
}
|
||||
$query->with('primaryTerritory:id,name,location_type,source_system,source_code');
|
||||
if (isset($filters['geography_scope'])) {
|
||||
$query->where('geography_scope', $filters['geography_scope']);
|
||||
@@ -263,7 +266,7 @@ class PortalManagementController extends Controller
|
||||
'filters' => $filters,
|
||||
'sectors' => $resource === 'opportunities' ? Sector::orderBy('name')->get(['id', 'name']) : [],
|
||||
'geographyReviewCount' => $resource === 'opportunities'
|
||||
? Opportunity::where('geography_scope', 'unspecified')->count()
|
||||
? (clone $query)->where('geography_scope', 'unspecified')->count()
|
||||
: 0,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use App\Services\PortalProfileAdministration;
|
||||
use App\Services\PortalSuperusers;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class PortalProfileController extends Controller
|
||||
{
|
||||
public function requests(Request $request, PortalProfileAccess $access): Response
|
||||
{
|
||||
return Inertia::render('profiles/request', ['requests' => DB::table('portal_profile_requests')->where('user_id', $request->user()->id)->latest()->get(),
|
||||
'types' => OpportunityManagement::CATEGORIES,
|
||||
'organizations' => DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')->where('m.user_id', $request->user()->id)
|
||||
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->get(['o.id', 'o.name']),
|
||||
'documents' => DB::table('documents')->where('uploaded_by', $request->user()->id)->whereNull('deleted_at')->where('status', 'active')->where('malware_scan_status', 'clean')
|
||||
->whereColumn('sha256', 'malware_scanned_sha256')->get(['id', 'file_name']), 'grants' => $access->grants($request->user(), 'promoter')]);
|
||||
}
|
||||
|
||||
public function requestProfile(Request $request, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
return response()->json(['id' => $service->request($request->user(), $request->all()), 'message' => 'Pedido recebido; perfil ainda não ativado.'], 201);
|
||||
}
|
||||
|
||||
public function requestStatus(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$row = DB::table('portal_profile_requests')->where('id', $id)->where('user_id', $request->user()->id)->first();
|
||||
abort_unless($row !== null, 403);
|
||||
|
||||
return response()->json($row);
|
||||
}
|
||||
|
||||
public function administration(Request $request, PortalProfileAdministration $service): Response
|
||||
{
|
||||
$service->administrative($request->user());
|
||||
|
||||
return Inertia::render('profiles/administration', ['requests' => DB::table('portal_profile_requests')->latest()->get(),
|
||||
'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(),
|
||||
'actorId' => $request->user()->id, 'types' => OpportunityManagement::CATEGORIES,
|
||||
'organizations' => DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']),
|
||||
'securityRequests' => DB::table('portal_security_approvals')->latest()->limit(30)->get(),
|
||||
'securityAlerts' => DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->whereIn('action', ['superuser_pair_initialized', 'superuser_replaced', 'role_revoked'])->latest('occurred_at')->limit(20)->get(['id', 'action', 'actor_user_id', 'occurred_at'])]);
|
||||
}
|
||||
|
||||
public function decide(Request $request, string $id, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
$service->decide($request->user(), $id, $request->all());
|
||||
|
||||
return response()->json(['message' => 'Decisão registada; somente os tipos aprovados ficam autorizados.']);
|
||||
}
|
||||
|
||||
public function audit(Request $request, PortalProfileAccess $access): Response|JsonResponse
|
||||
{
|
||||
abort_unless($access->has($request->user(), 'auditor') || ($access->superuser($request->user()) && $access->mfa($request->user())), 403);
|
||||
$filters = $request->validate(['actor_id' => 'nullable|integer|min:1', 'action' => 'nullable|string|max:100', 'record_id' => 'nullable|integer|min:1', 'from' => 'nullable|date', 'until' => 'nullable|date|after_or_equal:from', 'format' => 'nullable|in:json']);
|
||||
$query = DB::table('audit_events')->when($filters['actor_id'] ?? null, fn ($q, $v) => $q->where('actor_user_id', $v))
|
||||
->when($filters['action'] ?? null, fn ($q, $v) => $q->where('action', $v))
|
||||
->when($filters['record_id'] ?? null, fn ($q, $v) => $q->where('after_data->record_id', $v))
|
||||
->when($filters['from'] ?? null, fn ($q, $v) => $q->where('occurred_at', '>=', $v))
|
||||
->when($filters['until'] ?? null, fn ($q, $v) => $q->where('occurred_at', '<=', $v));
|
||||
// Minimized metadata: never decrypt private snapshots or export authentication bodies.
|
||||
$query->select(['id', 'source_system', 'action', 'actor_user_id', 'entity_type', 'occurred_at', 'correlation_id']);
|
||||
if (($filters['format'] ?? null) === 'json') {
|
||||
$records = $query->orderByDesc('occurred_at')->limit(1000)->get();
|
||||
$access->audit($request->user(), 'audit_metadata_exported', ['filters' => $filters, 'count' => $records->count()]);
|
||||
|
||||
return response()->json(['filters' => $filters, 'records' => $records, 'limit' => 1000])->header('Content-Disposition', 'attachment; filename="audit-metadata.json"')->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
$records = $query->orderByDesc('occurred_at')->paginate(30)->withQueryString();
|
||||
$access->audit($request->user(), 'audit_metadata_read', ['filters' => $filters, 'count' => $records->count()]);
|
||||
|
||||
return Inertia::render('profiles/audit', ['records' => $records, 'filters' => $filters]);
|
||||
}
|
||||
|
||||
public function grant(Request $request, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
return response()->json(['id' => $service->grant($request->user(), $request->all())], 201);
|
||||
}
|
||||
|
||||
public function requestDocument(Request $request, string $id, string $documentId, PortalProfileAdministration $service): \Illuminate\Http\Response|BinaryFileResponse
|
||||
{
|
||||
$service->administrative($request->user());
|
||||
$row = DB::table('portal_profile_requests')->where('id', $id)->first();
|
||||
abort_unless($row && in_array($documentId, json_decode($row->document_ids, true), true), 404);
|
||||
$document = DB::table('documents')->where('id', $documentId)->first();
|
||||
abort_unless($document !== null, 404);
|
||||
|
||||
return app(VerifiedDocumentBinary::class)->respond($document, true);
|
||||
}
|
||||
|
||||
public function revoke(Request $request, string $id, PortalProfileAdministration $service): JsonResponse
|
||||
{
|
||||
$service->revoke($request->user(), $id, (string) $request->input('reason'));
|
||||
|
||||
return response()->json(['message' => 'Papel revogado; sessões e tokens anteriores invalidados.']);
|
||||
}
|
||||
|
||||
public function opportunities(Request $request, PortalProfileAccess $access): Response
|
||||
{
|
||||
abort_unless($access->grants($request->user()) !== [] || $access->superuser($request->user()), 403);
|
||||
|
||||
return app(OpportunityManagementController::class)->index($request, app(OpportunityManagement::class));
|
||||
}
|
||||
|
||||
public function edit(Request $request, PortalProfileAccess $access, ?int $id = null): Response
|
||||
{
|
||||
if ($id) {
|
||||
$access->authorize($request->user(), str_ends_with($request->path(), '/view') ? 'view' : 'edit', Opportunity::withTrashed()->findOrFail($id));
|
||||
} else {
|
||||
abort_unless($access->navigation($request->user())['contexts'] ?? [], 403);
|
||||
}
|
||||
|
||||
return app(OpportunityManagementController::class)->edit($request, app(OpportunityManagement::class), $id);
|
||||
}
|
||||
|
||||
public function workflow(Request $request, int $id, OpportunityProfileWorkflow $service): JsonResponse
|
||||
{
|
||||
$record = $service->transition($request->user(), $id, $request->all());
|
||||
|
||||
return response()->json(['id' => $id, 'status' => app(PortalProfileAccess::class)->state($record), 'publication_status' => $record->status, 'etag' => app(OpportunityManagement::class)->etag($record), 'message' => 'Ação interna registada; não representa decisão externa.']);
|
||||
}
|
||||
|
||||
public function replacement(Request $request, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['old_user_id' => 'required|integer', 'new_user_id' => 'required|integer', 'reason' => 'required|string|min:20|max:1000']);
|
||||
|
||||
return response()->json(['id' => $service->requestReplacement($request->user(), $data['old_user_id'], $data['new_user_id'], $data['reason'])], 201);
|
||||
}
|
||||
|
||||
public function approveSecurity(Request $request, string $id, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$service->approve($request->user(), $id);
|
||||
|
||||
return response()->json(['message' => 'Aprovação independente registada.']);
|
||||
}
|
||||
|
||||
public function replace(Request $request, string $id, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$service->applyReplacement($request->user(), $id);
|
||||
|
||||
return response()->json(['message' => 'Titular substituído atomicamente; continuam dois titulares.']);
|
||||
}
|
||||
|
||||
public function elevation(Request $request, PortalSuperusers $service): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['action' => 'required|string', 'record_id' => 'nullable|integer|exists:opportunities,id', 'reason' => 'required|string|min:20|max:1000']);
|
||||
|
||||
return response()->json(['id' => $service->requestElevation($request->user(), $data['action'], $data['record_id'] ?? null, $data['reason'])], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\PortalProfileInstallation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PortalProfileInstallationController extends Controller
|
||||
{
|
||||
public function preview(Request $request, PortalProfileInstallation $installation): JsonResponse|Response
|
||||
{
|
||||
$plan = $installation->preview($request->user());
|
||||
|
||||
return $request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/installation', $plan);
|
||||
}
|
||||
|
||||
public function apply(Request $request, PortalProfileInstallation $installation): JsonResponse
|
||||
{
|
||||
return response()->json($installation->apply($request->user(), $request->all()));
|
||||
}
|
||||
|
||||
public function bootstrap(Request $request, PortalProfileInstallation $installation): JsonResponse
|
||||
{
|
||||
return response()->json($installation->bootstrap($request->user(), $request->all()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user