trashed() || $record->owner_user_id === null || ! $access->canView($actor, $record) || $access->has($actor, 'auditor') || in_array($record->status, ['archived', 'rejected'], true)) { return false; } // Never overwrite a normal approved publication or its workflow decisions. if ($record->published_at && data_get($record->workflow_context, 'publication.purpose') !== self::PURPOSE) { return false; } $internal = $access->internalAssignment($actor, $record->opportunity_category); return ($internal && $access->promoterRecordScope($actor, $record) && $access->operational($actor, 'edit', $record->id)) || ($access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'publish', $record->id)); } private function authorize(User $actor, Opportunity $record): void { abort_unless($this->allowed($actor, $record), 403, 'A designação e o âmbito existentes não autorizam esta divulgação.'); if (! app(PortalProfileAccess::class)->mfa($actor)) { throw new HttpResponseException(response()->json(['message' => 'Confirme a entrada normal com MFA recente para divulgar.'], 403)->header('X-Portal-Reauthentication', 'required')); } } /** * @return array */ public function editor(User $actor, Opportunity $record): array { abort_unless($this->allowed($actor, $record), 403); $saved = data_get($record->workflow_context, 'promotional_summary', []); // Only previously public text is recovered automatically. No private contact/document is copied. $existing = data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE; $form = $saved['input'] ?? ['title' => $existing ? $record->title : '', 'summary' => $existing ? $record->summary : '', 'description' => $existing ? $record->description : '', 'source_reference' => '', 'edition' => '', 'source_date' => '', 'limitations' => '', 'territory_ids' => $existing ? DB::table('opportunity_locations')->where('opportunity_id', $record->id)->pluck('geo_location_id')->all() : [], 'sector_id' => $record->sector_id]; return ['record' => ['id' => $record->id, 'reference' => $record->reference], 'etag' => app(OpportunityManagement::class)->etag($record), 'proactiveRelations' => app(ProactiveInvestment::class)->publicationRelations($record), 'proactiveKind' => app(OpportunityManagement::class)->values($record)['proactive']['kind'] ?? null, 'proactiveEnabled' => isset(app(OpportunityManagement::class)->values($record)['proactive']), 'proactiveEvidence' => array_values(array_filter(app(OpportunityManagement::class)->values($record)['proactive']['evidence'] ?? [], fn ($e) => $e['access'] === 'public')), 'proactiveOptions' => app(ProactiveInvestment::class)->options(), 'initial' => [...$form, ...($saved['input']['proactive_public'] ?? null ? ['proactive_public' => $saved['input']['proactive_public']] : [])], 'publicNow' => $existing && $record->status === 'published', 'reauthenticationRequired' => ! app(PortalProfileAccess::class)->mfa($actor), 'sectors' => Sector::catalog()->orderBy('name')->get(['id', 'name']), 'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type']), 'history' => DB::table('audit_events')->where('source_system', 'tempo-preparation-publication') ->where('after_data->record_id', $record->id)->orderByDesc('occurred_at')->get(['id', 'action', 'actor_user_id', 'occurred_at'])]; } /** * @param array $input * @return array */ public function preview(User $actor, Opportunity $record, array $input): array { $this->authorize($actor, $record); $data = Validator::make($input, ['etag' => 'required|string|size:64', 'action' => 'required|in:disclose,withdraw', 'title' => 'required_if:action,disclose|nullable|string|max:255', 'summary' => 'required_if:action,disclose|nullable|string|max:499', 'description' => 'nullable|string|max:6000', 'limitations' => 'nullable|string|max:2500', 'source_reference' => 'nullable|string|max:2000', 'edition' => 'nullable|string|max:150', 'source_date' => 'nullable|date_format:Y-m-d', 'proactive_public' => 'sometimes|array', 'proactive_reviewed' => 'sometimes|boolean', 'sector_id' => ['nullable', 'integer', Rule::in(Sector::catalog()->pluck('id')->all())], 'territory_ids' => 'sometimes|array|max:30', 'territory_ids.*' => 'required|uuid|distinct', ])->validate(); abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409, 'A ficha mudou. Reabra antes de preparar outra prévia.'); if ($data['action'] === 'withdraw') { abort_unless(data_get($record->workflow_context, 'publication.purpose') === self::PURPOSE, 409); } $paths = []; foreach ($data['territory_ids'] ?? [] as $id) { $path = app(InvestmentGeography::class)->path($id, true); abort_unless(count($path) > 0 && collect($path)->contains('id', $id), 422, 'Seleccione apenas territórios públicos existentes.'); $paths[] = $path; } $text = fn ($key) => trim(strip_tags($data[$key] ?? '')); $body = implode("\n\n", array_filter([$text('description'), self::NOTICE, 'Limitações e lacunas: '.($text('limitations') ?: 'Informação ainda por completar.'), 'Fonte: '.($text('source_reference') ?: 'Não indicada.'), 'Edição: '.($text('edition') ?: 'Não indicada.'), 'Data-base: '.($data['source_date'] ?? 'Não indicada.')])); $body = OpportunityPublicText::description($record, $body); $projection = ['title' => $text('title'), 'summary' => $text('summary'), 'description' => $body, 'sector_id' => $data['sector_id'] ?? null, 'maturity_label' => 'Em preparação', 'notice' => self::NOTICE, 'territories' => array_map(fn ($p) => ['id' => end($p)->id, 'label' => implode(' / ', array_column($p, 'name'))], $paths)]; if ($data['action'] === 'disclose' && ($proactive = app(ProactiveInvestment::class)->publication($record, $data))) { $projection['proactive'] = $proactive; $projection['description'] .= "\n\n".implode("\n", [$proactive['group'] === 'ideas' ? 'Ideia em preparação' : ($proactive['group'] === 'needs' ? 'Necessidade — '.$proactive['state'] : 'O que existe'), 'Necessidade: '.$proactive['need'], 'Proposta: '.$proactive['proposal'], 'Fundamento: '.$proactive['basis'], 'Limitações: '.$proactive['limitations'], 'Próximo passo: '.$proactive['next_action'], ProactiveInvestment::NOTICE]); } if ($data['action'] === 'disclose') { abort_unless($projection['title'] !== '' && $projection['summary'] !== '', 422, 'Indique título e resumo em texto simples.'); } if ($data['action'] === 'withdraw') { $projection = [...$projection, ...$record->only(['title', 'summary', 'description', 'sector_id', 'maturity_label'])]; } $payload = ['actor_id' => $actor->id, 'record_id' => $record->id, 'etag' => $data['etag'], 'action' => $data['action'], 'input' => $data, 'projection' => $projection, 'expires' => now()->addMinutes(15)->timestamp]; return ['action' => $data['action'], 'projection' => $projection, 'token' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)), 'current' => $record->only(['title', 'summary', 'description', 'status']), 'writes' => false]; } /** * @param array $input * @return array */ public function confirm(User $actor, int $id, array $input): array { $data = Validator::make($input, ['token' => 'required|string|max:300000', 'operation_key' => 'required|uuid', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:10|max:1000'])->validate(); try { $preview = json_decode(Crypt::decryptString($data['token']), true, flags: JSON_THROW_ON_ERROR); } catch (\Throwable) { abort(422, 'Prévia inválida. Prepare outra prévia.'); } abort_unless(($preview['actor_id'] ?? null) === $actor->id && ($preview['record_id'] ?? null) === $id, 403); return DB::transaction(function () use ($actor, $id, $data, $preview) { DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['promotional-operation:'.$data['operation_key']]); $record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail(); $this->authorize($actor, $record); $digest = hash('sha256', $data['token'].'|'.$data['reason']); $replay = DB::table('audit_events')->where('source_system', 'tempo-preparation-publication') ->where('after_data->operation_key', $data['operation_key'])->first(); if ($replay) { $prior = json_decode($replay->after_data, true); abort_unless($prior['record_id'] === $id && (int) $replay->actor_user_id === $actor->id && hash_equals($prior['request_hash'], $digest), 409, 'Chave usada noutra operação.'); return ['id' => $id, 'replayed' => true, 'message' => 'Operação já registada; nenhuma alteração repetida.']; } abort_unless(($preview['expires'] ?? 0) >= now()->timestamp, 409, 'A prévia expirou. Prepare outra prévia.'); abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $preview['etag']), 409, 'Alteração concorrente: a ficha foi preservada. Reabra e reveja a nova versão.'); $before = $record->getRawOriginal(); $oldLocations = DB::table('opportunity_locations')->where('opportunity_id', $id)->pluck('geo_location_id')->all(); $context = $record->workflow_context ?? []; if ($preview['action'] === 'withdraw') { $record->status = 'suspended'; $record->published_at = null; $context['publication']['visibility'] = 'withdrawn'; $action = 'promotional_summary_withdrawn'; } else { $p = $preview['projection']; if ($p['sector_id'] !== null) { abort_unless(Sector::catalog()->whereKey($p['sector_id'])->exists(), 409, 'O sector deixou de estar disponível no catálogo. Prepare outra prévia.'); } $paths = []; foreach ($preview['input']['territory_ids'] ?? [] as $territory) { $path = app(InvestmentGeography::class)->path($territory, true); abort_unless(count($path) && collect($path)->contains('id', $territory), 409, 'Um território deixou de estar disponível. Prepare outra prévia.'); $paths[] = $path; } $currentTerritories = array_map(fn ($path) => ['id' => end($path)->id, 'label' => implode(' / ', array_column($path, 'name'))], $paths); abort_unless($currentTerritories === $p['territories'], 409, 'O enquadramento territorial mudou. Prepare outra prévia.'); // Preserve the original private draft bytes; retain its former base // columns in the existing hidden context before projecting public text. $context['promotional_private_base'] ??= $record->only(OpportunityManagement::FIELDS); $context['working_state'] ??= app(PortalProfileAccess::class)->state($record); $context['promotional_summary'] = ['input' => $preview['input'], 'projection' => $p]; $context['publication'] = ['visibility' => 'public', 'purpose' => self::PURPOSE, 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'execution_approved' => false, 'private_draft_preserved' => true]; $action = data_get($before, 'status') === 'published' ? 'promotional_summary_updated' : 'promotional_summary_published'; foreach (['title', 'summary', 'description', 'sector_id', 'maturity_label'] as $field) { $record->{$field} = $p[$field]; } foreach (['promoter_name', 'promoter_type', 'financing_modality', 'expected_impact', 'maturity_level', 'estimated_investment_amount', 'estimated_investment_currency'] as $field) { $record->{$field} = null; } $main = $paths[0] ?? []; $record->primary_territory_id = $main ? end($main)->id : null; $record->province = collect($main)->firstWhere('location_type', 'province')?->name; $record->district = collect($main)->firstWhere('location_type', 'district')?->name; $record->geography_scope = count($paths) > 1 ? 'multiple' : ($main ? 'specific' : 'unspecified'); DB::table('opportunity_locations')->where('opportunity_id', $id)->delete(); foreach ($preview['input']['territory_ids'] ?? [] as $territory) { DB::table('opportunity_locations')->insert(['opportunity_id' => $id, 'geo_location_id' => $territory]); } $record->status = 'published'; $record->is_featured = false; $record->last_validated_at = null; $record->published_at = now(); } $record->workflow_context = $context; $record->management_version++; $record->save(); DB::table('audit_events')->insert(['source_system' => 'tempo-preparation-publication', 'entity_type' => 'opportunity', 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(), 'before_data' => json_encode(['record_id' => $id, 'record' => $before, 'territories' => $oldLocations], JSON_THROW_ON_ERROR), 'after_data' => json_encode(['record_id' => $id, 'operation_key' => $data['operation_key'], 'request_hash' => $digest, 'reason' => $data['reason'], 'projection' => $preview['projection'], 'execution_approved' => false, 'record' => $record->getRawOriginal()], JSON_THROW_ON_ERROR)]); return ['id' => $id, 'replayed' => false, 'message' => $preview['action'] === 'withdraw' ? 'Resumo retirado; cadastro e histórico preservados.' : 'Resumo divulgado como Em preparação. Não autoriza execução do investimento.']; }); } }