> */ public function listing(): array { $rows = DB::table('desk_service_versions')->selectRaw('DISTINCT ON (code) code, version, definition') ->orderBy('code')->orderByDesc('version')->get()->keyBy('code'); $services = []; foreach (config('virtual-desk.services') as $code => $initial) { $row = $rows->get($code); $services[] = ['code' => $code, 'version' => (int) ($row->version ?? 0), 'editable' => true, 'fixedTypes' => array_map(fn (array $field) => $field['type'], $initial['fields']), 'definition' => $row ? json_decode($row->definition, true, 512, JSON_THROW_ON_ERROR) : $initial]; } foreach ($rows as $code => $row) { if (! array_key_exists($code, config('virtual-desk.services'))) { $services[] = ['code' => $code, 'version' => (int) $row->version, 'editable' => false, 'definition' => json_decode($row->definition, true, 512, JSON_THROW_ON_ERROR)]; } } return $services; } /** @return array */ public function history(string $code, int $requestedPage): array { abort_unless(array_key_exists($code, config('virtual-desk.services')) || DB::table('desk_service_versions')->where('code', $code)->exists(), 404); $query = DB::table('desk_service_versions')->where('code', $code); $total = (clone $query)->count(); $pages = max(1, (int) ceil($total / 10)); $page = min(max(1, $requestedPage), $pages); $rows = $query->orderByDesc('version')->forPage($page, 10)->get(['id', 'version', 'status', 'author_id', 'published_by', 'published_at', 'definition', 'source']); $events = DB::table('audit_events')->where('source_system', 'virtual-desk')->where('entity_type', 'desk_service_versions') ->where('action', 'service_definition_version_published')->whereIn('entity_id', $rows->pluck('id')->all()) ->get(['entity_id', 'after_data'])->keyBy('entity_id'); return ['page' => $page, 'pages' => $pages, 'total' => $total, 'data' => $rows->map(function ($row) use ($events): array { $event = $events->get($row->id); $audit = $event ? json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR) : []; return ['id' => $row->id, 'version' => (int) $row->version, 'status' => $row->status, 'author_id' => $row->author_id, 'published_by' => $row->published_by, 'published_at' => $row->published_at, 'definition' => json_decode($row->definition, true, 512, JSON_THROW_ON_ERROR), 'source' => json_decode($row->source, true, 512, JSON_THROW_ON_ERROR), 'reason' => $audit['reason'] ?? null]; })->all()]; } /** @param array $input */ public function publish(User $actor, string $code, array $input): \stdClass { abort_unless(array_key_exists($code, config('virtual-desk.services')), 404); $data = Validator::make($input, [ 'base_version' => ['required', 'integer', 'min:0'], 'confirm' => ['accepted'], 'reason' => ['required', 'string', 'min:20', 'max:2000'], 'definition' => ['required', 'array:title,theme,profile,stage,entity,result,fields'], 'definition.title' => ['required', 'string', 'max:200'], 'definition.theme' => ['required', 'string', 'max:150'], 'definition.profile' => ['required', 'string', 'max:200'], 'definition.stage' => ['required', 'string', 'max:20'], 'definition.entity' => ['required', 'string', 'max:300'], 'definition.result' => ['required', 'string', 'max:2000'], 'definition.fields' => ['required', 'array', 'min:1', 'max:60'], 'definition.fields.*' => ['required', 'array:label,type,required,min,options,when'], 'definition.fields.*.label' => ['required', 'string', 'max:300'], 'definition.fields.*.type' => ['required', Rule::in(['text', 'textarea', 'email', 'tel', 'url', 'number', 'date', 'select', 'territory'])], 'definition.fields.*.required' => ['required', 'boolean'], 'definition.fields.*.min' => ['sometimes', 'numeric', 'min:0', 'max:999999999999'], 'definition.fields.*.options' => ['sometimes', 'array', 'min:1', 'max:100'], 'definition.fields.*.options.*' => ['required', 'string', 'max:300'], 'definition.fields.*.when' => ['sometimes', 'array:field,equals', 'required_array_keys:field,equals'], 'definition.fields.*.when.field' => ['required_with:definition.fields.*.when', 'string', 'max:64'], 'definition.fields.*.when.equals' => ['required_with:definition.fields.*.when', 'string', 'max:1000'], ])->validate(); $this->checkFields($data['definition']['fields']); return DB::transaction(function () use ($actor, $code, $data): \stdClass { DB::select('select pg_advisory_xact_lock(261008, 2)'); $latest = DB::table('desk_service_versions')->where('code', $code)->orderByDesc('version')->first(); $previous = $latest ? json_decode($latest->definition, true, 512, JSON_THROW_ON_ERROR) : config('virtual-desk.services.'.$code); if ($data['definition']['stage'] !== $previous['stage']) { throw ValidationException::withMessages(['definition.stage' => 'A etapa pertence ao percurso existente e não pode ser alterada neste editor.']); } foreach (config('virtual-desk.services.'.$code.'.fields') as $key => $canonical) { if (isset($data['definition']['fields'][$key]) && $data['definition']['fields'][$key]['type'] !== $canonical['type']) { throw ValidationException::withMessages(['definition.fields.'.$key.'.type' => 'Este identificador é interpretado pelo motor existente. Conserve o tipo ou acrescente um novo campo com outro identificador.']); } } // Retry of the same confirmed publication preserves the first version and authorship. if ($latest && (int) $latest->version === (int) $data['base_version'] + 1 && $latest->status === 'published' && $this->definitionHash(json_decode($latest->definition, true, 512, JSON_THROW_ON_ERROR)) === $this->definitionHash($data['definition'])) { return $latest; } abort_unless((int) ($latest->version ?? 0) === (int) $data['base_version'], 409, 'O formulário foi alterado. Recarregue e compare a versão actual antes de publicar.'); if ($latest && $latest->status === 'published' && $this->definitionHash($previous) === $this->definitionHash($data['definition'])) { throw ValidationException::withMessages(['definition' => 'Não há alterações na definição para publicar uma nova versão.']); } $id = (string) Str::uuid(); DB::table('desk_service_versions')->insert([ 'id' => $id, 'code' => $code, 'version' => (int) $data['base_version'] + 1, 'status' => 'published', 'definition' => json_encode($data['definition'], JSON_THROW_ON_ERROR), 'source' => $latest->source ?? json_encode(config('virtual-desk.source'), JSON_THROW_ON_ERROR), 'author_id' => $actor->id, 'published_by' => $actor->id, 'published_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); DB::table('audit_events')->insert([ 'source_system' => 'virtual-desk', 'entity_type' => 'desk_service_versions', 'entity_id' => $id, 'actor_user_id' => $actor->id, 'action' => 'service_definition_version_published', 'before_data' => json_encode(['id' => $latest?->id, 'version' => (int) $data['base_version']], JSON_THROW_ON_ERROR), 'after_data' => json_encode(['code' => $code, 'version' => (int) $data['base_version'] + 1, 'reason' => $data['reason'], 'effect' => 'new_requests_only', 'definition_sha256' => $this->definitionHash($data['definition'])], JSON_THROW_ON_ERROR), ]); return DB::table('desk_service_versions')->where('id', $id)->firstOrFail(); }); } /** @param array $definition */ private function definitionHash(array $definition): string { return hash('sha256', json_encode(Arr::sortRecursive($definition), JSON_THROW_ON_ERROR)); } /** @param array> $fields */ private function checkFields(array $fields): void { foreach ($fields as $key => $field) { $path = 'definition.fields.'.$key; if (! preg_match('/^[a-z][a-z0-9_]{0,63}$/D', (string) $key) || in_array($key, ['country', 'declared_origin_country_code', 'context_reuse', 'context_opportunity', 'context_opportunity_snapshot'], true)) { throw ValidationException::withMessages(['definition.fields' => 'Use identificadores de campo distintos dos campos comuns de país e contexto, com letras minúsculas, números e sublinhado.']); } if ($field['type'] === 'select' && empty($field['options'])) { throw ValidationException::withMessages([$path.'.options' => 'Indique as opções deste campo.']); } if ((isset($field['min']) && $field['type'] !== 'number') || (isset($field['options']) && $field['type'] !== 'select')) { throw ValidationException::withMessages([$path => 'O mínimo aplica-se apenas a números e as opções apenas a listas de respostas.']); } foreach (array_keys($field['options'] ?? []) as $option) { if (! preg_match('/^[a-zA-Z0-9_-]{1,80}$/D', (string) $option)) { throw ValidationException::withMessages([$path.'.options' => 'Use códigos de opção sem espaços ou pontuação especial.']); } } $visited = [$key]; $node = $field; while (isset($node['when'])) { $parent = $node['when']['field']; if (! isset($fields[$parent]) || in_array($parent, $visited, true)) { throw ValidationException::withMessages([$path.'.when' => 'A condição deve referir outro campo existente, sem dependências circulares.']); } if ($fields[$parent]['type'] === 'number') { throw ValidationException::withMessages([$path.'.when' => 'As condições actuais comparam respostas exactas. Use uma lista de opções como origem da condição numérica.']); } if ($fields[$parent]['type'] === 'select' && ! array_key_exists($node['when']['equals'], $fields[$parent]['options'] ?? [])) { throw ValidationException::withMessages([$path.'.when' => 'O valor da condição deve corresponder a uma opção do campo escolhido.']); } $visited[] = $parent; $node = $fields[$parent]; } } if (isset($fields['territory_id'])) { if (! isset($fields['location_source'])) { throw ValidationException::withMessages(['definition.fields.location_source' => 'A localização exige a respectiva fonte. Mantenha o campo da fonte ou retire também a localização.']); } $locationConditions = $this->conditions('territory_id', $fields); $sourceConditions = $this->conditions('location_source', $fields); if (array_diff_assoc($sourceConditions, $locationConditions) !== []) { throw ValidationException::withMessages(['definition.fields.location_source.when' => 'A fonte tem de estar disponível sempre que a localização se aplica. Use as mesmas condições ou condições mais abrangentes.']); } } } /** Conditions have already been checked for valid references and cycles. * @param array> $fields * @return array */ private function conditions(string $key, array $fields): array { $conditions = []; while (isset($fields[$key]['when'])) { $when = $fields[$key]['when']; $conditions[$when['field']] = $when['equals']; $key = $when['field']; } return $conditions; } }