Files

275 lines
19 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class DeskServiceEditor
{
/** @return list<array<string,mixed>> */
public function listing(): array
{
$availability = app(DeskServiceAvailability::class);
$active = $availability->current()->get()->keyBy('code');
$upcoming = $availability->upcoming()->get()->groupBy('code')->map(fn ($rows) => $rows->map(fn ($row) => ['version' => (int) $row->version, 'effective_from' => $row->effective_from])->all());
$rows = DB::table('desk_service_versions')->selectRaw('DISTINCT ON (code) code, version, definition')
->selectRaw($availability->dateProjection())
->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,
'effective_from' => $row->effective_from ?? null, 'activeVersion' => (int) ($active->get($code)->version ?? 0),
'upcoming' => $upcoming->get($code, []),
'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,
'effective_from' => $row->effective_from ?? null, 'activeVersion' => (int) ($active->get($code)->version ?? 0),
'upcoming' => $upcoming->get($code, []),
'definition' => json_decode($row->definition, true, 512, JSON_THROW_ON_ERROR)];
}
}
return $services;
}
/** @return array<string,mixed> */
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)->select(['id', 'version', 'status', 'author_id', 'published_by', 'published_at', 'definition', 'source'])
->selectRaw(app(DeskServiceAvailability::class)->dateProjection())->get();
$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),
'effective_from' => $row->effective_from,
'source' => json_decode($row->source, true, 512, JSON_THROW_ON_ERROR), 'reason' => $audit['reason'] ?? null];
})->all()];
}
/** @param array<string,mixed> $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'],
'effective_from' => ['nullable', 'date_format:Y-m-d'],
'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,statement,mime_types,max_size_kb,max_files'],
'definition.fields.*.label' => ['required', 'string', 'max:300'],
'definition.fields.*.type' => ['required', Rule::in(['text', 'textarea', 'email', 'tel', 'url', 'number', 'date', 'select', 'multiselect', 'consent', 'territory', 'attachment'])],
'definition.fields.*.mime_types' => ['sometimes', 'array', 'list', 'min:1', 'max:4'],
'definition.fields.*.mime_types.*' => ['required', 'string', Rule::in(DeskAttachments::MIME_TYPES)],
'definition.fields.*.max_size_kb' => ['sometimes', 'integer', 'min:1', 'max:10240'],
'definition.fields.*.max_files' => ['sometimes', 'integer', 'min:1', 'max:100'],
'definition.fields.*.statement' => ['sometimes', 'string', 'min:3', 'max:5000'],
'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,operator', 'required_array_keys:field,equals'],
'definition.fields.*.when.operator' => ['sometimes', Rule::in(['equals', 'contains'])],
'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)');
$scheduled = app(DeskServiceAvailability::class)->scheduled();
if (! $scheduled && ($data['effective_from'] ?? null) !== null) {
throw ValidationException::withMessages(['effective_from' => 'Instale a actualização das datas de entrada em vigor antes de agendar uma versão.']);
}
$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->sameEffectiveRequest($latest, $data, $scheduled)
&& $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 (($data['effective_from'] ?? null) !== null && $data['effective_from'] < now()->toDateString()) {
throw ValidationException::withMessages(['effective_from' => 'Uma nova publicação começa hoje ou numa data futura; não altera a vigência de pedidos anteriores.']);
}
$effective = ($data['effective_from'] ?? null) ?: now()->toDateString();
if ($latest && $latest->status === 'published' && $this->definitionHash($previous) === $this->definitionHash($data['definition'])
&& (! $scheduled || ($latest->effective_from ?? null) === $effective || (! ($data['effective_from'] ?? null) && (! ($latest->effective_from ?? null) || $latest->effective_from <= now()->toDateString())))) {
throw ValidationException::withMessages(['definition' => 'Não há alterações na definição ou na data de início 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',
...($scheduled ? ['effective_from' => $effective] : []),
'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', 'effective_from' => $scheduled ? $effective : null,
'effective_request_mode' => ($data['effective_from'] ?? null) ? 'dated' : 'immediate',
'selection' => 'latest_started_effective_date_then_version; undated_legacy_fallback',
'definition_sha256' => $this->definitionHash($data['definition'])], JSON_THROW_ON_ERROR),
]);
return DB::table('desk_service_versions')->where('id', $id)->firstOrFail();
});
}
/** @param array<array-key,mixed> $definition */
private function definitionHash(array $definition): string
{
return hash('sha256', json_encode(Arr::sortRecursive($definition), JSON_THROW_ON_ERROR));
}
/** PostgreSQL JSONB does not retain object-key order.
* @param array<array-key,mixed> $left
* @param array<array-key,mixed> $right */
public function sameDefinition(array $left, array $right): bool
{
return $this->definitionHash($left) === $this->definitionHash($right);
}
/** @param array<string,mixed> $data */
private function sameEffectiveRequest(\stdClass $latest, array $data, bool $scheduled): bool
{
if (! $scheduled || ($latest->effective_from ?? null) === null) {
return ! ($data['effective_from'] ?? null);
}
if ($data['effective_from'] ?? null) {
return $latest->effective_from === $data['effective_from'];
}
$event = DB::table('audit_events')->where('source_system', 'virtual-desk')->where('entity_type', 'desk_service_versions')
->where('entity_id', $latest->id)->where('action', 'service_definition_version_published')->orderByDesc('id')->value('after_data');
return $event && (json_decode($event, true, 512, JSON_THROW_ON_ERROR)['effective_request_mode'] ?? null) === 'immediate';
}
/** @param array<string,array<string,mixed>> $fields */
private function checkFields(array $fields): void
{
foreach ($fields as $key => $field) {
$path = 'definition.fields.'.$key;
if ($field['type'] === 'attachment') {
if (count(array_unique($field['mime_types'] ?? [])) !== count($field['mime_types'] ?? [])) {
throw ValidationException::withMessages([$path.'.mime_types' => 'Escolha cada formato apenas uma vez nesta pergunta.']);
}
if (! isset($field['mime_types'],$field['max_size_kb'],$field['max_files'])) {
throw ValidationException::withMessages([$path => 'Defina os formatos, tamanho por ficheiro e quantidade máxima desta pergunta.']);
}
if (! app(DeskAttachments::class)->scopedLinksInstalled()) {
throw ValidationException::withMessages([$path => 'Instale a actualização dos vínculos documentais antes de publicar perguntas de anexos. Os formulários existentes continuam disponíveis.']);
}
} elseif (array_intersect(['mime_types', 'max_size_kb', 'max_files'], array_keys($field))) {
throw ValidationException::withMessages([$path => 'Os limites de ficheiros pertencem apenas a perguntas de anexos.']);
}
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 (in_array($field['type'], ['select', 'multiselect'], true) && empty($field['options'])) {
throw ValidationException::withMessages([$path.'.options' => 'Indique as opções deste campo.']);
}
if ((isset($field['min']) && $field['type'] !== 'number') || (isset($field['options']) && ! in_array($field['type'], ['select', 'multiselect'], true))) {
throw ValidationException::withMessages([$path => 'O mínimo aplica-se apenas a números e as opções apenas a listas de respostas.']);
}
if (($field['type'] === 'consent' && trim($field['statement'] ?? '') === '') || (isset($field['statement']) && $field['type'] !== 'consent')) {
throw ValidationException::withMessages([$path.'.statement' => 'Uma declaração de consentimento exige o texto exacto a apresentar; esse texto pertence apenas a campos de consentimento.']);
}
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 (in_array($fields[$parent]['type'], ['number', 'attachment'], true)) {
throw ValidationException::withMessages([$path.'.when' => 'Use uma resposta de texto, lista ou declaração como origem da condição; números e anexos não são respostas comparáveis por estas condições.']);
}
$operator = $node['when']['operator'] ?? 'equals';
if (($fields[$parent]['type'] === 'multiselect') !== ($operator === 'contains')) {
throw ValidationException::withMessages([$path.'.when' => 'Use inclusão de uma opção para uma selecção múltipla e igualdade para respostas simples.']);
}
if (in_array($fields[$parent]['type'], ['select', 'multiselect'], true) && ! 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.']);
}
if ($fields[$parent]['type'] === 'consent' && ! in_array($node['when']['equals'], ['accepted', 'declined'], true)) {
throw ValidationException::withMessages([$path.'.when' => 'Compare a declaração com accepted ou declined; uma resposta por preencher não é aceitação.']);
}
$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<string,array<string,mixed>> $fields
* @return array<string,string> */
private function conditions(string $key, array $fields): array
{
$conditions = [];
while (isset($fields[$key]['when'])) {
$when = $fields[$key]['when'];
$conditions[$when['field']] = ($when['operator'] ?? 'equals').':'.$when['equals'];
$key = $when['field'];
}
return $conditions;
}
}