status === 'published' && (! $record->deadline || $record->deadline >= today()->toDateString()); } /** @return array> */ public function commands(User $actor, int $partnership): array { $commands = []; $events = DB::table('partnership_events')->where('partnership_id', $partnership)->where('actor_id', $actor->id) ->whereIn('event_type', ['partnership_interest.submitted.v1', 'partnership_interest.changed.v1'])->whereRaw("payload->'payload'->>'client_key' IS NOT NULL")->get(['payload', 'event_type']); foreach ($events as $event) { $payload = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload']; $commands[(int) $payload['interest_id']][] = ['key' => $payload['client_key'], 'version' => (int) $payload['interest_version'], 'action' => $event->event_type === 'partnership_interest.submitted.v1' ? 'submit' : $payload['action']]; } return $commands; } /** @param array $data */ public function submit(User $actor, int $id, array $data): stdClass { $data = Validator::make($data, ['client_key' => ['nullable', 'uuid'], 'interest_id' => ['nullable', 'integer', 'min:1'], 'partnership_version' => ['nullable', 'integer', 'min:1'], 'version' => ['nullable', 'integer', 'min:1'], 'organization_id' => ['nullable', 'uuid'], 'proposal' => ['required', 'string', 'max:15000'], 'payload' => ['array:'.implode(',', self::PAYLOAD)], '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'], 'payload.territory_id' => ['nullable', 'uuid']])->validate(); if (isset($data['client_key'])) { $data['client_key'] = strtolower($data['client_key']); } foreach (['interest_id', 'partnership_version', 'version'] as $integer) { if (isset($data[$integer])) { $data[$integer] = (int) $data[$integer]; } } $hash = $this->hash(Arr::except($data, 'client_key')); return DB::transaction(function () use ($actor, $id, $data, $hash) { $exchange = app(PartnershipExchange::class); $record = $exchange->find($id, true); $replay = $this->replay($actor, $record, 'partnership_interest.submitted.v1', $data['client_key'] ?? null, $hash); if ($replay) { abort_unless($exchange->owns($actor, $replay), 403); return $replay; } if (isset($data['interest_id'])) { $existing = DB::table('partnership_interests')->where('partnership_id', $id)->where('id', $data['interest_id'])->lockForUpdate()->first(); abort_unless($existing !== null, 404, 'A candidatura não pertence a esta parceria.'); } else { $matches = 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()->get(); abort_if($matches->count() > 1, 409, 'Há mais de uma candidatura possível. Abra a candidatura que pretende actualizar.'); $existing = $matches->first(); } if ($existing) { abort_unless($exchange->owns($actor, $existing), 403); } $organization = array_key_exists('organization_id', $data) ? $data['organization_id'] : ($existing->organization_id ?? null); abort_if($organization && ! $exchange->represents($actor, $organization), 403); abort_unless($this->accepting($record), 409, 'Receção de interesses encerrada.'); abort_if(isset($data['partnership_version']) && (int) $record->version !== $data['partnership_version'], 409, 'A proposta da parceria mudou. Compare os dados antes de submeter.'); abort_if((int) $record->user_id === $actor->id || $exchange->owns($actor, $record, false) || $organization && $organization === $record->organization_id, 403, 'Auto-candidatura não permitida.'); if ($organization) { abort_if(DB::table('partnership_interests')->where('partnership_id', $id)->where('organization_id', $organization)->where('status', '!=', 'withdrawn')->when($existing, fn ($q) => $q->where('id', '!=', $existing->id))->exists(), 409, 'A organização já tem uma candidatura ativa.'); } Validator::make($data, ['payload.sector_id' => ['nullable', 'exists:sectors,id'], 'payload.territory_id' => ['nullable', 'exists:geo_locations,id']])->validate(); if ($existing) { abort_unless(isset($data['version']) && (int) $existing->version === $data['version'], 409, 'Candidatura existente ou alterada; abra-a para atualizar.'); abort_unless(in_array($existing->status, ['submitted', 'clarification', 'withdrawn'], true), 409); } $before = $existing ? $this->snapshot($existing) : null; $payload = array_replace($existing ? Arr::only(json_decode($existing->payload, true, 512, JSON_THROW_ON_ERROR), self::PAYLOAD) : [], $data['payload']); $fields = ['proposal' => $data['proposal'], 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'organization_id' => $organization, 'status' => 'submitted', 'assessment' => '{}', 'version' => $existing ? (int) $existing->version + 1 : 1, 'updated_at' => now()]; if ($existing) { $interestId = (int) $existing->id; DB::table('partnership_interests')->where('id', $interestId)->update($fields); } else { $interestId = (int) DB::table('partnership_interests')->insertGetId([...$fields, 'partnership_id' => $id, 'user_id' => $actor->id, 'created_at' => now()]); } $saved = DB::table('partnership_interests')->where('id', $interestId)->first(); $metadata = ['interest_id' => $interestId, 'interest_version' => (int) $saved->version, 'partnership_version' => (int) $record->version, 'previous' => $before, 'submitted' => $this->snapshot($saved)]; if (isset($data['client_key'])) { $metadata += ['client_key' => $data['client_key'], 'command_hash' => $hash]; } $exchange->event($record, $actor, 'partnership_interest.submitted.v1', $metadata, 'Manifestação de interesse recebida', [$record->user_id, $actor->id]); $saved->command_replayed = false; return $saved; }); } /** @param array $assessment */ public function decide(User $actor, int $id, int $version, string $action, string $reason, array $assessment = [], ?int $partnershipVersion = null, ?string $weightsHash = null, ?string $clientKey = null): stdClass { $clientKey = $clientKey === null ? null : strtolower($clientKey); $hash = $this->hash(['interest_id' => $id, 'version' => $version, 'action' => $action, 'reason' => $reason, 'assessment' => $assessment, 'partnership_version' => $partnershipVersion, 'weights_hash' => $weightsHash]); return DB::transaction(function () use ($actor, $id, $version, $action, $reason, $assessment, $partnershipVersion, $weightsHash, $clientKey, $hash) { $parent = DB::table('partnership_interests')->where('id', $id)->first(['partnership_id']); abort_unless($parent !== null, 404); $exchange = app(PartnershipExchange::class); $record = $exchange->find($parent->partnership_id, true); $interest = DB::table('partnership_interests')->where('id', $id)->lockForUpdate()->first(); abort_unless($interest !== null && (int) $interest->partnership_id === (int) $record->id, 409); abort_unless($exchange->owns($actor, $action === 'withdraw' ? $interest : $record), 403); if ($action === 'withdraw') { abort_if($assessment !== [], 403, 'A avaliação fundamentada pertence ao responsável pela proposta.'); } $replay = $this->replay($actor, $record, 'partnership_interest.changed.v1', $clientKey, $hash); if ($replay) { return $replay; } abort_unless((int) $interest->version === $version, 409, 'Candidatura alterada.'); abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409); if ($action !== 'withdraw') { abort_if($partnershipVersion !== null && (int) $record->version !== $partnershipVersion, 409, 'A proposta ou os requisitos foram alterados. Compare antes de decidir.'); abort_if($weightsHash !== null && app(PartnershipMatching::class)->explain($record, $interest)['weights_hash'] !== $weightsHash, 409, 'Os critérios de correspondência foram alterados. Compare antes de decidir.'); } $states = ['withdraw' => 'withdrawn', 'clarify' => 'clarification', 'shortlist' => 'shortlisted', 'reject' => 'rejected', 'connect' => 'negotiation', 'assess' => $interest->status]; abort_unless(isset($states[$action]), 422); $before = $this->snapshot($interest); foreach ($assessment as $code => $criterion) { abort_unless(array_key_exists($code, config('partnerships.criteria')) && is_array($criterion), 422); $assessment[$code] = [...Validator::make($criterion, ['state' => 'required|in:met,unmet,unknown', 'evidence' => 'required_unless:state,unknown|nullable|string|max:2000'])->validate(), 'assessed_by' => $actor->id, 'assessed_at' => now()->toIso8601String()]; } $assessed = array_replace(json_decode($interest->assessment, true, 512, JSON_THROW_ON_ERROR), $assessment); $matching = $action === 'withdraw' ? null : app(PartnershipMatching::class)->explain($record, $interest, $assessed); if (in_array($action, ['shortlist', 'connect'], true)) { abort_if(($matching['eligibility'] ?? null) === '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, JSON_THROW_ON_ERROR), 'decision_reason' => $reason, 'version' => $version + 1, 'updated_at' => now()]); $saved = DB::table('partnership_interests')->where('id', $id)->first(); $metadata = ['interest_id' => $id, 'interest_version' => $version + 1, 'partnership_version' => (int) $record->version, 'action' => $action, 'from' => $interest->status, 'to' => $states[$action], 'reason' => $reason, 'assessment' => $assessed, 'matching_context' => $matching, 'previous' => $before]; if ($clientKey !== null) { $metadata += ['client_key' => $clientKey, 'command_hash' => $hash]; } $exchange->event($record, $actor, 'partnership_interest.changed.v1', $metadata, 'Candidatura: '.$states[$action], [$record->user_id, $interest->user_id]); $saved->command_replayed = false; return $saved; }); } /** @return array */ private function snapshot(stdClass $interest): array { return ['version' => (int) $interest->version, 'status' => $interest->status, 'organization_id' => $interest->organization_id, 'proposal' => $interest->proposal, 'payload' => json_decode($interest->payload, true, 512, JSON_THROW_ON_ERROR), 'assessment' => json_decode($interest->assessment, true, 512, JSON_THROW_ON_ERROR), 'decision_reason' => $interest->decision_reason]; } /** @param array $command */ private function hash(array $command): string { return hash('sha256', json_encode(Arr::sortRecursive($command), JSON_THROW_ON_ERROR)); } private function replay(User $actor, stdClass $record, string $type, ?string $key, string $hash): ?stdClass { if ($key === null) { return null; } $event = DB::table('partnership_events')->where('partnership_id', $record->id)->where('actor_id', $actor->id)->where('event_type', $type)->whereRaw("payload->'payload'->>'client_key' = ?", [$key])->first(['payload']); if (! $event) { return null; } $payload = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload']; abort_unless($payload['command_hash'] === $hash, 409, 'Chave já utilizada com outro conteúdo.'); $interest = DB::table('partnership_interests')->where('partnership_id', $record->id)->where('id', $payload['interest_id'])->first(); abort_unless($interest !== null, 409, 'A candidatura original não está disponível.'); $interest->command_replayed = true; return $interest; } }