141 lines
10 KiB
PHP
141 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use stdClass;
|
|
|
|
class PartnershipAgreements
|
|
{
|
|
private const ACTIONS = ['record', 'confirm', 'start', 'complete', 'cancel', 'renew'];
|
|
|
|
/** @param array{partnership:stdClass,interest:stdClass,room:stdClass} $context */
|
|
public function party(User $actor, array $context): ?string
|
|
{
|
|
$exchange = app(PartnershipExchange::class);
|
|
|
|
return $exchange->owns($actor, $context['partnership']) ? 'proponent' : ($exchange->owns($actor, $context['interest']) ? 'candidate' : null);
|
|
}
|
|
|
|
/** @param array{partnership:stdClass,interest:stdClass,room:stdClass} $context
|
|
* @return list<array{key:string,action:string,version:int,status:string,party:string|null}>
|
|
*/
|
|
public function commands(User $actor, array $context): array
|
|
{
|
|
if ($this->party($actor, $context) === null) {
|
|
return [];
|
|
}
|
|
$rows = DB::table('partnership_events')->where('partnership_id', $context['partnership']->id)->where('actor_id', $actor->id)->where('event_type', 'partnership_agreement.changed.v1')->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $context['room']->id])->whereRaw("payload->'payload'->>'client_key' IS NOT NULL")->orderBy('aggregate_version')->selectRaw("payload->'payload'->>'client_key' AS key,payload->'payload'->>'action' AS action,payload->'payload'->'version' AS version,payload->'payload'->>'status' AS status,payload->'payload'->>'confirmation_party' AS party")->get();
|
|
$commands = [];
|
|
foreach ($rows as $row) {
|
|
$version = is_string($row->version) ? json_decode($row->version, true, 512, JSON_THROW_ON_ERROR) : $row->version;
|
|
if (is_string($row->key) && Str::isUuid($row->key) && in_array($row->action, self::ACTIONS, true) && is_int($version) && $version > 0 && is_string($row->status)) {
|
|
$commands[] = ['key' => $row->key, 'action' => $row->action, 'version' => $version, 'status' => $row->status, 'party' => in_array($row->party, ['proponent', 'candidate'], true) ? $row->party : null];
|
|
}
|
|
}
|
|
|
|
return $commands;
|
|
}
|
|
|
|
/** @param array<string,mixed> $input
|
|
* @return array{recorded:bool,replayed:bool,version:int,status:string,saved_version:int}
|
|
*/
|
|
public function act(User $actor, int $id, array $input): array
|
|
{
|
|
$data = Validator::make($input, ['client_key' => 'nullable|uuid', 'version' => 'required|integer|min:1', 'partnership_version' => 'nullable|integer|min:1', 'interest_version' => 'nullable|integer|min:1', 'action' => 'required|in:'.implode(',', self::ACTIONS), 'title' => 'required_if:action,record|nullable|string|max:200', 'terms' => 'required_if:action,record|nullable|string|max:20000', 'reason' => 'required|string|max:2000'])->validate();
|
|
$key = is_string($data['client_key'] ?? null) ? strtolower($data['client_key']) : null;
|
|
unset($data['client_key']);
|
|
if ($data['action'] !== 'record') {
|
|
$data = Arr::except($data, ['title', 'terms']);
|
|
}
|
|
$hash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
|
|
|
|
return DB::transaction(function () use ($actor, $id, $data, $key, $hash) {
|
|
$exchange = app(PartnershipExchange::class);
|
|
$context = $exchange->room($actor, $id);
|
|
$record = $exchange->find($context['partnership']->id, true);
|
|
$interest = DB::table('partnership_interests')->where('id', $context['interest']->id)->lockForUpdate()->first();
|
|
$room = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
|
|
abort_unless($interest && $room && (int) $interest->partnership_id === (int) $record->id && (int) $room->interest_id === (int) $interest->id, 409, 'O contexto da sala foi alterado.');
|
|
$context = ['partnership' => $record, 'interest' => $interest, 'room' => $room];
|
|
$party = $this->party($actor, $context);
|
|
abort_unless($party !== null, 403, 'A representação actual não permite alterar o acordo.');
|
|
if ($key !== null) {
|
|
$event = DB::table('partnership_events')->where('partnership_id', $record->id)->where('actor_id', $actor->id)->where('event_type', 'partnership_agreement.changed.v1')->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $id])->whereRaw("payload->'payload'->>'client_key' = ?", [$key])->selectRaw("payload->'payload'->>'command_hash' AS hash,payload->'payload'->'version' AS version")->first();
|
|
if ($event) {
|
|
abort_unless($event->hash === $hash, 409, 'Chave já utilizada com outro conteúdo.');
|
|
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
|
|
abort_unless(is_int($version) && $version > 0, 409, 'Versão anterior não identificada.');
|
|
|
|
return ['recorded' => true, 'replayed' => true, 'version' => (int) $room->version, 'status' => $room->status, 'saved_version' => $version];
|
|
}
|
|
}
|
|
abort_unless((int) $room->version === (int) $data['version'], 409, 'Acordo alterado. Compare antes de continuar.');
|
|
foreach (['partnership_version' => $record->version, 'interest_version' => $interest->version] as $field => $version) {
|
|
abort_if(isset($data[$field]) && (int) $data[$field] !== (int) $version, 409, 'A proposta ou candidatura foi alterada. Reveja o contexto antes de decidir.');
|
|
}
|
|
$agreement = $room->agreement ? json_decode($room->agreement, true, 512, JSON_THROW_ON_ERROR) : null;
|
|
abort_unless($agreement === null || is_array($agreement), 409, 'O instrumento guardado não tem uma estrutura reconhecida.');
|
|
$status = $room->status;
|
|
$before = $this->snapshot($room, $agreement);
|
|
$action = $data['action'];
|
|
if ($action === 'record') {
|
|
abort_unless(in_array($status, ['negotiation', 'agreement_recorded'], true), 409);
|
|
$previous = $agreement['previous_versions'] ?? [];
|
|
abort_unless(is_array($previous), 409, 'O histórico do instrumento não tem uma estrutura reconhecida.');
|
|
if ($agreement) {
|
|
$previous[] = Arr::except($agreement, ['previous_versions']);
|
|
}
|
|
$agreement = ['title' => $data['title'], 'terms' => $data['terms'], 'recorded_by' => $actor->id, 'recorded_at' => now()->toIso8601String(), 'confirmations' => [], 'parties' => [$record->user_id, $interest->user_id], 'legal_signature_status' => 'not_verified', 'previous_versions' => $previous,
|
|
'recorded_context' => ['partnership_version' => (int) $record->version, 'interest_version' => (int) $interest->version, 'proponent' => ['user_id' => (int) $record->user_id, 'organization_id' => $record->organization_id], 'candidate' => ['user_id' => (int) $interest->user_id, 'organization_id' => $interest->organization_id]]];
|
|
$status = 'agreement_recorded';
|
|
} elseif ($action === 'confirm') {
|
|
abort_unless($status === 'agreement_recorded' && $agreement, 409);
|
|
abort_unless(! isset($agreement['confirmations']) || is_array($agreement['confirmations']), 409, 'As confirmações guardadas não têm uma estrutura reconhecida.');
|
|
$agreement['confirmations'][$party] = ['actor_id' => $actor->id, 'date' => now()->toIso8601String(), 'reason' => $data['reason']];
|
|
if ($this->confirmation($agreement['confirmations']['proponent'] ?? null) && $this->confirmation($agreement['confirmations']['candidate'] ?? null)) {
|
|
$status = 'agreed';
|
|
}
|
|
} else {
|
|
$allowed = ['start' => ['agreed'], 'complete' => ['execution'], 'cancel' => ['negotiation', 'agreement_recorded', 'agreed', 'execution'], 'renew' => ['agreed', 'execution']];
|
|
abort_unless(in_array($status, $allowed[$action] ?? [], true), 409);
|
|
if ($action === 'complete') {
|
|
abort_if(DB::table('partnership_milestones')->where('room_id', $id)->where('status', '!=', 'completed')->exists(), 409, 'Existem marcos pendentes.');
|
|
}
|
|
$status = ['start' => 'execution', 'complete' => 'closed', 'cancel' => 'cancelled', 'renew' => 'negotiation'][$action];
|
|
if ($action === 'renew') {
|
|
abort_unless(is_array($agreement) && is_array($agreement['previous_versions'] ?? []), 409, 'O instrumento ou o seu histórico não tem uma estrutura reconhecida.');
|
|
$agreement['previous_versions'][] = Arr::except($agreement, ['previous_versions']);
|
|
$agreement['confirmations'] = [];
|
|
}
|
|
}
|
|
DB::table('partnership_rooms')->where('id', $id)->update(['agreement' => $agreement ? json_encode($agreement, JSON_THROW_ON_ERROR) : null, 'status' => $status, 'version' => $room->version + 1, 'updated_at' => now()]);
|
|
$saved = DB::table('partnership_rooms')->where('id', $id)->first();
|
|
$metadata = ['room_id' => $id, 'status' => $status, 'version' => (int) $saved->version, 'reason' => $data['reason'], 'action' => $action, 'confirmation_party' => $action === 'confirm' ? $party : null, 'partnership_version' => (int) $record->version, 'interest_version' => (int) $interest->version, 'previous' => $before, 'applied' => $this->snapshot($saved, $agreement)];
|
|
if ($key !== null) {
|
|
$metadata += ['client_key' => $key, 'command_hash' => $hash];
|
|
}
|
|
$exchange->event($record, $actor, 'partnership_agreement.changed.v1', $metadata, 'Acordo: '.$status, [$record->user_id, $interest->user_id]);
|
|
|
|
return ['recorded' => true, 'replayed' => false, 'version' => (int) $saved->version, 'status' => $status, 'saved_version' => (int) $saved->version];
|
|
});
|
|
}
|
|
|
|
private function confirmation(mixed $value): bool
|
|
{
|
|
return is_array($value) && is_int($value['actor_id'] ?? null) && $value['actor_id'] > 0;
|
|
}
|
|
|
|
/** @param array<string,mixed>|null $agreement
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function snapshot(stdClass $room, ?array $agreement): array
|
|
{
|
|
return ['id' => (int) $room->id, 'version' => (int) $room->version, 'status' => $room->status, 'agreement' => $agreement];
|
|
}
|
|
}
|