*/ public function choices(User $actor): array { return $this->sources($actor)->orderBy('r.id')->limit(100)->get(['r.id', 'r.title', 'r.version_label', 'r.metadata_version', 'r.published', 'd.sha256'])->all(); } private function sources(User $actor): Builder { return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') ->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted') ->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted') ->whereIn('d.data_classification', ['internal', 'public']) ->when(! $actor->can('manage-portal'), function ($query) use ($actor) { $query->where(function ($scope) use ($actor) { $scope->where(fn ($public) => $public->where('r.published', true)->where('d.data_classification', 'public')->where('d.status', 'active')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')); if ($actor->hasVerifiedEmail()) { $scope->orWhere(fn ($own) => $own->where('r.created_by', $actor->id)->where('d.uploaded_by', $actor->id)); } $editorial = app(DocumentManagementAccess::class); if ($editorial->editorial($actor)) { $scope->orWhereIn('r.id', $editorial->editorialResources($actor)->select('r.id')); } }); }); } /** * @param array $input */ public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass { app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity); $data = Validator::make($input, ['resource_id' => 'required|integer|min:1', 'creation_key' => 'required|uuid', 'etag' => 'required|string|size:64', 'reason' => 'required|string|min:20|max:1000', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'])->validate(); return DB::transaction(function () use ($actor, $opportunity, $data) { $opportunity = Opportunity::whereKey($opportunity->id)->lockForUpdate()->firstOrFail(); app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity); $source = $this->sources($actor)->where('r.id', $data['resource_id'])->lockForUpdate()->first(['r.*', 'd.id as original_id', 'd.sha256', 'd.version_no']); abort_unless($source !== null, 403, 'O acesso actual à biblioteca não permite reutilizar este original.'); $existing = DB::table('opportunity_documents')->where('opportunity_id', $opportunity->id)->whereNull('deleted_at')->where('document_id', $source->original_id)->first(); if ($existing) { $payload = json_decode($existing->payload, true); abort_unless(($payload['library_source']['resource_id'] ?? null) === $source->id, 409, 'Original já associado por outro percurso; recuperar o suporte existente.'); abort_unless(($payload['section'] ?? null) === ($data['section'] ?? null) && ($payload['supported_statement'] ?? null) === ($data['supported_statement'] ?? null), 409, 'Original já associado com outra referência; editar explicitamente o suporte existente.'); return $existing; } abort_unless(hash_equals(app(OpportunityManagement::class)->etag($opportunity), $data['etag']), 409, 'A ficha mudou; preserve a selecção e recupere a versão actual.'); abort_if(DB::table('opportunity_documents')->where('id', $data['creation_key'])->exists(), 409, 'Chave já utilizada noutro suporte.'); abort_if(DB::table('opportunity_documents')->where('opportunity_id', $opportunity->id)->whereNull('deleted_at')->count() >= 100, 422, 'Limite de documentos atingido.'); $payload = ['title' => $source->title, 'category' => $source->category, 'issuer' => null, 'edition' => $source->version_label, 'classification' => 'internal', 'visibility' => 'internal', 'source_reference' => $source->source_reference, 'section' => $data['section'] ?? null, 'supported_statement' => $data['supported_statement'] ?? null, 'library_source' => ['resource_id' => $source->id, 'document_id' => $source->original_id, 'metadata_version' => $source->metadata_version, 'document_version' => $source->version_no, 'edition' => $source->version_label, 'sha256' => trim($source->sha256), 'source_reference' => $source->source_reference]]; $now = now(); DB::table('opportunity_documents')->insert(['id' => $data['creation_key'], 'opportunity_id' => $opportunity->id, 'document_id' => $source->original_id, 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'version' => 1, 'created_by' => $actor->id, 'created_at' => $now, 'updated_at' => $now]); $after = DB::table('opportunity_documents')->where('id', $data['creation_key'])->first(); DB::table('audit_events')->insert(['source_system' => 'opportunity-resources', 'entity_type' => 'documents', 'actor_user_id' => $actor->id, 'action' => 'library_original_reused', 'data_classification' => 'restricted', 'before_data' => json_encode(['snapshot' => Crypt::encryptString('null')]), 'after_data' => json_encode(['opportunity_id' => $opportunity->id, 'resource_id' => $after->id, 'version' => 1, 'reason' => Crypt::encryptString($data['reason']), 'snapshot' => Crypt::encryptString(json_encode($after, JSON_THROW_ON_ERROR))])]); $opportunity->management_version++; $opportunity->save(); return $after; }); } public function assertAvailable(\stdClass $resource, \stdClass $document): void { $payload = is_string($resource->payload) ? json_decode($resource->payload, true) : $resource->payload; $pin = $payload['library_source'] ?? null; if (! $pin) { return; } $source = DB::table('portal_resources')->where('id', $pin['resource_id'])->first(); abort_unless($source && $source->rights_status !== 'restricted' && $document->rights_status !== 'restricted' && in_array($document->data_classification, ['internal', 'public'], true) && $document->id === $pin['document_id'] && hash_equals($pin['sha256'], trim($document->sha256)), 403, 'O original está retirado, restrito ou já não corresponde à versão guardada.'); } /** * @return array */ public function snapshot(User $actor, Opportunity $opportunity): array { $access = app(PortalProfileAccess::class); if (! $access->canView($actor, $opportunity)) { return []; } return collect(app(OpportunityResources::class)->listing($opportunity->id, 'documents'))->filter(fn ($r) => ! $r->deleted_at && isset($r->payload['library_source']))->map(function ($r) use ($opportunity) { $pin = $r->payload['library_source']; return [...$pin, 'title' => $r->payload['title'], 'support_id' => $r->id, 'support_version' => $r->version, 'section' => $r->payload['section'] ?? null, 'opportunity_id' => $opportunity->id, 'download_url' => '/minhas-oportunidades/'.$opportunity->id.'/resources/documents/'.$r->id.'/download?source_sha256='.$pin['sha256']]; })->values()->all(); } }