> */ 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 */ 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 $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 $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 $left * @param array $right */ public function sameDefinition(array $left, array $right): bool { return $this->definitionHash($left) === $this->definitionHash($right); } /** @param array $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> $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> $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['operator'] ?? 'equals').':'.$when['equals']; $key = $when['field']; } return $conditions; } }