['draft', 'returned', 'rejected'], 'publish' => ['submitted', 'pending'], 'return' => ['submitted', 'pending'], 'reject' => ['submitted', 'pending'], 'suspend' => ['published'], 'archive' => ['draft', 'published', 'suspended', 'rejected'], 'renew' => ['published', 'suspended', 'archived']]; private const STATES = ['submit' => 'submitted', 'publish' => 'published', 'return' => 'returned', 'reject' => 'rejected', 'suspend' => 'suspended', 'archive' => 'archived', 'renew' => 'draft']; private const MODERATION = ['publish', 'return', 'reject', 'suspend']; private function permitted(User $actor, stdClass $record, string $action): bool { $owns = app(PartnershipExchange::class)->owns($actor, $record); return in_array($action, self::MODERATION, true) ? Gate::forUser($actor)->allows('manage-portal') && ! $owns : $owns; } /** @return list */ public function actions(?User $actor, stdClass $record): array { $actions = []; foreach (self::ALLOWED as $action => $states) { if ($actor && in_array($record->status, $states, true) && $this->permitted($actor, $record, $action)) { $actions[] = $action; } } return $actions; } /** @return list */ public function commands(?User $actor, stdClass $record): array { if (! $actor) { return []; } $commands = []; $events = DB::table('partnership_events')->where('partnership_id', $record->id)->where('actor_id', $actor->id)->where('event_type', 'like', 'partnership_opportunity.%')->whereRaw("payload->'payload'->>'client_key' IS NOT NULL")->orderBy('aggregate_version')->get(['payload']); foreach ($events as $event) { $data = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload']; if (is_string($data['client_key'] ?? null) && is_string($data['action'] ?? null) && isset(self::ALLOWED[$data['action']]) && is_int($data['result_version'] ?? null) && $this->permitted($actor, $record, $data['action'])) { $commands[] = ['key' => $data['client_key'], 'action' => $data['action'], 'version' => $data['result_version']]; } } return $commands; } public function transition(User $actor, int $id, int $version, string $action, string $reason, ?string $clientKey = null): stdClass { $reason = trim($reason); $clientKey = $clientKey === null ? null : strtolower($clientKey); Validator::make(['version' => $version, 'reason' => $reason, 'client_key' => $clientKey], ['version' => 'required|integer|min:1', 'reason' => 'required|string|max:2000', 'client_key' => 'nullable|uuid'])->validate(); $hash = hash('sha256', json_encode(Arr::sortRecursive(['version' => $version, 'action' => $action, 'reason' => $reason]), JSON_THROW_ON_ERROR)); return DB::transaction(function () use ($actor, $id, $version, $action, $reason, $clientKey, $hash) { $record = app(PartnershipExchange::class)->find($id, true); abort_unless($this->permitted($actor, $record, $action), 403, 'A competência actual não permite esta acção sobre a proposta.'); if ($clientKey !== null) { $event = DB::table('partnership_events')->where('partnership_id', $id)->where('actor_id', $actor->id)->where('event_type', 'like', 'partnership_opportunity.%')->whereRaw("payload->'payload'->>'client_key' = ?", [$clientKey])->first(['payload']); if ($event) { $data = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload']; abort_unless(($data['command_hash'] ?? null) === $hash, 409, 'Chave já utilizada com outro conteúdo.'); $record->command_replayed = true; return $record; } } abort_unless((int) $record->version === $version, 409, 'Proposta alterada; compare antes de decidir.'); abort_unless(isset(self::ALLOWED[$action]) && in_array($record->status, self::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(); } $before = $this->snapshot($record); $status = self::STATES[$action]; $moderation = in_array($action, self::MODERATION, true); 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 = app(PartnershipExchange::class)->find($id); $metadata = ['from' => $record->status, 'to' => $status, 'reason' => $reason, 'action' => $action, 'result_version' => $version + 1, 'previous' => $before, 'applied' => $this->snapshot($saved)]; if ($clientKey !== null) { $metadata += ['client_key' => $clientKey, 'command_hash' => $hash]; } app(PartnershipExchange::class)->event($saved, $actor, 'partnership_opportunity.'.$status.'.v1', $metadata, 'Proposta: '.$status, [$record->user_id]); $saved->command_replayed = false; return $saved; }); } /** @return array */ public function snapshot(stdClass $record): array { $payload = json_decode($record->payload, true, 512, JSON_THROW_ON_ERROR); unset($payload['_creation_hash']); return [...Arr::only((array) $record, ['id', 'user_id', 'organization_id', 'title', 'kind', 'partnership_type', 'sector_id', 'deadline', 'offer', 'seeking', 'version', 'status', 'published_at']), 'payload' => $payload, 'origins' => DB::table('partnership_origins')->where('partnership_id', $record->id)->get(['type', 'target_id'])->map(fn ($row) => (array) $row)->all(), 'locations' => DB::table('partnership_locations')->where('partnership_id', $record->id)->get(['geo_location_id', 'role', 'source_reference'])->map(fn ($row) => (array) $row)->all()]; } }