$context * @return array */ public function afterApproval(array $context): array { $days = config('private_offers.validity_days'); $reference = config('private_offers.validity_policy_reference'); if ($days !== null && (! is_int($days) || $days < 1 || $days > 36500 || ! is_string($reference) || trim($reference) === '')) { throw ValidationException::withMessages(['validity' => 'Configuração de validade incompleta. Não foi aplicado um prazo presumido.']); } $operation = $context['offer_operation'] ?? ['status' => 'available', 'cycle' => 0]; $hash = $context['approval']['hash']; $policy = ['days' => $days, 'policy_reference' => $reference, 'approval_hash' => $hash]; $operation['validity_policies'][$hash] ??= $policy; $operation['validity'] ??= $operation['validity_policies'][$hash]; $operation['last_approval_hash'] = $hash; return $operation; } private function offer(int $id, bool $lock = false): Opportunity { $record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id); abort_unless($record->isPrivateOfferPreparation(), 404); return $record; } public function publisher(User $actor, Opportunity $record): bool { $access = app(PortalProfileAccess::class); return $access->canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record) && $access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'publish', $record->id); } /** @return Builder */ public function staffQuery(User $actor): Builder { $access = app(PortalProfileAccess::class); abort_unless($access->mfa($actor) && ! $access->has($actor, 'auditor'), 403); $grants = [...$access->grants($actor, 'validator'), ...$access->grants($actor, 'publisher')]; abort_unless($grants !== [], 403); return $access->scope(Opportunity::query(), $actor)->whereNotNull('workflow_context->private_offer_preparation') ->where('owner_user_id', '!=', $actor->id)->whereRaw("NOT COALESCE(workflow_context->'contributors','[]'::jsonb) @> ?::jsonb", [json_encode([$actor->id])]) ->where(fn ($q) => $q->whereNull('organization_id')->orWhereNotIn('organization_id', DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->select('organization_id'))) ->where(function ($q) use ($grants) { foreach ($grants as $grant) { $q->orWhere(function ($scope) use ($grant) { $scope->whereIn('opportunity_category', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR)); if ($grant->organization_id !== null) { $scope->where('organization_id', $grant->organization_id); } }); } }); } /** @return array */ public function presentation(User $actor, int $id): array { $record = app(PrivateOfferPreparation::class)->record($actor, $id); $reviewer = app(PrivateOfferConfidentiality::class)->internal($actor, $record); $publisher = $this->publisher($actor, $record); $reports = []; if ($reviewer) { foreach ($record->workflow_context['offer_reports'] ?? [] as $report) { $reports[] = [...$report, 'reason' => Crypt::decryptString($report['reason']), 'resolution_reason' => isset($report['resolution_reason']) ? Crypt::decryptString($report['resolution_reason']) : null, 'source_snapshot' => isset($report['source_snapshot']) ? json_decode(Crypt::decryptString($report['source_snapshot']), true, 512, JSON_THROW_ON_ERROR) : null]; } } $highlight = $record->workflow_context['offer_highlight'] ?? null; return ['id' => $id, 'etag' => app(OpportunityManagement::class)->etag($record), 'availability' => app(OfferAvailability::class)->record($record), 'cycle' => $record->workflow_context['offer_operation']['cycle'] ?? 0, 'owner' => app(PrivateOfferConfidentiality::class)->seller($actor, $record), 'reviewer' => $reviewer, 'publisher' => $publisher, 'editorial_state' => app(PortalProfileAccess::class)->state($record), 'reports' => $reports, 'highlight' => $publisher && $highlight ? [...$highlight, 'note' => Crypt::decryptString($highlight['note'])] : null, 'commercial_activation' => false, 'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'cycle'])), $record->workflow_context['offer_operation_history'] ?? [])]; } /** @param array $context * @param array $metadata */ private function save(Opportunity $record, ?User $actor, array $context, string $action, array $metadata = []): void { $event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor?->id, 'at' => now()->toIso8601String(), 'cycle' => $context['offer_operation']['cycle'] ?? 0, ...$metadata]; $context['offer_operation_history'][] = $event; $record->workflow_context = $context; $record->management_version++; $record->save(); DB::table('audit_events')->insert(['source_system' => 'private-offers', 'entity_type' => 'opportunity', 'actor_user_id' => $actor?->id, 'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(), 'after_data' => json_encode(['record_id' => $record->id, 'record_version' => $record->management_version, ...$event], JSON_THROW_ON_ERROR)]); } /** @param array $context * @return array */ private function withdraw(array $context, string $origin): array { $context['offer_operation'] = [...($context['offer_operation'] ?? ['cycle' => 0]), 'status' => 'withdrawn', 'withdrawn_at' => now()->toIso8601String(), 'origin' => $origin]; return $context; } /** @param array $input */ public function ownerAction(User $actor, int $id, array $input): Opportunity { $data = validator($input, ['action' => 'required|in:withdraw,request_reactivation,reactivate', 'confirm' => 'required|accepted', 'etag' => 'required_unless:action,withdraw|string|size:64', 'reason' => 'nullable|string|max:1000'])->validate(); return DB::transaction(function () use ($actor, $id, $data) { $record = $this->offer($id, true); abort_unless(app(PrivateOfferConfidentiality::class)->seller($actor, $record), 403); $context = $record->workflow_context; $state = app(OfferAvailability::class)->record($record)['state']; if ($data['action'] === 'withdraw') { if (($context['offer_operation']['status'] ?? null) === 'withdrawn') { return $record; } $this->save($record, $actor, $this->withdraw($context, 'owner'), 'offer_withdrawn', ['reason' => Crypt::encryptString($data['reason'] ?? '')]); return $record; } abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409); if ($data['action'] === 'request_reactivation') { abort_unless(in_array($state, ['withdrawn', 'expired'], true), 409, 'A oferta não está retirada ou expirada.'); $editorialState = app(PortalProfileAccess::class)->state($record); abort_unless(isset($context['submission']) || in_array($editorialState, ['draft', 'needs_information'], true), 409, 'A preparação anterior ainda precisa da vinculação editorial existente antes de abrir nova revisão.'); $context['offer_operation'] = [...($context['offer_operation'] ?? []), 'status' => 'revalidation', 'cycle' => ($context['offer_operation']['cycle'] ?? 0) + 1, 'requested_by' => $actor->id, 'requested_at' => now()->toIso8601String()]; // Reopen the same editorial engine, retaining old submissions and decisions. $record->workflow_context = $context; $record->save(); if (isset($context['submission'])) { $record = app(OpportunityProfileWorkflow::class)->transition($actor, $id, ['etag' => app(OpportunityManagement::class)->etag($record), 'action' => 'request_revision', 'reason' => 'Pedido explícito de nova validação para reactivar a oferta. '.($data['reason'] ?? '')]); } $this->save($record, $actor, $record->workflow_context, 'offer_reactivation_requested'); return $record; } abort_unless($state === 'revalidation' && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'É necessária nova validação editorial.'); $snapshot = app(PrivateOfferPreparation::class)->snapshot($record); abort_unless(($snapshot['payload']['operational_cycle'] ?? null) === $context['offer_operation']['cycle'] && hash_equals($context['approval']['hash'] ?? '', $snapshot['hash']) && ($context['submission']['payload']['operational_cycle'] ?? null) === $context['offer_operation']['cycle'], 409); $context['offer_operation']['status'] = 'available'; $context['offer_operation']['activated_hash'] = $snapshot['hash']; $this->save($record, $actor, $context, 'offer_reactivated_privately'); return $record; }); } public function expire(int $id): bool { return DB::transaction(function () use ($id) { $record = $this->offer($id, true); $availability = app(OfferAvailability::class)->record($record); if ($availability['state'] !== 'expired' || ($record->workflow_context['offer_operation']['status'] ?? null) === 'expired') { return false; } $context = $record->workflow_context; $context['offer_operation'] = [...($context['offer_operation'] ?? []), 'status' => 'expired', 'expired_at' => $availability['expires_at']]; $this->save($record, null, $context, 'offer_expired', ['expires_at' => $availability['expires_at']]); return true; }); } public function canReport(User $actor, Opportunity $record): bool { if (app(PortalProfileAccess::class)->canView($actor, $record)) { return true; } try { app(OfferPublicCatalogue::class)->detail($record->id); return true; } catch (HttpExceptionInterface $error) { return false; } } /** @param array $input * @return array */ public function report(User $actor, int $id, array $input): array { $data = validator($input, ['client_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:2000', 'confirm' => 'required|accepted'])->validate(); return DB::transaction(function () use ($actor, $id, $data) { $record = $this->offer($id, true); $context = $record->workflow_context; $key = $data['client_key']; $hash = hash('sha256', $data['reason']); if (isset($context['offer_reports'][$key])) { $previous = $context['offer_reports'][$key]; abort_unless($previous['reporter_id'] === $actor->id, 404); abort_unless($previous['command_hash'] === $hash, 409); return ['id' => $key, 'replayed' => true]; } abort_unless($this->canReport($actor, $record), 404); try { $source = app(OfferPublicCatalogue::class)->detail($id); } catch (HttpExceptionInterface $error) { $source = Arr::only(app(OpportunityManagement::class)->values($record), ['title', 'description', 'opportunity_category', 'private_offer']); } $task = app(OpportunityWorkflowTasks::class)->ensure($record, 'offer_report_'.$key, 'Denúncia da Oferta '.$record->reference); $context['offer_reports'][$key] = ['id' => $key, 'reporter_id' => $actor->id, 'at' => now()->toIso8601String(), 'command_hash' => $hash, 'reason' => Crypt::encryptString($data['reason']), 'version' => 1, 'status' => 'open', 'task_id' => $task, 'source_snapshot' => Crypt::encryptString(json_encode($source, JSON_THROW_ON_ERROR)), 'source_record_version' => $record->management_version, 'source_approval_hash' => $context['approval']['hash'] ?? null, 'source_public_hash' => $context['offer_publication']['publication']['hash'] ?? null]; $this->save($record, $actor, $context, 'offer_reported', ['report_id' => $key]); return ['id' => $key, 'replayed' => false]; }); } /** @param array $input */ public function handleReport(User $actor, int $id, string $reportId, array $input): void { $data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'action' => 'required|in:start_review,resolve_no_withdrawal,resolve_withdrawal', 'reason' => 'required|string|min:20|max:2000', 'confirm' => 'required|accepted'])->validate(); DB::transaction(function () use ($actor, $id, $reportId, $data) { $record = $this->offer($id, true); abort_unless(app(PrivateOfferConfidentiality::class)->internal($actor, $record), 403); abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']), 409); $context = $record->workflow_context; $report = $context['offer_reports'][$reportId] ?? null; abort_unless($report !== null, 404); abort_unless($report['version'] === (int) $data['version'] && in_array($report['status'], ['open', 'in_review'], true), 409); if ($data['action'] === 'start_review') { abort_unless($report['status'] === 'open', 409); app(OpportunityWorkflowTasks::class)->assign($record, $report['task_id'], $actor); $report['status'] = 'in_review'; } else { abort_unless($report['status'] === 'in_review' && $report['reviewer_id'] === $actor->id, 409, 'A denúncia deve ser tratada pelo revisor que a iniciou.'); app(OpportunityWorkflowTasks::class)->complete($report['task_id'], 'Denúncia tratada; decisão no histórico privado.'); $report['status'] = 'resolved'; $report['resolution'] = $data['action']; if ($data['action'] === 'resolve_withdrawal') { $context = $this->withdraw($context, 'moderation'); } } $report['reviewer_id'] = $actor->id; $report['version']++; $report['updated_at'] = now()->toIso8601String(); $report['resolution_reason'] = Crypt::encryptString($data['reason']); $report['history'][] = ['action' => $data['action'], 'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'reason' => Crypt::encryptString($data['reason'])]; $context['offer_reports'][$reportId] = $report; $this->save($record, $actor, $context, 'offer_report_'.$data['action'], ['report_id' => $reportId]); }); } /** @param array $input */ public function highlight(User $actor, int $id, array $input): void { $data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:0', 'status' => 'required|in:draft,archived', 'label' => 'required|string|max:150', 'starts_at' => 'nullable|date', 'ends_at' => 'nullable|required_with:starts_at|date|after:starts_at', 'note' => 'required|string|min:20|max:1000', 'confirm' => 'required|accepted'])->validate(); DB::transaction(function () use ($actor, $id, $data) { $record = $this->offer($id, true); abort_unless($this->publisher($actor, $record), 403); $context = $record->workflow_context; $before = $context['offer_highlight'] ?? null; abort_unless(hash_equals(app(OpportunityManagement::class)->etag($record), $data['etag']) && (int) $data['version'] === ($before['version'] ?? 0), 409); $plan = ['version' => (int) $data['version'] + 1, 'status' => $data['status'], 'label' => $data['label'], 'starts_at' => $data['starts_at'] ?? null, 'ends_at' => $data['ends_at'] ?? null, 'note' => Crypt::encryptString($data['note']), 'actor_id' => $actor->id, 'at' => now()->toIso8601String()]; $context['offer_highlight_history'][] = $before; $context['offer_highlight'] = $plan; $this->save($record, $actor, $context, 'offer_highlight_prepared', ['plan_version' => $plan['version'], 'status' => $plan['status']]); }); } }