*/ public function catalogue(User $actor): array { abort_unless($actor->can('manage-portal'), 403); $today = CarbonImmutable::now('UTC')->toDateString(); $report = $this->metrics->report($actor, ['from' => $today, 'to' => $today]); return ['definition_version' => $report['definition_version'], 'services' => $report['services'], 'items' => array_map(fn ($m) => Arr::only($m, ['id', 'title', 'unit', 'source', 'limits']), $report['metrics'])]; } /** @param array $input * @return array */ public function listing(User $actor, array $input): array { abort_unless($actor->can('manage-portal'), 403); $v = Validator::make($input, ['page' => 'nullable|integer|min:1', 'state' => ['nullable', Rule::in(array_keys(config('product-experiments.states')))]])->validate(); $base = ['installed' => $this->available(), 'states' => config('product-experiments.states'), 'filters' => ['state' => $v['state'] ?? '']]; if (! $base['installed']) { return [...$base, 'items' => [], 'page' => 1, 'pages' => 1, 'total' => 0]; } $q = DB::table('product_experiments as p')->join('product_experiment_revisions as r', fn ($join) => $join->on('r.experiment_id', '=', 'p.id')->on('r.version', '=', 'p.version')) ->when(! empty($v['state']), fn ($q) => $q->where('p.state', $v['state'])); $total = (clone $q)->count(); $pages = max(1, (int) ceil($total / 25)); $page = min((int) ($v['page'] ?? 1), $pages); $items = $q->orderByDesc('p.updated_at')->orderBy('p.id')->forPage($page, 25)->get(['p.id', 'p.version', 'p.state', 'p.updated_at', 'r.snapshot'])->map(function ($row) { $snapshot = $this->decode($row->snapshot); return ['id' => $row->id, 'version' => (int) $row->version, 'state' => $row->state, 'at' => $row->updated_at, 'title' => $snapshot['values']['title'], 'hypothesis' => $snapshot['values']['hypothesis']]; })->all(); return [...$base, 'items' => $items, 'page' => $page, 'pages' => $pages, 'total' => $total]; } /** @param array $input * @return array */ public function view(User $actor, ?string $id, array $input = []): array { abort_unless($actor->can('manage-portal'), 403); $v = Validator::make($input, ['revision' => 'nullable|integer|min:1', 'history_before' => 'nullable|integer|min:1'])->validate(); $base = ['installed' => $this->available(), 'id' => $id, 'states' => config('product-experiments.states'), 'changeKinds' => config('product-experiments.change_kinds'), 'decisions' => config('product-experiments.decisions'), 'labels' => config('product-experiments.fields'), 'catalogue' => $this->catalogue($actor)]; if ($id === null) { return [...$base, 'currentVersion' => 0, 'historical' => false, 'document' => null, 'values' => $this->defaults(), 'history' => [], 'historyNext' => null]; } abort_unless($base['installed'], 503, 'O registo de experiências ainda não está disponível neste destino.'); $row = DB::table('product_experiments')->where('id', $id)->firstOrFail(); $revision = (int) ($v['revision'] ?? $row->version); $selected = DB::table('product_experiment_revisions')->where('experiment_id', $id)->where('version', $revision)->firstOrFail(); $history = DB::table('product_experiment_revisions')->where('experiment_id', $id)->when(! empty($v['history_before']), fn ($q) => $q->where('version', '<', $v['history_before']))->orderByDesc('version')->limit(26)->get(['version', 'created_at', 'snapshot']); $next = $history->count() > 25 ? (int) $history[24]->version : null; $document = $this->decode($selected->snapshot); $historical = $revision !== (int) $row->version; $vocabulary = $historical ? $document['vocabulary'] : config('product-experiments'); return [...$base, 'states' => $vocabulary['states'], 'changeKinds' => $vocabulary['change_kinds'], 'decisions' => $vocabulary['decisions'], 'labels' => $vocabulary['fields'], 'currentVersion' => (int) $row->version, 'historical' => $historical, 'document' => $document, 'values' => $document['values'], 'historyNext' => $next, 'history' => $history->take(25)->map(function ($r) { $s = $this->decode($r->snapshot); return ['version' => (int) $r->version, 'at' => $r->created_at, 'actor' => $s['actor']['name'], 'state' => $s['values']['state'], 'reason' => $s['reason']]; })->all()]; } /** @return array */ private function defaults(): array { $values = []; foreach (config('product-experiments.fields') as $key => $label) { if (! is_string($key)) { throw new \LogicException('Experiment field identifiers must be strings.'); } $values[$key] = ''; } return [...$values, 'state' => 'draft']; } /** @param array $input * @return array */ public function save(User $actor, ?string $id, array $input): array { abort_unless($actor->can('manage-portal'), 403); abort_unless($this->available(), 503, 'O registo de experiências ainda não está disponível neste destino.'); $v = Validator::make($input, ['version' => ['required', 'integer', $id === null ? 'in:0' : 'min:1'], 'operation_key' => 'required|uuid', 'reason' => [Rule::requiredIf($id !== null || ($input['values']['state'] ?? null) === 'cancelled'), 'nullable', 'string', 'min:3', 'max:3000'], 'values' => ['required', 'array:'.implode(',', array_keys(config('product-experiments.fields')))], 'values.*' => 'nullable|string|max:5000', 'values.state' => ['required', Rule::in(array_keys(config('product-experiments.states')))], 'values.change_kind' => ['nullable', Rule::in(array_keys(config('product-experiments.change_kinds')))], 'values.decision' => ['nullable', Rule::in(array_keys(config('product-experiments.decisions')))], 'values.title' => 'nullable|string|max:200', 'values.reference' => 'nullable|string|max:1500', 'values.indicator_id' => 'nullable|string|max:100', 'values.indicator_unit' => 'nullable|string|max:100', 'values.period_from' => 'nullable|date_format:Y-m-d', 'values.period_to' => 'nullable|date_format:Y-m-d', 'values.observed_from' => 'nullable|date_format:Y-m-d', 'values.observed_to' => 'nullable|date_format:Y-m-d', 'confirm_plan_change' => 'sometimes|boolean', 'confirm_observation_plan' => 'sometimes|boolean', 'refresh_indicator' => 'sometimes|boolean', 'attach_report' => 'sometimes|boolean', 'remove_report' => 'sometimes|boolean', 'indicator_definition_version' => 'nullable|string|max:100'])->validate(); $values = $this->defaults(); foreach ($v['values'] as $key => $value) { $values[$key] = trim($value ?? ''); } $this->validateValues($values); $command = ['version' => (int) $v['version'], 'operation_key' => $v['operation_key'], 'reason' => trim($v['reason'] ?? ''), 'values' => $values, 'indicator_definition_version' => $v['indicator_definition_version'] ?? null]; foreach (['confirm_plan_change', 'confirm_observation_plan', 'refresh_indicator', 'attach_report', 'remove_report'] as $flag) { $command[$flag] = (bool) ($v[$flag] ?? false); } $hash = $this->hash($command); $existing = $id === null ? DB::table('product_experiments')->where('created_by', $actor->id)->where('creation_key', $v['operation_key'])->first() : DB::table('product_experiments')->where('id', $id)->firstOrFail(); if ($existing) { $replay = DB::table('product_experiment_revisions')->where('experiment_id', $existing->id)->where('actor_id', $actor->id)->where('operation_key', $v['operation_key'])->first(); if ($replay) { abort_unless(hash_equals($replay->request_hash, $hash), 409, 'Esta chave já foi usada para conteúdo diferente.'); return ['id' => $existing->id, 'version' => (int) $replay->version, 'replayed' => true]; } } // Capture an optional operational report in its own read-only consistent transaction, before mutation. $report = null; if ($v['attach_report'] ?? false) { if (($v['remove_report'] ?? false) || ! $values['observed_from'] || ! $values['observed_to']) { throw ValidationException::withMessages(['attach_report' => 'Indique o período observado; anexar e remover o relatório são acções distintas.']); } $report = $this->metrics->report($actor, ['from' => $values['observed_from'], 'to' => $values['observed_to'], 'service_code' => $values['report_service']]); } return DB::transaction(function () use ($actor, $id, $v, $values, $hash, $report) { if ($id === null) { DB::select('select pg_advisory_xact_lock(261103, hashtext(?))', [$actor->id.':'.$v['operation_key']]); $row = DB::table('product_experiments')->where('created_by', $actor->id)->where('creation_key', $v['operation_key'])->lockForUpdate()->first(); } else { $row = DB::table('product_experiments')->where('id', $id)->lockForUpdate()->firstOrFail(); } if ($row) { $replay = DB::table('product_experiment_revisions')->where('experiment_id', $row->id)->where('actor_id', $actor->id)->where('operation_key', $v['operation_key'])->first(); if ($replay) { abort_unless(hash_equals($replay->request_hash, $hash), 409, 'Esta chave já foi usada para conteúdo diferente.'); return ['id' => $row->id, 'version' => (int) $replay->version, 'replayed' => true]; } abort_unless((int) $row->version === (int) $v['version'], 409, 'O registo foi alterado. Consulte a versão actual e compare os campos antes de reaplicar as suas alterações.'); } $previous = $row ? $this->decode(DB::table('product_experiment_revisions')->where('experiment_id', $row->id)->where('version', $row->version)->value('snapshot')) : null; $indicator = $previous['indicator'] ?? null; if ($values['indicator_id'] === 'manual') { if (($indicator['id'] ?? null) !== 'manual' || $indicator['title'] !== $values['indicator_title'] || $indicator['unit'] !== $values['indicator_unit'] || $indicator['source'] !== $values['indicator_definition']) { $indicator = ['id' => 'manual', 'title' => $values['indicator_title'], 'unit' => $values['indicator_unit'], 'source' => $values['indicator_definition'], 'limits' => 'Definição declarada pelo autor; não é medição automática do portal.', 'definition_version' => config('product-experiments.version')]; } } elseif ($values['indicator_id'] === '') { $indicator = null; } elseif (($indicator['id'] ?? null) !== $values['indicator_id'] || ($v['refresh_indicator'] ?? false)) { $catalogue = $this->catalogue($actor); if (($v['indicator_definition_version'] ?? null) !== $catalogue['definition_version']) { throw ValidationException::withMessages(['indicator_definition_version' => 'A definição dos indicadores mudou. Consulte a edição actual antes de a adoptar.']); } $selected = null; foreach ($catalogue['items'] as $candidate) { if ($candidate['id'] === $values['indicator_id']) { $selected = $candidate; break; } } if (! $selected) { throw ValidationException::withMessages(['values.indicator_id' => 'Escolha um indicador existente ou documente um indicador próprio.']); } $indicator = [...$selected, 'definition_version' => $catalogue['definition_version']]; } $plan = Arr::only($values, ['change_kind', 'reference', 'hypothesis', 'indicator_id', 'period_from', 'period_to', 'segment', 'control', 'variant', 'assignment', 'criterion', 'transparency', 'consent', 'security', 'equality', 'preserves_requirements']); $planHash = $this->hash([...$plan, 'indicator' => $indicator]); $registration = $previous['registration'] ?? ['first_at' => null, 'original_plan_sha256' => null, 'amendments' => 0, 'amended_after_observation' => false]; $changed = $registration['first_at'] !== null && $previous['plan_sha256'] !== $planHash; if ($changed && ! ($v['confirm_plan_change'] ?? false)) { throw ValidationException::withMessages(['confirm_plan_change' => 'O plano já foi registado. Confirme a nova versão; as observações anteriores continuam no histórico e não comprovam o plano alterado.']); } if ($changed) { $registration['amendments']++; $registration['amended_after_observation'] = $registration['amended_after_observation'] || in_array($previous['values']['state'], ['observing', 'reviewed'], true); } $at = CarbonImmutable::now('UTC')->format('Y-m-d\TH:i:s.uP'); if ($registration['first_at'] === null && in_array($values['state'], ['planned', 'observing', 'reviewed'], true)) { $registration['first_at'] = $at; $registration['original_plan_sha256'] = $planHash; } $snapshotReport = ($v['remove_report'] ?? false) ? null : ($report ?? $previous['operational_report'] ?? null); $resultFields = ['observed_from', 'observed_to', 'control_result', 'variant_result', 'result', 'limitations', 'evidence', 'decision', 'decision_reason']; $resultHash = $this->hash(Arr::only($values, $resultFields)); $observationPlan = $previous['observation_plan_sha256'] ?? null; if ($previous === null || $previous['result_sha256'] !== $resultHash || ($v['confirm_observation_plan'] ?? false)) { $observationPlan = $planHash; } if (in_array($values['state'], ['observing', 'reviewed'], true) && $observationPlan !== $planHash) { throw ValidationException::withMessages(['values.result' => 'As observações permanecem ligadas ao plano anterior. Reveja o resultado e documente a sua aplicação ao plano actual antes de registar a decisão.']); } if ($observationPlan === $planHash) { foreach (['observed_from', 'observed_to'] as $key) { if ($values[$key] && ((! $values['period_from'] || ! $values['period_to']) || $values[$key] < $values['period_from'] || $values[$key] > $values['period_to'])) { throw ValidationException::withMessages(['values.'.$key => 'A observação deve corresponder ao período do plano a que fica ligada.']); } } } $snapshot = ['definition_version' => config('product-experiments.version'), 'vocabulary' => Arr::only(config('product-experiments'), ['states', 'change_kinds', 'decisions', 'fields']), 'version' => (int) ($row->version ?? 0) + 1, 'at' => $at, 'actor' => ['id' => $actor->id, 'name' => $actor->name], 'reason' => $v['reason'] ?? '', 'values' => $values, 'indicator' => $indicator, 'plan_sha256' => $planHash, 'registration' => $registration, 'result_sha256' => $resultHash, 'observation_plan_sha256' => $observationPlan, 'operational_report' => $snapshotReport, 'report_scope' => 'operational_cohort_not_experiment_arm', 'observation_reaffirmed_for_plan' => (bool) ($v['confirm_observation_plan'] ?? false), 'report_matches_observation' => $snapshotReport === null ? null : Arr::only($snapshotReport['filters'], ['from', 'to', 'service_code']) === ['from' => $values['observed_from'], 'to' => $values['observed_to'], 'service_code' => $values['report_service'] === '' ? null : $values['report_service']]]; if ($previous && $previous['values'] === $values && $previous['indicator'] === $indicator && $previous['operational_report'] === $snapshotReport && $previous['observation_plan_sha256'] === $observationPlan) { return ['id' => $row->id, 'version' => (int) $row->version, 'unchanged' => true, 'replayed' => false]; } $experiment = $row->id ?? (string) Str::uuid(); $revision = (string) Str::uuid(); if ($row === null) { DB::table('product_experiments')->insert(['id' => $experiment, 'version' => 1, 'state' => $values['state'], 'created_by' => $actor->id, 'updated_by' => $actor->id, 'creation_key' => $v['operation_key'], 'created_at' => now(), 'updated_at' => now()]); } else { DB::table('product_experiments')->where('id', $experiment)->update(['version' => $snapshot['version'], 'state' => $values['state'], 'updated_by' => $actor->id, 'updated_at' => now()]); } $snapshotHash = $this->hash($snapshot); DB::table('product_experiment_revisions')->insert(['id' => $revision, 'experiment_id' => $experiment, 'version' => $snapshot['version'], 'actor_id' => $actor->id, 'operation_key' => $v['operation_key'], 'request_hash' => $hash, 'snapshot_sha256' => $snapshotHash, 'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR)), 'created_at' => now()]); DB::table('audit_events')->insert(['source_system' => 'product-experiments', 'entity_type' => 'product_experiments', 'entity_id' => $experiment, 'actor_user_id' => $actor->id, 'action' => 'experiment_revision_recorded', 'before_data' => json_encode(['version' => (int) ($row->version ?? 0), 'state' => $row->state ?? null]), 'after_data' => json_encode(['version' => $snapshot['version'], 'state' => $values['state'], 'revision_id' => $revision, 'snapshot_sha256' => $snapshotHash])]); return ['id' => $experiment, 'version' => $snapshot['version'], 'replayed' => false]; }); } /** @param array $values */ private function validateValues(array $values): void { $errors = []; $complete = in_array($values['state'], ['planned', 'observing', 'reviewed'], true); if ($complete) { foreach (['title', 'change_kind', 'reference', 'hypothesis', 'indicator_id', 'period_from', 'period_to', 'segment', 'control', 'variant', 'assignment', 'criterion', 'transparency', 'consent', 'security', 'equality'] as $key) { if ($values[$key] === '') { $errors['values.'.$key] = 'Preencha este campo para registar o plano.'; } } if ($values['preserves_requirements'] !== 'yes') { $errors['values.preserves_requirements'] = 'Uma experiência não pode reduzir transparência, consentimento, segurança ou igualdade de tratamento.'; } if ($values['indicator_id'] === 'manual') { foreach (['indicator_title', 'indicator_definition', 'indicator_unit'] as $key) { if ($values[$key] === '') { $errors['values.'.$key] = 'Documente o indicador próprio e a sua base de cálculo.'; } } } } if ($values['period_from'] && $values['period_to'] && $values['period_from'] > $values['period_to']) { $errors['values.period_to'] = 'O fim não pode anteceder o início.'; } if ($values['state'] === 'reviewed') { foreach (['observed_from', 'observed_to', 'control_result', 'variant_result', 'result', 'limitations', 'evidence', 'decision', 'decision_reason'] as $key) { if ($values[$key] === '') { $errors['values.'.$key] = 'Documente a observação e a decisão; um resultado inconclusivo pode ser declarado com as suas limitações.'; } } } foreach (['observed_from', 'observed_to'] as $key) { if ($values[$key] && $values[$key] > CarbonImmutable::now('UTC')->toDateString()) { $errors['values.'.$key] = 'Uma observação efectivamente realizada não pode ter data futura.'; } } if ($values['observed_from'] && $values['observed_to'] && $values['observed_from'] > $values['observed_to']) { $errors['values.observed_to'] = 'O fim observado não pode anteceder o início.'; } if ($values['state'] === 'observing' && ($values['observed_from'] === '' || $values['observed_from'] > CarbonImmutable::now('UTC')->toDateString())) { $errors['values.observed_from'] = 'Indique o início efectivamente observado.'; } if ($errors !== []) { throw ValidationException::withMessages($errors); } } /** @return array */ private function decode(string $encrypted): array { return json_decode(Crypt::decryptString($encrypted), true, 512, JSON_THROW_ON_ERROR); } /** @param array $value */ private function hash(array $value): string { return hash('sha256', json_encode(Arr::sortRecursive($value), JSON_THROW_ON_ERROR)); } }