308 lines
22 KiB
PHP
308 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/** Versioned declarations and evidence; this registry never assigns visitors or deploys changes. */
|
|
final class ProductExperiments
|
|
{
|
|
public function __construct(private readonly ProductUsageMetrics $metrics) {}
|
|
|
|
public function available(): bool
|
|
{
|
|
return Schema::hasTable('product_experiments') && Schema::hasTable('product_experiment_revisions');
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
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<string,mixed> $input
|
|
* @return array<string,mixed> */
|
|
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<string,mixed> $input
|
|
* @return array<string,mixed> */
|
|
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<string,string> */
|
|
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<string,mixed> $input
|
|
* @return array<string,mixed> */
|
|
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<string,string> $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<string,mixed> */
|
|
private function decode(string $encrypted): array
|
|
{
|
|
return json_decode(Crypt::decryptString($encrypted), true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
/** @param array<array-key,mixed> $value */
|
|
private function hash(array $value): string
|
|
{
|
|
return hash('sha256', json_encode(Arr::sortRecursive($value), JSON_THROW_ON_ERROR));
|
|
}
|
|
}
|