*/ public function organizations(): array { return DB::table('organizations')->whereIn(DB::raw('id::text'), DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->where('action', 'operator_organization_registered')->selectRaw("after_data->>'organization_id'"))->whereNull('deleted_at') ->get(['id', 'name', 'external_id', 'version_no'])->map(fn ($r) => $this->organization($r->id))->filter()->values()->all(); } /** * @return array|null */ public function organization(?string $id): ?array { if ($id === null) { return null; } $r = DB::table('organizations')->where('id', $id)->whereNull('deleted_at')->first(); if (! $r) { return null; } $event = DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->where('action', 'operator_organization_registered') ->where('after_data->organization_id', $id)->orderByDesc('occurred_at')->first(); if (! $event) { return null; } $data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR); if (($data['organization']['name'] ?? null) !== $r->name || ($data['organization']['nuit'] ?? null) !== $r->external_id) { return null; } return ['id' => $r->id, 'version' => $r->version_no, ...$data['organization'], 'source_reference' => $data['source_reference']]; } /** * @param array $input * @return array */ public function registerOrganization(User $actor, array $input): array { app(PortalProfileAdministration::class)->administrative($actor); $v = Validator::make($input, ['operation_key' => 'required|uuid', 'name' => 'required|string|max:200', 'nuit' => 'required|string|regex:/^[0-9]{9}$/', 'contact' => 'required|string|regex:/^\+258[0-9]{9}$/', 'director' => 'required|string|max:200', 'function' => 'required|in:portal_owner_operator', 'source_reference' => 'required|string|min:20|max:1000'])->validate(); return DB::transaction(function () use ($actor, $v) { DB::select('SELECT pg_advisory_xact_lock(101026,40001)'); $payload = array_intersect_key($v, array_flip(['name', 'nuit', 'contact', 'director', 'function'])); $existingEvent = DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->whereIn('action', ['operator_organization_registered', 'operator_organization_registration_recovered'])->where('after_data->operation_key', $v['operation_key'])->first(); if ($existingEvent) { $e = json_decode($existingEvent->after_data, true); abort_unless($e['organization'] == $payload && $e['source_reference'] === $v['source_reference'], 409, 'Esta chave corresponde a outro cadastro.'); return ['id' => $e['organization_id'], 'organization' => $this->organization($e['organization_id']), 'message' => 'Cadastro recuperado; não repetido.']; } $names = array_unique([$v['name'], preg_replace('/\s*,\s*/', ' ', $v['name'])]); $matches = DB::table('organizations')->where(fn ($q) => $q->where('external_id', $v['nuit'])->orWhereIn('name', $names)->orWhere('name', str_replace(' Lda', ', Lda', $v['name'])))->lockForUpdate()->get(); abort_if($matches->count() > 1, 409, 'Correspondência ambígua no cadastro mestre; não criar duplicado.'); $r = $matches->first(); if ($r) { abort_if($r->deleted_at !== null || ($r->external_id !== null && $r->external_id !== $v['nuit']), 409, 'Identidade ou estado da organização exige reconciliação.'); $known = $this->organization($r->id); if ($known) { abort_unless(array_intersect_key($known, $payload) == $payload, 409, 'Dados do cadastro já existente diferem; preservar a versão anterior.'); app(PortalProfileAccess::class)->audit($actor, 'operator_organization_registration_recovered', ['operation_key' => $v['operation_key'], 'organization_id' => $r->id, 'organization' => $payload, 'source_reference' => $v['source_reference']]); return ['id' => $r->id, 'organization' => $known, 'message' => 'Organização existente recuperada.']; } $id = $r->id; $before = ['name' => $r->name, 'external_id' => $r->external_id, 'source_system' => $r->source_system, 'version_no' => $r->version_no]; abort_unless(in_array($r->name, $names, true) || $r->name === str_replace(' Lda', ', Lda', $v['name']), 409, 'Denominação divergente exige reconciliação expressa.'); DB::table('organizations')->where('id', $id)->update(['name' => $v['name'], 'external_id' => $v['nuit'], 'updated_at' => now(), 'version_no' => $r->version_no + 1]); // Reconcile only the explicit comma alias; preserve previous values in audit. } else { $id = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $id, 'name' => $v['name'], 'external_id' => $v['nuit'], 'source_system' => 'portal-operator-master', 'organization_type' => 'private']); } app(PortalProfileAccess::class)->audit($actor, 'operator_organization_registered', ['operation_key' => $v['operation_key'], 'organization_id' => $id, 'organization' => $payload, 'previous_master_values' => $before ?? null, 'source_reference' => $v['source_reference'], 'metadata_storage' => 'existing_audit_snapshot', 'no_corporate_membership_created' => true]); return ['id' => $id, 'organization' => $this->organization($id), 'message' => 'Organização operadora registada no cadastro mestre. Nenhuma representação ou papel foi atribuído.']; }); } /** * @param array $input */ public function grant(User $actor, array $input): string { app(PortalProfileAdministration::class)->administrative($actor); $v = Validator::make($input, ['operation_key' => 'required|uuid', 'user_id' => 'required|integer|exists:users,id', 'role' => ['required', Rule::in(['promoter', 'validator', 'publisher'])], 'operator_organization_id' => 'required|uuid|exists:organizations,id', 'organization_id' => 'nullable|uuid|exists:organizations,id', 'opportunity_types' => 'required|array|min:1', 'opportunity_types.*' => ['required', Rule::in(OpportunityCanonicalContract::BASE_CATEGORIES)], 'confirmed_internal_designation' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000', 'responsibilities' => 'sometimes|array|max:0', 'expires_at' => 'nullable|date|after:now'])->validate(); abort_if((int) $v['user_id'] === $actor->id, 403, 'Não pode aprovar a sua atribuição.'); $subject = User::whereKey($v['user_id'])->firstOrFail(); abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret !== null && $subject->two_factor_confirmed_at !== null, 422, 'Confirmar contacto e o próprio MFA antes da atribuição interna.'); $organization = $this->organization($v['operator_organization_id']); abort_unless($organization !== null, 422, 'Selecione a organização operadora cadastrada; não exige comprovativos de Promotor externo.'); if ($v['role'] === 'promoter') { abort_unless(($v['organization_id'] ?? null) === $v['operator_organization_id'], 422, 'O vínculo interno é à organização operadora, não uma representação de gestão empresarial.'); } return DB::transaction(function () use ($actor, $v) { DB::select('SELECT pg_advisory_xact_lock(101026,40002)'); $subject = User::whereKey($v['user_id'])->lockForUpdate()->firstOrFail(); abort_unless($subject->hasVerifiedEmail() && $subject->two_factor_secret !== null && $subject->two_factor_confirmed_at !== null && $this->organization($v['operator_organization_id']) !== null, 422, 'Conta ou organização já não reúne as condições da atribuição.'); $e = DB::table('audit_events')->where('source_system', 'portal-profiles-v1')->where('action', 'role_granted')->where('after_data->operation_key', $v['operation_key'])->first(); $payload = array_intersect_key($v, array_flip(['user_id', 'role', 'operator_organization_id', 'organization_id', 'opportunity_types', 'reason', 'expires_at'])); if ($e) { $d = json_decode($e->after_data, true); abort_unless($d['designation'] == $payload, 409, 'Chave usada numa atribuição diferente.'); abort_unless(DB::table('portal_role_assignments')->where('id', $d['assignment_id'])->whereNull('revoked_at')->exists(), 409, 'A atribuição foi revogada.'); return $d['assignment_id']; } $duplicate = DB::table('portal_role_assignments')->where('user_id', $v['user_id'])->where('role', $v['role'])->where('source', 'internal-portal-designation')->whereNull('revoked_at')->where('starts_at', '<=', now())->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->exists(); abort_if($duplicate, 409, 'Já existe atribuição interna ativa: recupere-a antes de repetir ou alterar.'); $id = (string) Str::uuid(); app(PortalProfileAdministration::class)->insertGrant($id, $actor, [...$v, 'source' => 'internal-portal-designation']); app(PortalProfileAccess::class)->audit($actor, 'role_granted', ['assignment_id' => $id, 'subject_user_id' => $v['user_id'], 'role' => $v['role'], 'operation_key' => $v['operation_key'], 'designation' => $payload, 'operator_organization_id' => $v['operator_organization_id'], 'reason' => $v['reason'], 'authority' => 'portal-owner-internal-designation', 'external_promoter_documents_required' => false, 'no_corporate_membership_created' => true]); return $id; }); } }