Files
Investidor-V2/app/Services/PartnershipExchange.php
T

252 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use stdClass;
class PartnershipExchange
{
private const PAYLOAD_FIELDS = ['summary', 'requirements', 'mandatory', 'confidentiality', 'consent', 'availability'];
private const ROOT_FIELDS = ['title', 'offer', 'seeking', 'organization_id', 'sector_id', 'kind', 'partnership_type', 'deadline'];
public function represents(User $actor, ?string $organization, bool $write = true): bool
{
return $organization !== null && $this->representatives($organization, $write)->where('representative.user_id', $actor->id)->exists();
}
private function representatives(?string $organization, bool $write = true): Builder
{
return DB::table('memberships as representative')->join('organizations as represented', 'represented.id', '=', 'representative.organization_id')
->when($organization !== null, fn ($query) => $query->where('representative.organization_id', $organization))->whereNull('representative.deleted_at')->whereNull('represented.deleted_at')
->when($write, fn ($q) => $q->whereIn('representative.role', ['admin', 'manager', 'officer']))->select('representative.user_id');
}
/** @return list<string> */
public function readableOrganizations(User $actor): array
{
$organizations = [];
foreach ($this->representatives(null, false)->where('representative.user_id', $actor->id)->select('representative.organization_id')->pluck('representative.organization_id') as $id) {
if (! is_string($id) || ! Str::isUuid($id)) {
throw new \LogicException('Organization identity must be a UUID from the authorized catalogue.');
}
$organizations[] = $id;
}
return $organizations;
}
/** @return list<int> */
public function notificationRecipients(stdClass ...$parties): array
{
$recipients = [];
foreach ($parties as $party) {
$ids = $party->organization_id ? $this->representatives($party->organization_id)->pluck('representative.user_id')->all() : User::query()->whereKey($party->user_id)->pluck('id')->all();
foreach ($ids as $id) {
$recipients[(int) $id] = (int) $id;
}
}
return array_values($recipients);
}
/** @return list<array{id:int,name:string,parties:list<string>}> */
public function participants(stdClass $record, stdClass $interest): array
{
// Called only after room access is checked. The selector reuses the same representation query as commands.
$participants = [];
foreach (['proponent' => $record, 'candidate' => $interest] as $party => $source) {
$users = User::query()->when($source->organization_id,
fn ($q) => $q->whereIn('id', $this->representatives($source->organization_id)),
fn ($q) => $q->whereKey($source->user_id))->get(['id', 'name']);
foreach ($users as $user) {
$participants[$user->id] ??= ['id' => $user->id, 'name' => $user->name, 'parties' => []];
$participants[$user->id]['parties'][] = $party;
}
}
$participants = array_values($participants);
usort($participants, fn ($a, $b) => strnatcasecmp($a['name'], $b['name']) ?: $a['id'] <=> $b['id']);
return $participants;
}
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<string,mixed> $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'],
'operation_key' => ['nullable', 'uuid'],
'title' => ['nullable', 'string', 'max:200'], 'offer' => ['nullable', 'string', 'max:15000'], 'seeking' => ['nullable', 'string', 'max:15000'],
'kind' => ['required', 'in:offering,seeking'], 'partnership_type' => ['nullable', 'string'],
'organization_id' => ['nullable', 'uuid'], 'sector_id' => ['nullable', 'integer'],
'deadline' => ['nullable', 'date_format:Y-m-d'], 'payload' => ['array:'.implode(',', self::PAYLOAD_FIELDS)],
'payload.summary' => ['nullable', 'string', 'max:15000'], 'payload.requirements' => ['nullable', 'string', 'max:10000'],
'payload.mandatory' => ['array', 'max:7'], 'payload.mandatory.*' => ['string'],
'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'],
], ['payload.availability.max' => 'Indique a disponibilidade em até 1000 caracteres.'])->validate();
if ($id === null) {
unset($data['operation_key']);
} elseif (is_string($data['operation_key'] ?? null)) {
$data['operation_key'] = strtolower($data['operation_key']);
}
$creationHash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
$operationHash = hash('sha256', json_encode(Arr::sortRecursive(Arr::except($data, ['client_key', 'operation_key'])), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $data, $id, $creationHash, $operationHash) {
$creating = $id === null;
DB::select('select pg_advisory_xact_lock(71026, ?)', [$actor->id]);
if ($id) {
$record = $this->find($id, true);
abort_unless($this->owns($actor, $record), 403);
$replay = app(PartnershipDraftSaves::class)->replay($actor, $record, $data['operation_key'] ?? null, $operationHash);
if ($replay) {
return $replay;
}
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($this->owns($actor, $existing), 403, 'A representação actual desta proposta não autoriza recuperar a gravação.');
abort_unless((json_decode($existing->payload, true)['_creation_hash'] ?? null) === $creationHash, 409, 'Chave de rascunho já utilizada com outro conteúdo.');
$existing->command_replayed = true;
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);
}
$previous = $creating ? null : app(PartnershipPublication::class)->snapshot($record);
abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403, 'Representação da organização não autorizada.');
// Replaying an already committed creation is a read; live-reference checks apply to new writes.
Validator::make($data, ['partnership_type' => ['nullable', Rule::in(array_keys(config('partnerships.types')))], 'payload.mandatory.*' => [Rule::in(array_keys(config('partnerships.criteria')))], 'sector_id' => ['nullable', Rule::exists('sectors', 'id')->where('is_active', true)->whereNull('deleted_at')]], ['sector_id.exists' => 'Seleccione um sector activo ou deixe a classificação por definir.'])->validate();
$origins = $data['origins'] ?? DB::table('partnership_origins')->where('partnership_id', $id)->get(['type', 'target_id'])->map(fn ($row) => (array) $row)->all();
foreach ($data['origins'] ?? [] as $origin) {
app(PartnershipSources::class)->resolve($origin['type'], $origin['target_id'], $actor);
}
$locations = $data['locations'] ?? DB::table('partnership_locations')->where('partnership_id', $id)->get(['geo_location_id', 'role', 'source_reference'])->map(fn ($row) => (array) $row)->all();
foreach ($data['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.');
}
$existingPayload = json_decode($record->payload, true);
$payload = array_replace(Arr::only($existingPayload, self::PAYLOAD_FIELDS), $data['payload'] ?? []);
$fields = array_replace(Arr::only((array) $record, self::ROOT_FIELDS), Arr::only($data, self::ROOT_FIELDS));
foreach (['title', 'offer', 'seeking'] as $field) {
$fields[$field] ??= '';
}
DB::table('portal_partnerships')->where('id', $id)->update([...$fields, 'payload' => json_encode([...$payload, '_creation_hash' => $existingPayload['_creation_hash'] ?? $creationHash], JSON_THROW_ON_ERROR),
'status' => 'draft', 'version' => $record->version + 1, 'updated_at' => now()]);
if (array_key_exists('origins', $data)) {
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']]);
}
}
if (array_key_exists('locations', $data)) {
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);
$metadata = ['previous_status' => $record->status, 'origins' => $origins, 'locations' => $locations, 'action' => $creating ? 'create' : 'edit', 'result_version' => (int) $saved->version, 'previous' => $previous, 'applied' => app(PartnershipPublication::class)->snapshot($saved)];
if (! $creating && isset($data['operation_key'])) {
$metadata += ['operation_key' => $data['operation_key'], 'operation_hash' => $operationHash];
}
$this->event($saved, $actor, 'partnership_opportunity.changed.v1', $metadata, 'Rascunho guardado', [$record->user_id]);
$saved->command_replayed = false;
if (! $creating && isset($data['operation_key'])) {
$saved->command_saved_version = (int) $saved->version;
}
return $saved;
});
}
public function transition(User $actor, int $id, int $version, string $action, string $reason, ?string $clientKey = null): stdClass
{
return app(PartnershipPublication::class)->transition($actor, $id, $version, $action, $reason, $clientKey);
}
/** @param array<string,mixed> $data */
public function interest(User $actor, int $id, array $data): stdClass
{
return app(PartnershipCandidacies::class)->submit($actor, $id, $data);
}
/** @param array<string,mixed> $assessment */
public function decideInterest(User $actor, int $id, int $version, string $action, string $reason, array $assessment = [], ?int $partnershipVersion = null, ?string $weightsHash = null, ?string $clientKey = null): stdClass
{
return app(PartnershipCandidacies::class)->decide($actor, $id, $version, $action, $reason, $assessment, $partnershipVersion, $weightsHash, $clientKey);
}
/** @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<string,mixed> $payload
* @param list<int> $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 {
if ($recipients !== []) {
$context = app(PartnershipNotifications::class)->context($record, $payload);
$payload['_notification_context'] = $context;
$parties = [(object) $context['partnership']];
if ($context['interest'] !== null) {
$parties[] = (object) $context['interest'];
}
$recipients = $this->notificationRecipients(...$parties);
}
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]);
}
});
}
}