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 */ 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 */ 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}> */ 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 $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 $data */ public function interest(User $actor, int $id, array $data): stdClass { return app(PartnershipCandidacies::class)->submit($actor, $id, $data); } /** @param array $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 $payload * @param list $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]); } }); } }