where('user_id', $actor->id)->where('organization_id', $organization)->whereNull('deleted_at') ->when($write, fn ($q) => $q->whereIn('role', ['admin', 'manager', 'officer']))->exists() && DB::table('organizations')->where('id', $organization)->whereNull('deleted_at')->exists(); } public function owns(User $actor, stdClass $record, bool $write = true): bool { // An organization record follows its current representatives, including the original author. return $record->organization_id ? $this->represents($actor, $record->organization_id, $write) : (int) $record->user_id === $actor->id; } public function find(int $id, bool $lock = false): stdClass { $record = DB::table('portal_partnerships')->where('id', $id)->when($lock, fn ($q) => $q->lockForUpdate())->first(); abort_unless($record !== null, 404); return $record; } /** @param array $data */ public function save(User $actor, array $data, ?int $id = null): stdClass { $data = Validator::make($data, [ 'client_key' => [$id ? 'nullable' : 'required', 'uuid'], 'version' => [$id ? 'required' : 'nullable', 'integer', 'min:1'], 'title' => ['nullable', 'string', 'max:200'], 'offer' => ['nullable', 'string', 'max:15000'], 'seeking' => ['nullable', 'string', 'max:15000'], 'kind' => ['required', 'in:offering,seeking'], 'partnership_type' => ['nullable', Rule::in(array_keys(config('partnerships.types')))], 'organization_id' => ['nullable', 'uuid'], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)->whereNull('deleted_at')], 'deadline' => ['nullable', 'date_format:Y-m-d'], 'payload' => ['array:summary,requirements,mandatory,confidentiality,consent,availability'], 'payload.summary' => ['nullable', 'string', 'max:15000'], 'payload.requirements' => ['nullable', 'string', 'max:10000'], 'payload.mandatory' => ['array', 'max:7'], 'payload.mandatory.*' => [Rule::in(array_keys(config('partnerships.criteria')))], 'payload.confidentiality' => ['in:public,private'], 'payload.consent' => ['boolean'], 'payload.availability' => ['nullable', 'string', 'max:1000'], 'locations' => ['array', 'max:30'], 'locations.*.geo_location_id' => ['required', 'uuid'], 'locations.*.role' => ['required', 'in:primary,operating,coverage'], 'locations.*.source_reference' => ['required', 'string', 'max:2000'], 'origins' => ['array', 'max:20'], 'origins.*.type' => ['required', 'string'], 'origins.*.target_id' => ['required', 'string', 'max:100'], ])->validate(); abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403, 'Representação da organização não autorizada.'); $creationHash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR)); return DB::transaction(function () use ($actor, $data, $id, $creationHash) { DB::select('select pg_advisory_xact_lock(71026, ?)', [$actor->id]); if ($id) { $record = $this->find($id, true); abort_unless($this->owns($actor, $record), 403); abort_unless((int) $record->version === (int) $data['version'], 409, 'Rascunho alterado; recarregue.'); abort_unless(in_array($record->status, ['draft', 'returned', 'rejected', 'published'], true), 409, 'Estado não permite edição.'); } else { $existing = DB::table('portal_partnerships')->where('user_id', $actor->id)->where('client_key', $data['client_key'])->first(); if ($existing) { abort_unless((json_decode($existing->payload, true)['_creation_hash'] ?? null) === $creationHash, 409, 'Chave de rascunho já utilizada com outro conteúdo.'); return $existing; } $id = DB::table('portal_partnerships')->insertGetId(['user_id' => $actor->id, 'client_key' => $data['client_key'], 'title' => '', 'sector' => '', 'province' => '', 'offer' => '', 'seeking' => '', 'status' => 'draft', 'created_at' => now(), 'updated_at' => now()]); $record = $this->find($id, true); } $origins = $data['origins'] ?? []; foreach ($origins as $origin) { app(PartnershipSources::class)->resolve($origin['type'], $origin['target_id'], $actor); } $locations = $data['locations'] ?? []; foreach ($locations as $location) { abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $location['geo_location_id'])->exists(), 422, 'Localização não pertence ao cadastro territorial público.'); } DB::table('portal_partnerships')->where('id', $id)->update(['title' => $data['title'] ?? '', 'offer' => $data['offer'] ?? '', 'seeking' => $data['seeking'] ?? '', 'organization_id' => $data['organization_id'] ?? null, 'sector_id' => $data['sector_id'] ?? null, 'kind' => $data['kind'], 'partnership_type' => $data['partnership_type'] ?? null, 'deadline' => $data['deadline'] ?? null, 'payload' => json_encode([...($data['payload'] ?? []), '_creation_hash' => json_decode($record->payload, true)['_creation_hash'] ?? $creationHash], JSON_THROW_ON_ERROR), 'status' => 'draft', 'version' => $record->version + 1, 'updated_at' => now()]); DB::table('partnership_origins')->where('partnership_id', $id)->delete(); foreach ($origins as $origin) { DB::table('partnership_origins')->insertOrIgnore(['partnership_id' => $id, 'type' => $origin['type'], 'target_id' => $origin['target_id']]); } DB::table('partnership_locations')->where('partnership_id', $id)->delete(); foreach ($locations as $location) { DB::table('partnership_locations')->insertOrIgnore(['partnership_id' => $id, 'geo_location_id' => $location['geo_location_id'], 'role' => $location['role'], 'source_reference' => $location['source_reference']]); } $saved = $this->find($id); $this->event($saved, $actor, 'partnership_opportunity.changed.v1', ['previous_status' => $record->status, 'origins' => $origins, 'locations' => $locations], 'Rascunho guardado', [$record->user_id]); return $saved; }); } public function transition(User $actor, int $id, int $version, string $action, string $reason): stdClass { return DB::transaction(function () use ($actor, $id, $version, $action, $reason) { $record = $this->find($id, true); abort_unless((int) $record->version === $version, 409, 'Proposta alterada; recarregue.'); $moderation = in_array($action, ['publish', 'return', 'reject', 'suspend'], true); if ($moderation) { Gate::forUser($actor)->authorize('manage-portal'); abort_if($this->owns($actor, $record), 403, 'Não pode moderar a própria proposta.'); } else { abort_unless($this->owns($actor, $record), 403); } $from = $record->status; $allowed = ['submit' => ['draft', 'returned', 'rejected'], 'publish' => ['submitted', 'pending'], 'return' => ['submitted', 'pending'], 'reject' => ['submitted', 'pending'], 'suspend' => ['published'], 'archive' => ['draft', 'published', 'suspended', 'rejected'], 'renew' => ['published', 'suspended', 'archived']]; abort_unless(isset($allowed[$action]) && in_array($from, $allowed[$action], true), 409, 'Transição inválida.'); if ($action === 'submit') { $payload = json_decode($record->payload, true); Validator::make(['title' => $record->title, 'type' => $record->partnership_type, 'offer' => $record->offer, 'seeking' => $record->seeking, 'consent' => $payload['consent'] ?? false], ['title' => 'required', 'type' => 'required', 'offer' => 'required_without:seeking', 'seeking' => 'required_without:offer', 'consent' => 'accepted'])->validate(); } $status = ['submit' => 'submitted', 'publish' => 'published', 'return' => 'returned', 'reject' => 'rejected', 'suspend' => 'suspended', 'archive' => 'archived', 'renew' => 'draft'][$action]; DB::table('portal_partnerships')->where('id', $id)->update(['status' => $status, 'version' => $version + 1, 'reviewed_by' => $moderation ? $actor->id : $record->reviewed_by, 'published_at' => $action === 'publish' ? now() : $record->published_at, 'updated_at' => now()]); $saved = $this->find($id); $this->event($saved, $actor, 'partnership_opportunity.'.$status.'.v1', ['from' => $from, 'to' => $status, 'reason' => $reason], 'Proposta: '.$status, [$record->user_id]); return $saved; }); } /** @param array $data */ public function interest(User $actor, int $id, array $data): stdClass { $data = Validator::make($data, ['version' => ['nullable', 'integer', 'min:1'], 'organization_id' => ['nullable', 'uuid'], 'proposal' => ['required', 'string', 'max:15000'], 'payload' => ['array:capacity,availability,contribution,conflicts,consent,sector_id,territory_id'], 'payload.consent' => ['accepted'], 'payload.capacity' => ['nullable', 'string', 'max:5000'], 'payload.availability' => ['nullable', 'string', 'max:1000'], 'payload.contribution' => ['nullable', 'string', 'max:5000'], 'payload.conflicts' => ['nullable', 'string', 'max:5000'], 'payload.sector_id' => ['nullable', 'integer', 'exists:sectors,id'], 'payload.territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id']])->validate(); abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403); return DB::transaction(function () use ($actor, $id, $data) { $record = $this->find($id, true); abort_unless($record->status === 'published' && (! $record->deadline || $record->deadline >= today()->toDateString()), 409, 'Receção de interesses encerrada.'); abort_if((int) $record->user_id === $actor->id || $this->owns($actor, $record, false) || ($data['organization_id'] ?? null) && $data['organization_id'] === $record->organization_id, 403, 'Auto-candidatura não permitida.'); $existing = DB::table('partnership_interests')->where('partnership_id', $id)->where(fn ($q) => $q->where('user_id', $actor->id) ->when($data['organization_id'] ?? null, fn ($org) => $org->orWhere('organization_id', $data['organization_id'])))->orderBy('id')->lockForUpdate()->first(); if ($data['organization_id'] ?? null) { abort_if(DB::table('partnership_interests')->where('partnership_id', $id)->where('organization_id', $data['organization_id'])->where('status', '!=', 'withdrawn') ->when($existing, fn ($q) => $q->where('id', '!=', $existing->id))->exists(), 409, 'A organização já tem uma candidatura ativa.'); } if ($existing) { abort_unless($this->owns($actor, $existing), 403); abort_unless(isset($data['version']) && (int) $existing->version === (int) $data['version'], 409, 'Candidatura existente ou alterada; abra-a para atualizar.'); abort_unless(in_array($existing->status, ['submitted', 'clarification', 'withdrawn'], true), 409); $interestId = $existing->id; DB::table('partnership_interests')->where('id', $interestId)->update(['proposal' => $data['proposal'], 'payload' => json_encode($data['payload']), 'organization_id' => $data['organization_id'] ?? null, 'status' => 'submitted', 'assessment' => '{}', 'version' => $existing->version + 1, 'updated_at' => now()]); } else { $interestId = DB::table('partnership_interests')->insertGetId(['partnership_id' => $id, 'user_id' => $actor->id, 'organization_id' => $data['organization_id'] ?? null, 'proposal' => $data['proposal'], 'payload' => json_encode($data['payload']), 'created_at' => now(), 'updated_at' => now()]); } $this->event($record, $actor, 'partnership_interest.submitted.v1', ['interest_id' => $interestId, 'interest_version' => $existing ? $existing->version + 1 : 1], 'Manifestação de interesse recebida', [$record->user_id, $actor->id]); return DB::table('partnership_interests')->where('id', $interestId)->first(); }); } /** @param array $assessment */ public function decideInterest(User $actor, int $id, int $version, string $action, string $reason, array $assessment = []): stdClass { return DB::transaction(function () use ($actor, $id, $version, $action, $reason, $assessment) { $interest = DB::table('partnership_interests')->where('id', $id)->lockForUpdate()->first(); abort_unless($interest !== null, 404); $record = $this->find($interest->partnership_id, true); abort_unless((int) $interest->version === $version, 409, 'Candidatura alterada.'); if ($action === 'withdraw') { abort_if($assessment !== [], 403, 'A avaliação fundamentada pertence ao responsável pela proposta.'); abort_unless($this->owns($actor, $interest), 403); abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409); } else { abort_unless($this->owns($actor, $record), 403); abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409); } $states = ['withdraw' => 'withdrawn', 'clarify' => 'clarification', 'shortlist' => 'shortlisted', 'reject' => 'rejected', 'connect' => 'negotiation', 'assess' => $interest->status]; abort_unless(isset($states[$action]), 422); foreach ($assessment as $code => $criterion) { abort_unless(array_key_exists($code, config('partnerships.criteria')) && is_array($criterion), 422); Validator::make($criterion, ['state' => 'required|in:met,unmet,unknown', 'evidence' => 'required_unless:state,unknown|nullable|string|max:2000'])->validate(); $assessment[$code]['assessed_by'] = $actor->id; $assessment[$code]['assessed_at'] = now()->toIso8601String(); } $assessed = array_replace(json_decode($interest->assessment, true), $assessment); if (in_array($action, ['shortlist', 'connect'], true)) { $match = app(PartnershipMatching::class)->explain($record, $interest, $assessed); abort_if($match['eligibility'] === 'unmet', 422, 'Existe requisito obrigatório comprovadamente incumprido.'); } if ($action === 'connect') { abort_unless($interest->status === 'shortlisted', 409, 'Selecione primeiro a candidatura para lista curta.'); DB::table('partnership_rooms')->insertOrIgnore(['interest_id' => $id, 'created_at' => now(), 'updated_at' => now()]); } DB::table('partnership_interests')->where('id', $id)->update(['status' => $states[$action], 'assessment' => json_encode($assessed), 'decision_reason' => $reason, 'version' => $version + 1, 'updated_at' => now()]); $this->event($record, $actor, 'partnership_interest.changed.v1', ['interest_id' => $id, 'interest_version' => $version + 1, 'from' => $interest->status, 'to' => $states[$action], 'reason' => $reason, 'assessment' => $assessed], 'Candidatura: '.$states[$action], [$record->user_id, $interest->user_id]); return DB::table('partnership_interests')->where('id', $id)->first(); }); } /** @return array{room:stdClass,interest:stdClass,partnership:stdClass} */ public function room(User $actor, int $id, bool $write = false): array { $room = DB::table('partnership_rooms')->where('id', $id)->first(); abort_unless($room !== null, 404); $interest = DB::table('partnership_interests')->where('id', $room->interest_id)->first(); $record = $this->find($interest->partnership_id); abort_unless($this->owns($actor, $record, $write) || $this->owns($actor, $interest, $write), 403, 'Sala privada reservada aos participantes.'); abort_if($write && in_array($room->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.'); return ['room' => $room, 'interest' => $interest, 'partnership' => $record]; } /** @param array $payload * @param list $recipients */ public function event(stdClass $record, User $actor, string $type, array $payload, string $title, array $recipients = []): void { DB::transaction(function () use ($record, $actor, $type, $payload, $title, $recipients): void { DB::select('select pg_advisory_xact_lock(hashtextextended(?,0))', ['partnership-events:'.$record->id]); $sequence = (DB::table('partnership_events')->where('partnership_id', $record->id)->max('aggregate_version') ?? 0) + 1; $eventId = (string) Str::uuid(); $envelope = ['event_id' => $eventId, 'event_type' => $type, 'event_version' => 1, 'source' => 'investmoz.partnerships', 'subject_id' => $record->id, 'occurred_at' => now()->toIso8601String(), 'producer_version' => '1.0', 'correlation_id' => $eventId, 'causation_id' => null, 'data_classification' => 'internal', 'aggregate_id' => $record->id, 'aggregate_version' => $sequence, 'publication_version' => $record->version, 'payload' => $payload, 'external_delivery' => 'not_configured']; DB::table('partnership_events')->insert(['id' => $eventId, 'partnership_id' => $record->id, 'actor_id' => $actor->id, 'event_type' => $type, 'aggregate_version' => $sequence, 'payload' => json_encode($envelope)]); DB::table('audit_events')->insert(['source_system' => 'partnership-exchange', 'entity_type' => 'portal_partnerships', 'actor_user_id' => $actor->id, 'action' => $type, 'after_data' => json_encode($envelope)]); foreach (array_unique($recipients) as $recipient) { DB::table('partnership_notifications')->insert(['event_id' => $eventId, 'user_id' => $recipient, 'title' => $title, 'href' => '/parcerias/'.$record->id]); } }); } }