Creation keys are private to the actor who submitted them. */ public function creationKeys(User $actor, int $partnership, int $room): array { $keys = []; $events = DB::table('partnership_events')->where('partnership_id', $partnership)->where('actor_id', $actor->id) ->where('event_type', 'partnership_milestone.changed.v1')->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $room]) ->whereRaw("payload->'payload'->>'client_key' IS NOT NULL")->get(['payload']); foreach ($events as $event) { $payload = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload']; $keys[(int) $payload['milestone_id']] = $payload['client_key']; } return $keys; } /** @param array $data * @return array{saved:bool,milestone_id:int,version:int,replayed:bool} */ public function save(User $actor, int $roomId, array $data, ?int $milestone = null): array { $command = ['title' => $data['title'], 'responsible_id' => (int) $data['responsible_id'], 'due_date' => $data['due_date'] ?? null, 'status' => $data['status'], 'evidence' => $data['evidence'] ?? null]; $creationHash = hash('sha256', json_encode($command, JSON_THROW_ON_ERROR)); return DB::transaction(function () use ($actor, $roomId, $data, $milestone, $command, $creationHash) { $exchange = app(PartnershipExchange::class); $context = $exchange->room($actor, $roomId); abort_unless($exchange->owns($actor, $context['partnership']) || $exchange->owns($actor, $context['interest']), 403); $room = DB::table('partnership_rooms')->where('id', $roomId)->lockForUpdate()->first(); abort_unless($room !== null, 404); if ($milestone === null && ($data['client_key'] ?? null)) { // The room lock serializes this actor/room key check and its event, including legacy clients. $previous = DB::table('partnership_events')->where('partnership_id', $context['partnership']->id)->where('actor_id', $actor->id) ->where('event_type', 'partnership_milestone.changed.v1')->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $roomId]) ->whereRaw("payload->'payload'->>'client_key' = ?", [$data['client_key']])->first(['payload']); if ($previous) { $payload = json_decode($previous->payload, true, 512, JSON_THROW_ON_ERROR)['payload']; abort_unless($payload['creation_hash'] === $creationHash, 409, 'Chave de criação de marco já utilizada com outro conteúdo.'); $saved = DB::table('partnership_milestones')->where('room_id', $roomId)->where('id', $payload['milestone_id'])->first(); abort_unless($saved !== null, 409, 'O marco original não está disponível para reconciliar a criação.'); return ['saved' => true, 'milestone_id' => (int) $saved->id, 'version' => (int) $saved->version, 'replayed' => true]; } } abort_unless(in_array($room->status, ['agreement_recorded', 'agreed', 'execution'], true), 409, 'O estado da sala não permite alterar marcos.'); $responsible = User::query()->whereKey($command['responsible_id'])->first(); abort_unless($responsible !== null && ($exchange->owns($responsible, $context['partnership']) || $exchange->owns($responsible, $context['interest'])), 422, 'Responsável deve ser participante identificado.'); $previousResponsible = null; $version = 1; if ($milestone !== null) { $existing = DB::table('partnership_milestones')->where('room_id', $roomId)->where('id', $milestone)->lockForUpdate()->first(); abort_unless($existing !== null, 404); abort_unless((int) $existing->version === (int) $data['version'], 409, 'O marco foi alterado. Compare com os dados actuais.'); $previousResponsible = (int) $existing->responsible_id; $version = (int) $existing->version + 1; DB::table('partnership_milestones')->where('id', $milestone)->update([...$command, 'version' => $version, 'updated_at' => now()]); } else { $milestone = (int) DB::table('partnership_milestones')->insertGetId([...$command, 'room_id' => $roomId, 'created_at' => now(), 'updated_at' => now()]); } $metadata = ['room_id' => $roomId, 'milestone_id' => $milestone, 'status' => $command['status'], 'previous_responsible_id' => $previousResponsible, 'responsible_id' => $command['responsible_id']]; if ($version === 1 && ($data['client_key'] ?? null)) { $metadata += ['client_key' => $data['client_key'], 'creation_hash' => $creationHash]; } $exchange->event($context['partnership'], $actor, 'partnership_milestone.changed.v1', $metadata, 'Marco atualizado', [$context['partnership']->user_id, $context['interest']->user_id]); return ['saved' => true, 'milestone_id' => $milestone, 'version' => $version, 'replayed' => false]; }); } }