where('tenant_id', $tenantId)->orderByDesc('version')->first() : null; } /** @param array $input */ public function configure(User $actor, array $input): void { DB::transaction(function () use ($actor, $input) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor); abort_unless(app(SubmissionForms::class)->canManage($actor), 403); abort_unless(Schema::hasTable('candidate_portal_versions'), 503, 'Instalação da área do candidato pendente.'); $data = Validator::make($input, ['version' => 'required|integer|min:0', 'registration_open' => 'required|boolean', 'notice' => 'required|string|min:30|max:15000', 'reason' => 'required|string|min:10|max:4000'])->validate(); $previous = $this->portal($actor->tenant_id); if ((int) $data['version'] !== (int) ($previous->version ?? 0)) { throw ValidationException::withMessages(['version' => 'A configuração mudou. Actualize antes de guardar.']); } $id = DB::table('candidate_portal_versions')->insertGetId(['tenant_id' => $actor->tenant_id, 'created_by' => $actor->id, 'version' => (int) $data['version'] + 1, 'registration_open' => $data['registration_open'], 'notice' => $data['notice'], 'reason' => $data['reason'], 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'candidate_portal.configured', null, $previous === null ? null : (array) $previous, ['id' => $id, ...$data, 'scope' => 'candidate_profile_registration_only', 'opportunity_publication_required' => true]); }); } /** @param array $input */ public function register(int $tenantId, array $input): CandidateAccount { return DB::transaction(function () use ($tenantId, $input) { // Same tenant mutex as company configuration; never lock or authenticate as a staff user. DB::table('tenants')->where('id', $tenantId)->lockForUpdate()->firstOrFail(); $portal = $this->portal($tenantId); abort_unless($portal !== null && $portal->registration_open, 403, 'Novos registos estão encerrados.'); $data = Validator::make($input, ['email' => 'required|string|email|max:254', 'password' => 'required|string|min:12|max:128|confirmed', 'notice_acknowledged' => 'accepted', 'portal_version' => 'required|integer|min:1'])->validate(); if ((int) $data['portal_version'] !== $portal->version) { throw ValidationException::withMessages(['portal_version' => 'O aviso mudou. Actualize e confira antes de criar a conta.']); } $email = mb_strtolower(trim($data['email'])); if (CandidateAccount::query()->where('tenant_id', $tenantId)->where('email', $email)->exists()) { throw ValidationException::withMessages(['email' => 'Não foi possível criar a conta. Se já se registou, utilize a entrada de candidatos.']); } $account = CandidateAccount::query()->create(['tenant_id' => $tenantId, 'email' => $email, 'password' => $data['password'], 'portal_version_id' => $portal->id]); $this->event($account, 'account.created', ['portal_version_id' => $portal->id, 'notice_acknowledged' => true, 'email_verified' => false]); return $account; }); } /** @return array */ public function profile(CandidateAccount $account): array { $row = DB::table('candidate_profile_versions')->where('tenant_id', $account->tenant_id)->where('candidate_account_id', $account->id)->orderByDesc('version')->first(); return ['version' => $row->version ?? 0, 'data' => $row === null ? ['name' => '', 'phone' => '', 'summary' => '', 'availability' => ['province' => '', 'district' => '', 'locality' => '']] : json_decode($row->data, true, 16, JSON_THROW_ON_ERROR)]; } /** @param array $input */ public function save(CandidateAccount $account, array $input): void { DB::transaction(function () use ($account, $input) { $sessionVersion = $account->session_version; $account = CandidateAccount::query()->where('tenant_id', $account->tenant_id)->where('id', $account->id)->lockForUpdate()->firstOrFail(); abort_unless($account->session_version === $sessionVersion, 401); $data = Validator::make($input, ['version' => 'required|integer|min:0', 'name' => 'required|string|min:3|max:200', 'phone' => 'nullable|string|max:50', 'summary' => 'required|string|min:10|max:5000', 'availability' => 'required|array:province,district,locality', 'availability.province' => 'required|string|max:150', 'availability.district' => 'required|string|max:150', 'availability.locality' => 'required|string|max:150', 'reason' => 'required|string|min:10|max:4000'])->validate(); $previous = $this->profile($account); if ((int) $data['version'] !== $previous['version']) { throw ValidationException::withMessages(['version' => 'O perfil mudou. Actualize e confira antes de guardar.']); } $id = DB::table('candidate_profile_versions')->insertGetId(['tenant_id' => $account->tenant_id, 'candidate_account_id' => $account->id, 'version' => $previous['version'] + 1, 'data' => json_encode(array_intersect_key($data, array_flip(['name', 'phone', 'summary', 'availability'])), JSON_THROW_ON_ERROR), 'reason' => $data['reason'], 'created_at' => now()]); $this->event($account, 'profile.version_created', ['profile_version_id' => $id, 'version' => $previous['version'] + 1]); }); } /** @param array $metadata */ public function event(CandidateAccount $account, string $action, array $metadata): void { DB::table('candidate_events')->insert(['tenant_id' => $account->tenant_id, 'candidate_account_id' => $account->id, 'action' => $action, 'metadata' => json_encode($metadata, JSON_THROW_ON_ERROR), 'created_at' => now()]); } }