diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 443cf6f..62babf3 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -78,6 +78,10 @@ jobs: - name: Regenerate OpenAPI run: composer run openapi + - name: Regenerate TypeScript OpenAPI types + if: steps.check_typescript_openapi.outputs.files_exists == 'true' + run: npm run typescript:generate + - name: Check openapi*.json and typescript changes run: | bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please run \"composer run openapi\" and commit the openapi*.json files and, if applicable, src/types/openapi/openapi*.ts. See the diff below.' && exit 1)" diff --git a/REUSE.toml b/REUSE.toml index 64b0df4..db5a88e 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -27,6 +27,7 @@ path = [ "package-lock.json", "package.json", "psalm.xml", + "src/types/openapi/*.ts", "tests/integration/composer.json", "tests/integration/composer.lock", "tests/integration/features/**/*.feature", diff --git a/appinfo/info.xml b/appinfo/info.xml index 4e861fd..f1dd01d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -49,6 +49,7 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform OCA\ProfileFields\Command\Data\Export + OCA\ProfileFields\Command\Data\Import OCA\ProfileFields\Command\Data\Clear OCA\ProfileFields\Command\Developer\Reset diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 42c7f20..bafae82 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -38,18 +38,35 @@ public function register(IRegistrationContext $context): void { #[\Override] public function boot(IBootContext $context): void { - $request = $context->getServerContainer()->get(IRequest::class); - $path = $request->getPathInfo(); - $requestUri = $request->getRequestUri(); + try { + $context->injectFn($this->bootWithRequest(...)); + } catch (\Throwable) { + return; + } + } + + private function bootWithRequest(IRequest $request): void { + $path = $this->readRequestString(static fn (): string|false => $request->getPathInfo()); + $requestUri = $this->readRequestString(static fn (): string => $request->getRequestUri()); if ( - ($path !== false && str_contains($path, '/settings/users')) - || str_contains($requestUri, '/settings/users') + ($path !== null && str_contains($path, '/settings/users')) + || ($requestUri !== null && str_contains($requestUri, '/settings/users')) ) { self::loadUserManagementAssets(); } } + private function readRequestString(callable $reader): ?string { + try { + $value = $reader(); + } catch (\Throwable) { + return null; + } + + return is_string($value) && $value !== '' ? $value : null; + } + public static function loadUserManagementAssets(): void { if (self::$userManagementAssetsLoaded) { return; diff --git a/lib/Command/Data/Export.php b/lib/Command/Data/Export.php index c119858..30391d8 100644 --- a/lib/Command/Data/Export.php +++ b/lib/Command/Data/Export.php @@ -19,6 +19,8 @@ use Symfony\Component\Console\Output\OutputInterface; class Export extends Command { + private const SCHEMA_VERSION = 1; + public function __construct( private FieldDefinitionService $fieldDefinitionService, private FieldValueMapper $fieldValueMapper, @@ -47,17 +49,30 @@ protected function configure(): void { #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { - $payload = [ - 'exported_at' => gmdate(DATE_ATOM), - 'definitions' => array_map( - static fn ($definition): array => $definition->jsonSerialize(), - $this->fieldDefinitionService->findAllOrdered(), - ), - 'values' => array_map( - fn (FieldValue $value): array => $this->serializeValue($value), - $this->fieldValueMapper->findAllOrdered(), - ), - ]; + try { + $definitions = $this->fieldDefinitionService->findAllOrdered(); + $fieldKeysByDefinitionId = []; + foreach ($definitions as $definition) { + $fieldKeysByDefinitionId[$definition->getId()] = $definition->getFieldKey(); + } + + $payload = [ + 'schema_version' => self::SCHEMA_VERSION, + 'exported_at' => gmdate(DATE_ATOM), + 'definitions' => array_map( + static fn ($definition): array => $definition->jsonSerialize(), + $definitions, + ), + 'values' => array_map( + fn (FieldValue $value): array => $this->serializeValue($value, $fieldKeysByDefinitionId), + $this->fieldValueMapper->findAllOrdered(), + ), + ]; + } catch (\Throwable $exception) { + $output->writeln('Failed to build export payload.'); + $output->writeln(sprintf('%s', $exception->getMessage())); + return self::FAILURE; + } try { $json = json_encode( @@ -88,16 +103,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * @return array */ - private function serializeValue(FieldValue $value): array { + private function serializeValue(FieldValue $value, array $fieldKeysByDefinitionId): array { try { $decodedValue = json_decode($value->getValueJson(), true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $exception) { throw new \RuntimeException('Failed to decode stored field value JSON.', 0, $exception); } + $fieldKey = $fieldKeysByDefinitionId[$value->getFieldDefinitionId()] ?? null; + if (!is_string($fieldKey) || $fieldKey === '') { + throw new \RuntimeException(sprintf('Could not resolve field_key for field definition %d.', $value->getFieldDefinitionId())); + } + return [ 'id' => $value->getId(), 'field_definition_id' => $value->getFieldDefinitionId(), + 'field_key' => $fieldKey, 'user_uid' => $value->getUserUid(), 'value' => $decodedValue, 'current_visibility' => $value->getCurrentVisibility(), diff --git a/lib/Command/Data/Import.php b/lib/Command/Data/Import.php new file mode 100644 index 0000000..d70962b --- /dev/null +++ b/lib/Command/Data/Import.php @@ -0,0 +1,98 @@ +setName('profile_fields:data:import') + ->setDescription('Import persisted Profile Fields definitions and values from a JSON payload') + ->addOption( + name: 'input', + shortcut: 'i', + mode: InputOption::VALUE_REQUIRED, + description: 'Read the JSON import payload from a file', + ) + ->addOption( + name: 'dry-run', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Validate the payload and report the import summary without persisting data', + ); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + $sourcePath = $input->getOption('input'); + if (!is_string($sourcePath) || $sourcePath === '') { + $output->writeln('Please provide --input with a JSON file path.'); + return self::FAILURE; + } + + $rawPayload = @file_get_contents($sourcePath); + if ($rawPayload === false) { + $output->writeln(sprintf('Could not read import payload from %s.', $sourcePath)); + return self::FAILURE; + } + + try { + $decodedPayload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + $output->writeln('Failed to decode import payload JSON.'); + $output->writeln(sprintf('%s', $exception->getMessage())); + return self::FAILURE; + } + + if (!is_array($decodedPayload)) { + $output->writeln('Import payload must decode to a JSON object.'); + return self::FAILURE; + } + + try { + $summary = $this->dataImportService->import($decodedPayload, (bool)$input->getOption('dry-run')); + } catch (\Throwable $throwable) { + $output->writeln('Import validation failed.'); + $output->writeln(sprintf('%s', $throwable->getMessage())); + return self::FAILURE; + } + + $output->writeln((bool)$input->getOption('dry-run') + ? 'Profile Fields data import dry-run completed.' + : 'Profile Fields data imported.'); + $output->writeln(sprintf( + 'Definitions: %d created, %d updated, %d skipped.', + $summary['created_definitions'], + $summary['updated_definitions'], + $summary['skipped_definitions'], + )); + $output->writeln(sprintf( + 'Values: %d created, %d updated, %d skipped.', + $summary['created_values'], + $summary['updated_values'], + $summary['skipped_values'], + )); + + return self::SUCCESS; + } +} diff --git a/lib/Listener/BeforeTemplateRenderedListener.php b/lib/Listener/BeforeTemplateRenderedListener.php index 5dbbc6b..7ce18fa 100644 --- a/lib/Listener/BeforeTemplateRenderedListener.php +++ b/lib/Listener/BeforeTemplateRenderedListener.php @@ -16,7 +16,7 @@ /** * @template-implements IEventListener */ -readonly class BeforeTemplateRenderedListener implements IEventListener { +class BeforeTemplateRenderedListener implements IEventListener { #[\Override] public function handle(Event $event): void { if ($event::class !== '\\OCA\\Settings\\Events\\BeforeTemplateRenderedEvent') { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 6d42ed9..e1eb280 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -46,6 +46,8 @@ * user_uid: string, * value: ProfileFieldsValuePayload, * current_visibility: ProfileFieldsVisibility, + * updated_by_uid: string, + * updated_at: string, * } * @psalm-type ProfileFieldsEditableField = array{ * definition: ProfileFieldsDefinition, diff --git a/lib/Service/DataImportService.php b/lib/Service/DataImportService.php new file mode 100644 index 0000000..9971469 --- /dev/null +++ b/lib/Service/DataImportService.php @@ -0,0 +1,291 @@ + $payload + * @return array{ + * created_definitions: int, + * updated_definitions: int, + * skipped_definitions: int, + * created_values: int, + * updated_values: int, + * skipped_values: int, + * } + */ + public function import(array $payload, bool $dryRun = false): array { + $normalizedPayload = $this->importPayloadValidator->validate($payload); + $summary = [ + 'created_definitions' => 0, + 'updated_definitions' => 0, + 'skipped_definitions' => 0, + 'created_values' => 0, + 'updated_values' => 0, + 'skipped_values' => 0, + ]; + + if ($dryRun) { + $this->collectDefinitionSummary($normalizedPayload['definitions'], $summary); + $this->collectValueSummary($normalizedPayload['values'], $summary); + return $summary; + } + + $this->connection->beginTransaction(); + + try { + $definitionsByFieldKey = $this->persistDefinitions($normalizedPayload['definitions'], $summary); + $this->persistValues($normalizedPayload['values'], $definitionsByFieldKey, $summary); + $this->connection->commit(); + } catch (\Throwable $throwable) { + $this->connection->rollBack(); + throw $throwable; + } + + return $summary; + } + + /** + * @param list $definitions + * @param array{ + * created_definitions: int, + * updated_definitions: int, + * skipped_definitions: int, + * created_values: int, + * updated_values: int, + * skipped_values: int, + * } $summary + */ + private function collectDefinitionSummary(array $definitions, array &$summary): void { + foreach ($definitions as $definition) { + $existingDefinition = $this->fieldDefinitionService->findByFieldKey($definition['field_key']); + if ($existingDefinition === null) { + $summary['created_definitions']++; + continue; + } + + if ($this->definitionNeedsUpdate($existingDefinition, $definition)) { + $summary['updated_definitions']++; + continue; + } + + $summary['skipped_definitions']++; + } + } + + /** + * @param list $values + * @param array{ + * created_definitions: int, + * updated_definitions: int, + * skipped_definitions: int, + * created_values: int, + * updated_values: int, + * skipped_values: int, + * } $summary + */ + private function collectValueSummary(array $values, array &$summary): void { + foreach ($values as $value) { + $definition = $this->fieldDefinitionService->findByFieldKey($value['field_key']); + if ($definition === null) { + $summary['created_values']++; + continue; + } + + $existingValue = $this->fieldValueService->findByFieldDefinitionIdAndUserUid($definition->getId(), $value['user_uid']); + if ($existingValue === null) { + $summary['created_values']++; + continue; + } + + if ($this->valueNeedsUpdate($existingValue, $value)) { + $summary['updated_values']++; + continue; + } + + $summary['skipped_values']++; + } + } + + /** + * @param list $definitions + * @param array{ + * created_definitions: int, + * updated_definitions: int, + * skipped_definitions: int, + * created_values: int, + * updated_values: int, + * skipped_values: int, + * } $summary + * @return array + */ + private function persistDefinitions(array $definitions, array &$summary): array { + $definitionsByFieldKey = []; + + foreach ($definitions as $definition) { + $existingDefinition = $this->fieldDefinitionService->findByFieldKey($definition['field_key']); + if ($existingDefinition === null) { + $definitionsByFieldKey[$definition['field_key']] = $this->fieldDefinitionService->create($definition); + $summary['created_definitions']++; + continue; + } + + if ($this->definitionNeedsUpdate($existingDefinition, $definition)) { + $existingDefinition = $this->fieldDefinitionService->update($existingDefinition, $definition); + $summary['updated_definitions']++; + } else { + $summary['skipped_definitions']++; + } + + $definitionsByFieldKey[$definition['field_key']] = $existingDefinition; + } + + return $definitionsByFieldKey; + } + + /** + * @param list $values + * @param array $definitionsByFieldKey + * @param array{ + * created_definitions: int, + * updated_definitions: int, + * skipped_definitions: int, + * created_values: int, + * updated_values: int, + * skipped_values: int, + * } $summary + */ + private function persistValues(array $values, array $definitionsByFieldKey, array &$summary): void { + foreach ($values as $value) { + $definition = $definitionsByFieldKey[$value['field_key']]; + $existingValue = $this->fieldValueService->findByFieldDefinitionIdAndUserUid($definition->getId(), $value['user_uid']); + + if ($existingValue === null) { + $this->fieldValueService->upsert( + $definition, + $value['user_uid'], + $value['value']['value'], + $value['updated_by_uid'], + $value['current_visibility'], + new DateTime($value['updated_at']), + ); + $summary['created_values']++; + continue; + } + + if ($this->valueNeedsUpdate($existingValue, $value)) { + $this->fieldValueService->upsert( + $definition, + $value['user_uid'], + $value['value']['value'], + $value['updated_by_uid'], + $value['current_visibility'], + new DateTime($value['updated_at']), + ); + $summary['updated_values']++; + continue; + } + + $summary['skipped_values']++; + } + } + + /** + * @param array{ + * field_key: non-empty-string, + * label: non-empty-string, + * type: 'text'|'number', + * admin_only: bool, + * user_editable: bool, + * user_visible: bool, + * initial_visibility: 'private'|'users'|'public', + * sort_order: int, + * active: bool, + * created_at?: non-empty-string, + * updated_at?: non-empty-string, + * } $definition + */ + private function definitionNeedsUpdate(FieldDefinition $existingDefinition, array $definition): bool { + return $existingDefinition->getLabel() !== $definition['label'] + || $existingDefinition->getSortOrder() !== $definition['sort_order'] + || $existingDefinition->getActive() !== $definition['active'] + || (($definition['updated_at'] ?? null) !== null && $existingDefinition->getUpdatedAt()->format(DATE_ATOM) !== $definition['updated_at']); + } + + /** + * @param array{ + * field_key: non-empty-string, + * user_uid: non-empty-string, + * value: array{value: mixed}, + * current_visibility: 'private'|'users'|'public', + * updated_by_uid: string, + * updated_at: non-empty-string, + * } $value + */ + private function valueNeedsUpdate(FieldValue $existingValue, array $value): bool { + $serializedValue = $this->fieldValueService->serializeForResponse($existingValue); + + return $serializedValue['value'] !== $value['value'] + || $serializedValue['current_visibility'] !== $value['current_visibility'] + || $serializedValue['updated_by_uid'] !== $value['updated_by_uid'] + || $serializedValue['updated_at'] !== $value['updated_at']; + } +} diff --git a/lib/Service/FieldDefinitionService.php b/lib/Service/FieldDefinitionService.php index 5ffd6d5..eed1e9b 100644 --- a/lib/Service/FieldDefinitionService.php +++ b/lib/Service/FieldDefinitionService.php @@ -32,7 +32,8 @@ public function create(array $definition): FieldDefinition { throw new InvalidArgumentException('field_key already exists'); } - $now = new DateTime(); + $createdAt = $this->parseImportedDate($definition['created_at'] ?? null) ?? new DateTime(); + $updatedAt = $this->parseImportedDate($definition['updated_at'] ?? null) ?? clone $createdAt; $entity = new FieldDefinition(); $entity->setFieldKey($validated['field_key']); $entity->setLabel($validated['label']); @@ -43,8 +44,8 @@ public function create(array $definition): FieldDefinition { $entity->setInitialVisibility($validated['initial_visibility']); $entity->setSortOrder($validated['sort_order']); $entity->setActive($validated['active']); - $entity->setCreatedAt($now); - $entity->setUpdatedAt($now); + $entity->setCreatedAt($createdAt); + $entity->setUpdatedAt($updatedAt); return $this->fieldDefinitionMapper->insert($entity); } @@ -70,11 +71,19 @@ public function update(FieldDefinition $existing, array $definition): FieldDefin $existing->setInitialVisibility($validated['initial_visibility']); $existing->setSortOrder($validated['sort_order']); $existing->setActive($validated['active']); - $existing->setUpdatedAt(new DateTime()); + $existing->setUpdatedAt($this->parseImportedDate($definition['updated_at'] ?? null) ?? new DateTime()); return $this->fieldDefinitionMapper->update($existing); } + private function parseImportedDate(mixed $value): ?DateTime { + if (!is_string($value) || $value === '') { + return null; + } + + return new DateTime($value); + } + /** * @return list */ diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index f50f5b1..bae4c92 100644 --- a/lib/Service/FieldValueService.php +++ b/lib/Service/FieldValueService.php @@ -10,6 +10,7 @@ namespace OCA\ProfileFields\Service; use DateTime; +use DateTimeInterface; use InvalidArgumentException; use JsonException; use OCA\ProfileFields\Db\FieldDefinition; @@ -27,7 +28,14 @@ public function __construct( /** * @param array|scalar|null $rawValue */ - public function upsert(FieldDefinition $definition, string $userUid, array|string|int|float|bool|null $rawValue, string $updatedByUid, ?string $currentVisibility = null): FieldValue { + public function upsert( + FieldDefinition $definition, + string $userUid, + array|string|int|float|bool|null $rawValue, + string $updatedByUid, + ?string $currentVisibility = null, + ?DateTimeInterface $updatedAt = null, + ): FieldValue { $normalizedValue = $this->normalizeValue($definition, $rawValue); $visibility = $currentVisibility ?? $definition->getInitialVisibility(); if (!FieldVisibility::isValid($visibility)) { @@ -40,7 +48,7 @@ public function upsert(FieldDefinition $definition, string $userUid, array|strin $entity->setValueJson($this->encodeValue($normalizedValue)); $entity->setCurrentVisibility($visibility); $entity->setUpdatedByUid($updatedByUid); - $entity->setUpdatedAt(new DateTime()); + $entity->setUpdatedAt($this->asMutableDateTime($updatedAt)); if ($entity->getId() === null) { return $this->fieldValueMapper->insert($entity); @@ -101,7 +109,7 @@ public function updateVisibility(FieldDefinition $definition, string $userUid, s $entity->setCurrentVisibility($currentVisibility); $entity->setUpdatedByUid($updatedByUid); - $entity->setUpdatedAt(new DateTime()); + $entity->setUpdatedAt($this->asMutableDateTime()); return $this->fieldValueMapper->update($entity); } @@ -180,4 +188,16 @@ private function decodeValue(string $valueJson): array { return $decoded; } + + private function asMutableDateTime(?DateTimeInterface $value = null): DateTime { + if ($value instanceof DateTime) { + return clone $value; + } + + if ($value !== null) { + return DateTime::createFromInterface($value); + } + + return new DateTime(); + } } diff --git a/lib/Service/ImportPayloadValidator.php b/lib/Service/ImportPayloadValidator.php new file mode 100644 index 0000000..323c6e6 --- /dev/null +++ b/lib/Service/ImportPayloadValidator.php @@ -0,0 +1,280 @@ + $payload + * @return array{ + * schema_version: int, + * definitions: list, + * values: list, + * } + */ + public function validate(array $payload): array { + $schemaVersion = $payload['schema_version'] ?? null; + if (!is_int($schemaVersion) || $schemaVersion !== self::SCHEMA_VERSION) { + throw new InvalidArgumentException(sprintf('schema_version must be %d', self::SCHEMA_VERSION)); + } + + $definitions = $this->validateDefinitions($this->requireList($payload, 'definitions')); + $values = $this->validateValues($this->requireList($payload, 'values'), $definitions); + + return [ + 'schema_version' => $schemaVersion, + 'definitions' => array_values($definitions), + 'values' => $values, + ]; + } + + /** + * @param list $definitions + * @return array + */ + private function validateDefinitions(array $definitions): array { + $normalizedDefinitions = []; + + foreach ($definitions as $index => $definition) { + if (!is_array($definition)) { + throw new InvalidArgumentException(sprintf('definitions[%d] must be an object', $index)); + } + + $validatedDefinition = $this->fieldDefinitionValidator->validate($definition); + $createdAt = $this->normalizeOptionalDate($definition, 'created_at', sprintf('definitions[%d].created_at must be a valid ISO-8601 datetime', $index)); + $updatedAt = $this->normalizeOptionalDate($definition, 'updated_at', sprintf('definitions[%d].updated_at must be a valid ISO-8601 datetime', $index)); + $fieldKey = $validatedDefinition['field_key']; + + if (isset($normalizedDefinitions[$fieldKey])) { + throw new InvalidArgumentException(sprintf('definitions[%d].field_key is duplicated', $index)); + } + + $existingDefinition = $this->fieldDefinitionService->findByFieldKey($fieldKey); + if ($existingDefinition !== null && !$this->isCompatibleDefinition($existingDefinition, $validatedDefinition)) { + throw new InvalidArgumentException(sprintf('definitions[%d].field_key conflicts with an incompatible existing definition', $index)); + } + + $normalizedDefinitions[$fieldKey] = $validatedDefinition; + if ($createdAt !== null) { + $normalizedDefinitions[$fieldKey]['created_at'] = $createdAt; + } + if ($updatedAt !== null) { + $normalizedDefinitions[$fieldKey]['updated_at'] = $updatedAt; + } + } + + return $normalizedDefinitions; + } + + /** + * @param list $values + * @param array $definitions + * @return list + */ + private function validateValues(array $values, array $definitions): array { + $normalizedValues = []; + $seenValueKeys = []; + + foreach ($values as $index => $value) { + if (!is_array($value)) { + throw new InvalidArgumentException(sprintf('values[%d] must be an object', $index)); + } + + $fieldKey = $this->requireNonEmptyString($value, 'field_key', sprintf('values[%d].field_key is required', $index)); + if (!isset($definitions[$fieldKey])) { + throw new InvalidArgumentException(sprintf('values[%d].field_key references an unknown definition', $index)); + } + + $userUid = $this->requireNonEmptyString($value, 'user_uid', sprintf('values[%d].user_uid is required', $index)); + if (!$this->userManager->userExists($userUid)) { + throw new InvalidArgumentException(sprintf('values[%d].user_uid does not exist in destination instance', $index)); + } + + $valuePayload = $value['value'] ?? null; + if (!is_array($valuePayload) || !array_key_exists('value', $valuePayload)) { + throw new InvalidArgumentException(sprintf('values[%d].value must be an object payload with a value key', $index)); + } + + $currentVisibility = $this->requireNonEmptyString($value, 'current_visibility', sprintf('values[%d].current_visibility is required', $index)); + if (!FieldVisibility::isValid($currentVisibility)) { + throw new InvalidArgumentException(sprintf('values[%d].current_visibility is not supported', $index)); + } + + $updatedByUid = $this->requireString($value, 'updated_by_uid', sprintf('values[%d].updated_by_uid is required', $index)); + $updatedAt = $this->requireNonEmptyString($value, 'updated_at', sprintf('values[%d].updated_at is required', $index)); + $this->assertDate($updatedAt, sprintf('values[%d].updated_at must be a valid ISO-8601 datetime', $index)); + + $compoundKey = $fieldKey . '\0' . $userUid; + if (isset($seenValueKeys[$compoundKey])) { + throw new InvalidArgumentException(sprintf('values[%d] duplicates field_key/user_uid pair', $index)); + } + $seenValueKeys[$compoundKey] = true; + + $normalizedValues[] = [ + 'field_key' => $fieldKey, + 'user_uid' => $userUid, + 'value' => ['value' => $valuePayload['value']], + 'current_visibility' => $currentVisibility, + 'updated_by_uid' => $updatedByUid, + 'updated_at' => $updatedAt, + ]; + } + + return $normalizedValues; + } + + /** + * @param array $payload + * @return list + */ + private function requireList(array $payload, string $key): array { + $value = $payload[$key] ?? null; + if (!is_array($value) || !array_is_list($value)) { + throw new InvalidArgumentException(sprintf('%s must be a list', $key)); + } + + return $value; + } + + /** + * @param array $payload + */ + private function requireNonEmptyString(array $payload, string $key, string $message): string { + $value = trim((string)($payload[$key] ?? '')); + if ($value === '') { + throw new InvalidArgumentException($message); + } + + return $value; + } + + /** + * @param array $payload + */ + private function requireString(array $payload, string $key, string $message): string { + if (!array_key_exists($key, $payload) || !is_string($payload[$key])) { + throw new InvalidArgumentException($message); + } + + return $payload[$key]; + } + + private function assertDate(string $value, string $message): void { + try { + new DateTimeImmutable($value); + } catch (\Exception) { + throw new InvalidArgumentException($message); + } + } + + /** + * @param array $payload + */ + private function normalizeOptionalDate(array $payload, string $key, string $message): ?string { + if (!array_key_exists($key, $payload) || $payload[$key] === null || $payload[$key] === '') { + return null; + } + + if (!is_string($payload[$key])) { + throw new InvalidArgumentException($message); + } + + try { + return (new DateTimeImmutable($payload[$key]))->format(DATE_ATOM); + } catch (\Exception) { + throw new InvalidArgumentException($message); + } + } + + /** + * @param array{ + * field_key: non-empty-string, + * label: non-empty-string, + * type: 'text'|'number', + * admin_only: bool, + * user_editable: bool, + * user_visible: bool, + * initial_visibility: 'private'|'users'|'public', + * sort_order: int, + * active: bool, + * } $definition + */ + private function isCompatibleDefinition(FieldDefinition $existingDefinition, array $definition): bool { + return $existingDefinition->getType() === $definition['type'] + && $existingDefinition->getAdminOnly() === $definition['admin_only'] + && $existingDefinition->getUserEditable() === $definition['user_editable'] + && $existingDefinition->getUserVisible() === $definition['user_visible'] + && $existingDefinition->getInitialVisibility() === $definition['initial_visibility']; + } +} diff --git a/openapi-administration.json b/openapi-administration.json index 624ba67..257bf03 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -128,7 +128,9 @@ "field_definition_id", "user_uid", "value", - "current_visibility" + "current_visibility", + "updated_by_uid", + "updated_at" ], "properties": { "id": { @@ -147,6 +149,12 @@ }, "current_visibility": { "$ref": "#/components/schemas/Visibility" + }, + "updated_by_uid": { + "type": "string" + }, + "updated_at": { + "type": "string" } } }, diff --git a/openapi-full.json b/openapi-full.json index ee3e064..57f4842 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -218,7 +218,9 @@ "field_definition_id", "user_uid", "value", - "current_visibility" + "current_visibility", + "updated_by_uid", + "updated_at" ], "properties": { "id": { @@ -237,6 +239,12 @@ }, "current_visibility": { "$ref": "#/components/schemas/Visibility" + }, + "updated_by_uid": { + "type": "string" + }, + "updated_at": { + "type": "string" } } }, diff --git a/openapi.json b/openapi.json index f69bb29..70d825f 100644 --- a/openapi.json +++ b/openapi.json @@ -148,7 +148,9 @@ "field_definition_id", "user_uid", "value", - "current_visibility" + "current_visibility", + "updated_by_uid", + "updated_at" ], "properties": { "id": { @@ -167,6 +169,12 @@ }, "current_visibility": { "$ref": "#/components/schemas/Visibility" + }, + "updated_by_uid": { + "type": "string" + }, + "updated_at": { + "type": "string" } } }, diff --git a/package-lock.json b/package-lock.json index a6239e7..70a4f77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "profile_fields", "version": "0.0.1-dev", + "license": "AGPL-3.0-or-later", "dependencies": { "@mdi/js": "^7.4.47", "@nextcloud/axios": "^2.5.2", @@ -20,6 +21,7 @@ "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/vite-config": "^2.5.2", "@playwright/test": "^1.54.2", + "openapi-typescript": "^7.13.0", "typescript": "^5.9.3", "vitest": "^4.0.18" }, @@ -28,6 +30,21 @@ "npm": "^11.3.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -1358,6 +1375,82 @@ "node": ">=18" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.10", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.10.tgz", + "integrity": "sha512-XCBR/9WHJ0cpezuunHMZjuFMl4KqUo7eiFwzrQrvm7lTXt0EBd3No8UY+9OyzXpDfreGEMMtxmaLZ+ksVw378g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -2491,6 +2584,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -2548,6 +2651,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -3118,6 +3231,13 @@ "node": ">=18" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3209,6 +3329,13 @@ "node": ">=0.8" } }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4413,6 +4540,20 @@ "dev": true, "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4452,6 +4593,19 @@ "node": ">=8" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4724,6 +4878,43 @@ "dev": true, "license": "MIT" }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js-yaml/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5896,6 +6087,40 @@ ], "license": "MIT" }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", @@ -6025,6 +6250,24 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -6191,6 +6434,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7391,6 +7644,19 @@ "dev": true, "license": "MIT" }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -7618,6 +7884,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/url": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", @@ -8682,6 +8955,23 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index d46fc80..1f3f17c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "NODE_ENV=production vite --mode production build", "dev": "NODE_ENV=development vite --mode development build", "screenshots:refresh": "node playwright/generate-screenshots.mjs", + "typescript:generate": "mkdir -p src/types/openapi && openapi-typescript openapi.json -o src/types/openapi/openapi.ts && openapi-typescript openapi-administration.json -o src/types/openapi/openapi-administration.ts && openapi-typescript openapi-full.json -o src/types/openapi/openapi-full.ts", "watch": "NODE_ENV=development vite --mode development build --watch", "test": "vitest run", "test:watch": "vitest", @@ -31,9 +32,10 @@ "vue": "^3.5.29" }, "devDependencies": { - "@playwright/test": "^1.54.2", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/vite-config": "^2.5.2", + "@playwright/test": "^1.54.2", + "openapi-typescript": "^7.13.0", "typescript": "^5.9.3", "vitest": "^4.0.18" } diff --git a/psalm.xml b/psalm.xml index 47c0422..22e35d0 100644 --- a/psalm.xml +++ b/psalm.xml @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later findUnusedBaselineEntry="true" findUnusedCode="false" resolveFromConfigFile="true" - phpVersion="8.2" + phpVersion="8.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor-bin/psalm/vendor/vimeo/psalm/config.xsd" diff --git a/src/api.ts b/src/api.ts index bd557d0..36c8572 100644 --- a/src/api.ts +++ b/src/api.ts @@ -5,7 +5,16 @@ import './polyfills/buffer.js' import axios from '@nextcloud/axios' import { generateOcsUrl } from '@nextcloud/router' -import type { EditableField, FieldDefinition, FieldValueRecord } from './types' +import type { + CreateDefinitionPayload, + EditableField, + FieldDefinition, + FieldValueRecord, + UpdateDefinitionPayload, + UpdateOwnVisibilityPayload, + UpsertAdminUserValuePayload, + UpsertOwnValuePayload, +} from './types' const jsonHeaders = { 'OCS-APIRequest': 'true', @@ -22,14 +31,14 @@ export const listDefinitions = async(): Promise => { return response.data.ocs.data } -export const createDefinition = async(payload: Record): Promise => { +export const createDefinition = async(payload: CreateDefinitionPayload): Promise => { const response = await axios.post<{ ocs: { data: FieldDefinition } }>(apiUrl('/api/v1/definitions'), payload, { headers: jsonHeaders, }) return response.data.ocs.data } -export const updateDefinition = async(id: number, payload: Record): Promise => { +export const updateDefinition = async(id: number, payload: UpdateDefinitionPayload): Promise => { const response = await axios.put<{ ocs: { data: FieldDefinition } }>(apiUrl(`/api/v1/definitions/${id}`), payload, { headers: jsonHeaders, }) @@ -49,17 +58,18 @@ export const listEditableFields = async(): Promise => { return response.data.ocs.data } -export const upsertOwnValue = async(fieldDefinitionId: number, payload: Record): Promise => { +export const upsertOwnValue = async(fieldDefinitionId: number, payload: UpsertOwnValuePayload): Promise => { const response = await axios.put<{ ocs: { data: FieldValueRecord } }>(apiUrl(`/api/v1/me/values/${fieldDefinitionId}`), payload, { headers: jsonHeaders, }) return response.data.ocs.data } -export const updateOwnVisibility = async(fieldDefinitionId: number, currentVisibility: string): Promise => { - const response = await axios.put<{ ocs: { data: FieldValueRecord } }>(apiUrl(`/api/v1/me/values/${fieldDefinitionId}/visibility`), { +export const updateOwnVisibility = async(fieldDefinitionId: number, currentVisibility: UpdateOwnVisibilityPayload['currentVisibility']): Promise => { + const payload: UpdateOwnVisibilityPayload = { currentVisibility, - }, { + } + const response = await axios.put<{ ocs: { data: FieldValueRecord } }>(apiUrl(`/api/v1/me/values/${fieldDefinitionId}/visibility`), payload, { headers: jsonHeaders, }) return response.data.ocs.data @@ -72,7 +82,7 @@ export const listAdminUserValues = async(userUid: string): Promise): Promise => { +export const upsertAdminUserValue = async(userUid: string, fieldDefinitionId: number, payload: UpsertAdminUserValuePayload): Promise => { const response = await axios.put<{ ocs: { data: FieldValueRecord } }>(apiUrl(`/api/v1/users/${encodeURIComponent(userUid)}/values/${fieldDefinitionId}`), payload, { headers: jsonHeaders, }) diff --git a/src/tests/utils/adminFieldValues.spec.ts b/src/tests/utils/adminFieldValues.spec.ts index 97e8570..459beea 100644 --- a/src/tests/utils/adminFieldValues.spec.ts +++ b/src/tests/utils/adminFieldValues.spec.ts @@ -27,6 +27,8 @@ const value = (fieldDefinitionId: number, userUid = 'alice'): FieldValueRecord = user_uid: userUid, value: { value: `value-${fieldDefinitionId}` }, current_visibility: 'users', + updated_by_uid: 'admin', + updated_at: '2026-03-10T00:00:00+00:00', }) describe('buildAdminEditableFields', () => { diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 46e6d80..0000000 --- a/src/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors -// SPDX-License-Identifier: AGPL-3.0-or-later - -export type FieldType = 'text' | 'number' - -export type FieldVisibility = 'private' | 'users' | 'public' - -export interface FieldDefinition { - id: number - field_key: string - label: string - type: FieldType - admin_only: boolean - user_editable: boolean - user_visible: boolean - initial_visibility: FieldVisibility - sort_order: number - active: boolean - created_at: string - updated_at: string -} - -export interface FieldValuePayload { - value?: string | number | null -} - -export interface FieldValueRecord { - id: number - field_definition_id: number - user_uid: string - value: FieldValuePayload - current_visibility: FieldVisibility -} - -export interface EditableField { - definition: FieldDefinition - value: FieldValueRecord | null - can_edit: boolean -} - -export interface AdminEditableField { - definition: FieldDefinition - value: FieldValueRecord | null -} - -export interface ApiError { - message: string -} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..7b2a4ba --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { components as ApiComponents, operations as ApiOperations } from './openapi/openapi-full' + +type ApiJsonBody = TRequestBody extends { + content: { + 'application/json': infer Body + } +} + ? Body + : never + +type ApiOperationRequestBody = TOperation extends { + requestBody?: infer RequestBody +} + ? NonNullable + : never + +type ApiRequestJsonBody = ApiJsonBody> + +export type FieldType = ApiComponents['schemas']['Type'] +export type FieldVisibility = ApiComponents['schemas']['Visibility'] +export type FieldDefinition = ApiComponents['schemas']['Definition'] + +// openapi-typescript collapses the loose `value: mixed` schema to Record. +// Keep the surrounding contract generated and widen only this payload leaf for frontend use. +export type FieldValuePayload = Omit & { + value?: string | number | null +} +export type FieldValueRecord = Omit & { + value: FieldValuePayload +} +export type EditableField = Omit & { + definition: FieldDefinition + value: FieldValueRecord | null +} +export type LookupField = Omit & { + definition: FieldDefinition + value: FieldValueRecord +} +export type LookupResult = Omit & { + fields: Record +} + +export type CreateDefinitionPayload = ApiRequestJsonBody +export type UpdateDefinitionPayload = ApiRequestJsonBody +export type UpsertOwnValuePayload = ApiRequestJsonBody +export type UpdateOwnVisibilityPayload = ApiRequestJsonBody +export type UpsertAdminUserValuePayload = ApiRequestJsonBody + +export type AdminEditableField = { + definition: FieldDefinition + value: FieldValueRecord | null +} + +export type ApiError = { + message: string +} diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts new file mode 100644 index 0000000..845e50b --- /dev/null +++ b/src/types/openapi/openapi-administration.ts @@ -0,0 +1,677 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/ocs/v2.php/apps/profile_fields/api/v1/definitions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List field definitions + * @description Return all profile field definitions ordered for admin management. + * This endpoint requires admin access + */ + get: operations["field_definition_api-index"]; + put?: never; + /** + * Create field definition + * @description Create a new profile field definition for the instance. + * This endpoint requires admin access + */ + post: operations["field_definition_api-create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/definitions/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update field definition + * @description Update a profile field definition without changing its immutable key. + * This endpoint requires admin access + */ + put: operations["field_definition_api-update"]; + post?: never; + /** + * Delete field definition + * @description Delete a profile field definition and return the removed record. + * This endpoint requires admin access + */ + delete: operations["field_definition_api-delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/users/{userUid}/values": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List stored values for a user + * @description Return all persisted profile field values for a specific user. + * This endpoint requires admin access + */ + get: operations["field_value_admin_api-index"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/users/{userUid}/values/{fieldDefinitionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Upsert a stored value for a user + * @description Create or update a profile field value for a specific user as an administrator. + * This endpoint requires admin access + */ + put: operations["field_value_admin_api-upsert"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/users/lookup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Lookup a user by a key profile field value + * @description Resolve a Nextcloud user from an exact profile field match, then return the user's stored profile fields keyed by field key. This is intended for ETL or payroll-style integrations that know one authoritative identifier such as CPF and need the rest of the cooperative data. + * This endpoint requires admin access + */ + post: operations["field_value_admin_api-lookup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Definition: { + /** Format: int64 */ + id: number; + field_key: string; + label: string; + type: components["schemas"]["Type"]; + admin_only: boolean; + user_editable: boolean; + user_visible: boolean; + initial_visibility: components["schemas"]["Visibility"]; + /** Format: int64 */ + sort_order: number; + active: boolean; + created_at: string; + updated_at: string; + }; + OCSMeta: { + status: string; + statuscode: number; + message?: string; + totalitems?: string; + itemsperpage?: string; + }; + /** @enum {string} */ + Type: "text" | "number"; + ValuePayload: { + value: Record; + }; + ValueRecord: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + field_definition_id: number; + user_uid: string; + value: components["schemas"]["ValuePayload"]; + current_visibility: components["schemas"]["Visibility"]; + updated_by_uid: string; + updated_at: string; + }; + /** @enum {string} */ + Visibility: "private" | "users" | "public"; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "field_definition_api-index": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Field definitions listed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Definition"][]; + }; + }; + }; + }; + }; + }; + "field_definition_api-create": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Immutable unique key of the field */ + fieldKey: string; + /** @description Human-readable label shown in the UI */ + label: string; + /** @description Value type accepted by the field */ + type: string; + /** + * @description Whether only admins can edit values for this field + * @default false + */ + adminOnly?: boolean; + /** + * @description Whether the owner can edit the field value + * @default false + */ + userEditable?: boolean; + /** + * @description Whether the owner can see the field in personal settings + * @default true + */ + userVisible?: boolean; + /** + * @description Initial visibility applied to new values + * @default private + */ + initialVisibility?: string; + /** + * Format: int64 + * @description Display order used in admin and profile forms + * @default 0 + */ + sortOrder?: number; + /** + * @description Whether the definition is currently active + * @default true + */ + active?: boolean; + }; + }; + }; + responses: { + /** @description Field definition created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Definition"]; + }; + }; + }; + }; + /** @description Invalid field definition payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_definition_api-update": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description Identifier of the field definition */ + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Human-readable label shown in the UI */ + label: string; + /** @description Value type accepted by the field */ + type: string; + /** + * @description Whether only admins can edit values for this field + * @default false + */ + adminOnly?: boolean; + /** + * @description Whether the owner can edit the field value + * @default false + */ + userEditable?: boolean; + /** + * @description Whether the owner can see the field in personal settings + * @default true + */ + userVisible?: boolean; + /** + * @description Initial visibility applied to new values + * @default private + */ + initialVisibility?: string; + /** + * Format: int64 + * @description Display order used in admin and profile forms + * @default 0 + */ + sortOrder?: number; + /** + * @description Whether the definition is currently active + * @default true + */ + active?: boolean; + }; + }; + }; + responses: { + /** @description Field definition updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Definition"]; + }; + }; + }; + }; + /** @description Invalid field definition payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_definition_api-delete": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description Identifier of the field definition */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Field definition deleted successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Definition"]; + }; + }; + }; + }; + /** @description Field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_value_admin_api-index": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description User identifier whose profile field values should be listed */ + userUid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User field values listed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ValueRecord"][]; + }; + }; + }; + }; + }; + }; + "field_value_admin_api-upsert": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description User identifier that owns the profile field value */ + userUid: string; + /** @description Identifier of the field definition */ + fieldDefinitionId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Value payload to persist */ + value?: ({ + value?: (string | number | boolean) | null; + } | string | number | boolean) | null; + /** @description Visibility to apply to the stored value */ + currentVisibility?: string | null; + }; + }; + }; + responses: { + /** @description User field value stored successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ValueRecord"]; + }; + }; + }; + }; + /** @description Invalid field value payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Authenticated admin user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_value_admin_api-lookup": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Immutable key of the lookup field, such as cpf */ + fieldKey: string; + /** @description Value payload to match exactly */ + fieldValue?: ({ + value?: (string | number | boolean) | null; + } | string | number | boolean) | null; + }; + }; + }; + responses: { + /** @description User lookup completed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + user_uid: string; + lookup_field_key: string; + fields: { + [key: string]: { + definition: { + [key: string]: Record; + }; + value: components["schemas"]["ValueRecord"]; + }; + }; + }; + }; + }; + }; + }; + /** @description Invalid lookup payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Authenticated admin user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Lookup field definition or user not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Multiple users match the lookup field value */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; +} diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts new file mode 100644 index 0000000..b44d326 --- /dev/null +++ b/src/types/openapi/openapi-full.ts @@ -0,0 +1,1017 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/ocs/v2.php/apps/profile_fields/api/v1/definitions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List field definitions + * @description Return all profile field definitions ordered for admin management. + * This endpoint requires admin access + */ + get: operations["field_definition_api-index"]; + put?: never; + /** + * Create field definition + * @description Create a new profile field definition for the instance. + * This endpoint requires admin access + */ + post: operations["field_definition_api-create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/definitions/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update field definition + * @description Update a profile field definition without changing its immutable key. + * This endpoint requires admin access + */ + put: operations["field_definition_api-update"]; + post?: never; + /** + * Delete field definition + * @description Delete a profile field definition and return the removed record. + * This endpoint requires admin access + */ + delete: operations["field_definition_api-delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/users/{userUid}/values": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List stored values for a user + * @description Return all persisted profile field values for a specific user. + * This endpoint requires admin access + */ + get: operations["field_value_admin_api-index"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/users/{userUid}/values/{fieldDefinitionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Upsert a stored value for a user + * @description Create or update a profile field value for a specific user as an administrator. + * This endpoint requires admin access + */ + put: operations["field_value_admin_api-upsert"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/users/lookup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Lookup a user by a key profile field value + * @description Resolve a Nextcloud user from an exact profile field match, then return the user's stored profile fields keyed by field key. This is intended for ETL or payroll-style integrations that know one authoritative identifier such as CPF and need the rest of the cooperative data. + * This endpoint requires admin access + */ + post: operations["field_value_admin_api-lookup"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/me/values": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List user-visible fields for the authenticated user + * @description Return active profile fields visible to the authenticated user, together with any current stored value and whether the user can edit the value directly. + */ + get: operations["field_value_api-index"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/me/values/{fieldDefinitionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Upsert the authenticated user's value for an editable field + * @description Create or update the current user's value for a field that allows self-service editing. + */ + put: operations["field_value_api-upsert"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/me/values/{fieldDefinitionId}/visibility": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update the visibility of the authenticated user's stored value + * @description Change only the visibility of an already stored field value owned by the authenticated user. + */ + put: operations["field_value_api-update-visibility"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Definition: { + /** Format: int64 */ + id: number; + field_key: string; + label: string; + type: components["schemas"]["Type"]; + admin_only: boolean; + user_editable: boolean; + user_visible: boolean; + initial_visibility: components["schemas"]["Visibility"]; + /** Format: int64 */ + sort_order: number; + active: boolean; + created_at: string; + updated_at: string; + }; + DefinitionInput: { + field_key?: string; + label?: string; + type?: string; + admin_only?: boolean; + user_editable?: boolean; + user_visible?: boolean; + initial_visibility?: string; + /** Format: int64 */ + sort_order?: number; + active?: boolean; + }; + EditableField: { + definition: components["schemas"]["Definition"]; + value: components["schemas"]["ValueRecord"]; + can_edit: boolean; + }; + LookupField: { + definition: components["schemas"]["Definition"]; + value: components["schemas"]["ValueRecord"]; + }; + LookupResult: { + user_uid: string; + lookup_field_key: string; + fields: { + [key: string]: components["schemas"]["LookupField"]; + }; + }; + OCSMeta: { + status: string; + statuscode: number; + message?: string; + totalitems?: string; + itemsperpage?: string; + }; + /** @enum {string} */ + Type: "text" | "number"; + ValuePayload: { + value: Record; + }; + ValueRecord: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + field_definition_id: number; + user_uid: string; + value: components["schemas"]["ValuePayload"]; + current_visibility: components["schemas"]["Visibility"]; + updated_by_uid: string; + updated_at: string; + }; + /** @enum {string} */ + Visibility: "private" | "users" | "public"; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "field_definition_api-index": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Field definitions listed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Definition"][]; + }; + }; + }; + }; + }; + }; + "field_definition_api-create": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Immutable unique key of the field */ + fieldKey: string; + /** @description Human-readable label shown in the UI */ + label: string; + /** @description Value type accepted by the field */ + type: string; + /** + * @description Whether only admins can edit values for this field + * @default false + */ + adminOnly?: boolean; + /** + * @description Whether the owner can edit the field value + * @default false + */ + userEditable?: boolean; + /** + * @description Whether the owner can see the field in personal settings + * @default true + */ + userVisible?: boolean; + /** + * @description Initial visibility applied to new values + * @default private + */ + initialVisibility?: string; + /** + * Format: int64 + * @description Display order used in admin and profile forms + * @default 0 + */ + sortOrder?: number; + /** + * @description Whether the definition is currently active + * @default true + */ + active?: boolean; + }; + }; + }; + responses: { + /** @description Field definition created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Definition"]; + }; + }; + }; + }; + /** @description Invalid field definition payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_definition_api-update": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description Identifier of the field definition */ + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Human-readable label shown in the UI */ + label: string; + /** @description Value type accepted by the field */ + type: string; + /** + * @description Whether only admins can edit values for this field + * @default false + */ + adminOnly?: boolean; + /** + * @description Whether the owner can edit the field value + * @default false + */ + userEditable?: boolean; + /** + * @description Whether the owner can see the field in personal settings + * @default true + */ + userVisible?: boolean; + /** + * @description Initial visibility applied to new values + * @default private + */ + initialVisibility?: string; + /** + * Format: int64 + * @description Display order used in admin and profile forms + * @default 0 + */ + sortOrder?: number; + /** + * @description Whether the definition is currently active + * @default true + */ + active?: boolean; + }; + }; + }; + responses: { + /** @description Field definition updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Definition"]; + }; + }; + }; + }; + /** @description Invalid field definition payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_definition_api-delete": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description Identifier of the field definition */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Field definition deleted successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["Definition"]; + }; + }; + }; + }; + /** @description Field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_value_admin_api-index": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description User identifier whose profile field values should be listed */ + userUid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User field values listed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ValueRecord"][]; + }; + }; + }; + }; + }; + }; + "field_value_admin_api-upsert": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description User identifier that owns the profile field value */ + userUid: string; + /** @description Identifier of the field definition */ + fieldDefinitionId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Value payload to persist */ + value?: ({ + value?: (string | number | boolean) | null; + } | string | number | boolean) | null; + /** @description Visibility to apply to the stored value */ + currentVisibility?: string | null; + }; + }; + }; + responses: { + /** @description User field value stored successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ValueRecord"]; + }; + }; + }; + }; + /** @description Invalid field value payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Authenticated admin user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_value_admin_api-lookup": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Immutable key of the lookup field, such as cpf */ + fieldKey: string; + /** @description Value payload to match exactly */ + fieldValue?: ({ + value?: (string | number | boolean) | null; + } | string | number | boolean) | null; + }; + }; + }; + responses: { + /** @description User lookup completed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + user_uid: string; + lookup_field_key: string; + fields: { + [key: string]: { + definition: { + [key: string]: Record; + }; + value: components["schemas"]["ValueRecord"]; + }; + }; + }; + }; + }; + }; + }; + /** @description Invalid lookup payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Authenticated admin user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Lookup field definition or user not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Multiple users match the lookup field value */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_value_api-index": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Editable profile fields listed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["EditableField"][]; + }; + }; + }; + }; + /** @description Authenticated user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_value_api-upsert": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description Identifier of the field definition */ + fieldDefinitionId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Value payload to persist */ + value?: ({ + value?: (string | number | boolean) | null; + } | string | number | boolean) | null; + /** @description Visibility to apply to the stored value */ + currentVisibility?: string | null; + }; + }; + }; + responses: { + /** @description User field value stored successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ValueRecord"]; + }; + }; + }; + }; + /** @description Invalid field value payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Authenticated user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field cannot be edited by the user */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_value_api-update-visibility": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description Identifier of the field definition */ + fieldDefinitionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Visibility to apply to the stored value */ + currentVisibility: string; + }; + }; + }; + responses: { + /** @description Field visibility updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ValueRecord"]; + }; + }; + }; + }; + /** @description Invalid visibility payload or value missing */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Authenticated user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field visibility cannot be changed by the user */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; +} diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts new file mode 100644 index 0000000..3bb4c5c --- /dev/null +++ b/src/types/openapi/openapi.ts @@ -0,0 +1,378 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/ocs/v2.php/apps/profile_fields/api/v1/me/values": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List user-visible fields for the authenticated user + * @description Return active profile fields visible to the authenticated user, together with any current stored value and whether the user can edit the value directly. + */ + get: operations["field_value_api-index"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/me/values/{fieldDefinitionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Upsert the authenticated user's value for an editable field + * @description Create or update the current user's value for a field that allows self-service editing. + */ + put: operations["field_value_api-upsert"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/profile_fields/api/v1/me/values/{fieldDefinitionId}/visibility": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update the visibility of the authenticated user's stored value + * @description Change only the visibility of an already stored field value owned by the authenticated user. + */ + put: operations["field_value_api-update-visibility"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Definition: { + /** Format: int64 */ + id: number; + field_key: string; + label: string; + type: components["schemas"]["Type"]; + admin_only: boolean; + user_editable: boolean; + user_visible: boolean; + initial_visibility: components["schemas"]["Visibility"]; + /** Format: int64 */ + sort_order: number; + active: boolean; + created_at: string; + updated_at: string; + }; + EditableField: { + definition: components["schemas"]["Definition"]; + value: components["schemas"]["ValueRecord"]; + can_edit: boolean; + }; + OCSMeta: { + status: string; + statuscode: number; + message?: string; + totalitems?: string; + itemsperpage?: string; + }; + /** @enum {string} */ + Type: "text" | "number"; + ValuePayload: { + value: Record; + }; + ValueRecord: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + field_definition_id: number; + user_uid: string; + value: components["schemas"]["ValuePayload"]; + current_visibility: components["schemas"]["Visibility"]; + updated_by_uid: string; + updated_at: string; + }; + /** @enum {string} */ + Visibility: "private" | "users" | "public"; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "field_value_api-index": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Editable profile fields listed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["EditableField"][]; + }; + }; + }; + }; + /** @description Authenticated user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_value_api-upsert": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description Identifier of the field definition */ + fieldDefinitionId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Value payload to persist */ + value?: ({ + value?: (string | number | boolean) | null; + } | string | number | boolean) | null; + /** @description Visibility to apply to the stored value */ + currentVisibility?: string | null; + }; + }; + }; + responses: { + /** @description User field value stored successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ValueRecord"]; + }; + }; + }; + }; + /** @description Invalid field value payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Authenticated user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field cannot be edited by the user */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; + "field_value_api-update-visibility": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + /** @description Identifier of the field definition */ + fieldDefinitionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Visibility to apply to the stored value */ + currentVisibility: string; + }; + }; + }; + responses: { + /** @description Field visibility updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ValueRecord"]; + }; + }; + }; + }; + /** @description Invalid visibility payload or value missing */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Authenticated user is required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field visibility cannot be changed by the user */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Field definition not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; +} diff --git a/tests/php/Api/ApiTestCase.php b/tests/php/Api/ApiTestCase.php index 1f5df5b..64d79b5 100644 --- a/tests/php/Api/ApiTestCase.php +++ b/tests/php/Api/ApiTestCase.php @@ -92,10 +92,12 @@ public static function tearDownAfterClass(): void { protected function setUp(): void { parent::setUp(); + $baseUrl = $this->resolveApiBaseUrl(); + /** @var array $data */ $data = json_decode((string)file_get_contents(__DIR__ . '/../../../openapi-full.json'), true, flags: JSON_THROW_ON_ERROR); $data['servers'] = [ - ['url' => 'http://nginx'], + ['url' => $baseUrl], ]; $this->schema = Schema::getInstance($data); @@ -209,6 +211,21 @@ protected function uniqueFieldKey(string $prefix): string { return sprintf('%s_%d', $prefix, random_int(1000, 999999)); } + private function resolveApiBaseUrl(): string { + $configuredBaseUrl = getenv('PROFILE_FIELDS_API_BASE_URL'); + if (is_string($configuredBaseUrl) && $configuredBaseUrl !== '') { + return $configuredBaseUrl; + } + + $defaultBaseUrl = 'http://nginx'; + $defaultHost = parse_url($defaultBaseUrl, PHP_URL_HOST); + if (is_string($defaultHost) && gethostbyname($defaultHost) !== $defaultHost) { + return $defaultBaseUrl; + } + + $this->markTestSkipped('API contract tests require PROFILE_FIELDS_API_BASE_URL or a resolvable nginx host.'); + } + private static function ensureSchemaExists(): void { $connection = Server::get(IDBConnection::class); if ($connection->tableExists('profile_fields_definitions') && $connection->tableExists('profile_fields_values')) { diff --git a/tests/php/Unit/AppInfo/ApplicationTest.php b/tests/php/Unit/AppInfo/ApplicationTest.php new file mode 100644 index 0000000..6f32355 --- /dev/null +++ b/tests/php/Unit/AppInfo/ApplicationTest.php @@ -0,0 +1,53 @@ +createMock(IRequest::class); + $request->expects($this->once()) + ->method('getPathInfo') + ->willThrowException(new \RuntimeException('unsupported path context')); + $request->expects($this->once()) + ->method('getRequestUri') + ->willThrowException(new \RuntimeException('unsupported uri context')); + + $bootContext = $this->createMock(IBootContext::class); + $bootContext->expects($this->once()) + ->method('injectFn') + ->willReturnCallback(static function (callable $fn) use ($request): mixed { + return $fn($request); + }); + + $application = new Application(); + + $application->boot($bootContext); + + self::assertTrue(true); + } + + public function testBootIgnoresUnresolvableRequestInjection(): void { + $bootContext = $this->createMock(IBootContext::class); + $bootContext->expects($this->once()) + ->method('injectFn') + ->willThrowException(new \RuntimeException('request unavailable')); + + $application = new Application(); + + $application->boot($bootContext); + + self::assertTrue(true); + } +} diff --git a/tests/php/Unit/Command/Data/ExportTest.php b/tests/php/Unit/Command/Data/ExportTest.php index 0f490bc..0df1f3f 100644 --- a/tests/php/Unit/Command/Data/ExportTest.php +++ b/tests/php/Unit/Command/Data/ExportTest.php @@ -72,9 +72,37 @@ public function testExecuteExportsDefinitionsAndValuesAsJson(): void { /** @var array{exported_at: string, definitions: list>, values: list>} $payload */ $payload = json_decode($tester->getDisplay(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame(1, $payload['schema_version']); self::assertArrayHasKey('exported_at', $payload); self::assertSame('cost_center', $payload['definitions'][0]['field_key']); + self::assertSame('cost_center', $payload['values'][0]['field_key']); self::assertSame('alice', $payload['values'][0]['user_uid']); self::assertSame(['value' => 'finance'], $payload['values'][0]['value']); } + + public function testExecuteFailsWhenFieldKeyCannotBeResolved(): void { + $value = new FieldValue(); + $value->setId(11); + $value->setFieldDefinitionId(999); + $value->setUserUid('alice'); + $value->setValueJson('{"value":"finance"}'); + $value->setCurrentVisibility('users'); + $value->setUpdatedByUid('admin'); + $value->setUpdatedAt(new \DateTime('2026-03-03T12:00:00+00:00')); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findAllOrdered') + ->willReturn([]); + + $this->fieldValueMapper->expects($this->once()) + ->method('findAllOrdered') + ->willReturn([$value]); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute([]); + + self::assertSame(1, $exitCode); + self::assertStringContainsString('Failed to build export payload.', $tester->getDisplay()); + self::assertStringContainsString('Could not resolve field_key for field definition 999.', $tester->getDisplay()); + } } diff --git a/tests/php/Unit/Command/Data/ImportTest.php b/tests/php/Unit/Command/Data/ImportTest.php new file mode 100644 index 0000000..a929daf --- /dev/null +++ b/tests/php/Unit/Command/Data/ImportTest.php @@ -0,0 +1,84 @@ +dataImportService = $this->createMock(DataImportService::class); + $this->command = new Import($this->dataImportService); + } + + public function testExecuteRunsDryRunImportAndPrintsSummary(): void { + $payloadFile = tempnam(sys_get_temp_dir(), 'profile-fields-import-'); + $this->assertNotFalse($payloadFile); + file_put_contents($payloadFile, json_encode([ + 'schema_version' => 1, + 'definitions' => [], + 'values' => [], + ], JSON_THROW_ON_ERROR)); + + $this->dataImportService->expects($this->once()) + ->method('import') + ->with([ + 'schema_version' => 1, + 'definitions' => [], + 'values' => [], + ], true) + ->willReturn([ + 'created_definitions' => 1, + 'updated_definitions' => 2, + 'skipped_definitions' => 3, + 'created_values' => 4, + 'updated_values' => 5, + 'skipped_values' => 6, + ]); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute([ + '--input' => $payloadFile, + '--dry-run' => true, + ]); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('Profile Fields data import dry-run completed.', $tester->getDisplay()); + self::assertStringContainsString('Definitions: 1 created, 2 updated, 3 skipped.', $tester->getDisplay()); + self::assertStringContainsString('Values: 4 created, 5 updated, 6 skipped.', $tester->getDisplay()); + + @unlink($payloadFile); + } + + public function testExecuteFailsForInvalidJsonInput(): void { + $payloadFile = tempnam(sys_get_temp_dir(), 'profile-fields-import-invalid-'); + $this->assertNotFalse($payloadFile); + file_put_contents($payloadFile, '{invalid-json'); + + $this->dataImportService->expects($this->never())->method('import'); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute([ + '--input' => $payloadFile, + ]); + + self::assertSame(1, $exitCode); + self::assertStringContainsString('Failed to decode import payload JSON.', $tester->getDisplay()); + + @unlink($payloadFile); + } +} diff --git a/tests/php/Unit/Listener/BeforeTemplateRenderedListenerTest.php b/tests/php/Unit/Listener/BeforeTemplateRenderedListenerTest.php new file mode 100644 index 0000000..95bdcad --- /dev/null +++ b/tests/php/Unit/Listener/BeforeTemplateRenderedListenerTest.php @@ -0,0 +1,25 @@ +handle(new class() extends Event { + }); + + self::assertTrue(true); + } +} diff --git a/tests/php/Unit/Service/DataImportServiceTest.php b/tests/php/Unit/Service/DataImportServiceTest.php new file mode 100644 index 0000000..97bb7aa --- /dev/null +++ b/tests/php/Unit/Service/DataImportServiceTest.php @@ -0,0 +1,258 @@ +importPayloadValidator = $this->createMock(ImportPayloadValidator::class); + $this->fieldDefinitionService = $this->createMock(FieldDefinitionService::class); + $this->fieldValueService = $this->createMock(FieldValueService::class); + $this->connection = $this->createMock(IDBConnection::class); + $this->service = new DataImportService( + $this->importPayloadValidator, + $this->fieldDefinitionService, + $this->fieldValueService, + $this->connection, + ); + } + + public function testImportDryRunReturnsSummaryWithoutPersisting(): void { + $existingDefinition = $this->buildDefinition(7, 'cost_center', 'Cost center', 2, true); + $existingDefinition->setUpdatedAt(new \DateTime('2026-03-11T09:30:00+00:00')); + $existingValue = $this->buildValue(7, 'alice', ['value' => 'finance'], 'users', 'ops-admin'); + $existingValue->setUpdatedAt(new \DateTime('2026-03-15T12:00:00+00:00')); + + $this->importPayloadValidator->expects($this->once()) + ->method('validate') + ->willReturn($this->buildNormalizedPayload()); + + $this->fieldDefinitionService->expects($this->exactly(4)) + ->method('findByFieldKey') + ->willReturnMap([ + ['region', null], + ['cost_center', $existingDefinition], + ['region', null], + ['cost_center', $existingDefinition], + ]); + + $this->fieldValueService->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(7, 'alice') + ->willReturn($existingValue); + + $this->fieldValueService->expects($this->once()) + ->method('serializeForResponse') + ->with($existingValue) + ->willReturn([ + 'id' => 1, + 'field_definition_id' => 7, + 'user_uid' => 'alice', + 'value' => ['value' => 'finance'], + 'current_visibility' => 'users', + 'updated_by_uid' => 'ops-admin', + 'updated_at' => '2026-03-15T12:00:00+00:00', + ]); + + $this->connection->expects($this->never())->method('beginTransaction'); + + $summary = $this->service->import(['schema_version' => 1], true); + + $this->assertSame([ + 'created_definitions' => 1, + 'updated_definitions' => 0, + 'skipped_definitions' => 1, + 'created_values' => 1, + 'updated_values' => 0, + 'skipped_values' => 1, + ], $summary); + } + + public function testImportPersistsCreatesAndUpdatesInsideTransaction(): void { + $existingDefinition = $this->buildDefinition(7, 'cost_center', 'Old cost center', 1, true); + $updatedDefinition = $this->buildDefinition(7, 'cost_center', 'Cost center', 2, true); + $createdDefinition = $this->buildDefinition(8, 'region', 'Region', 0, true); + $existingValue = $this->buildValue(7, 'alice', ['value' => 'legacy'], 'users', 'legacy-admin'); + + $this->importPayloadValidator->expects($this->once()) + ->method('validate') + ->willReturn($this->buildNormalizedPayload()); + + $this->fieldDefinitionService->expects($this->exactly(2)) + ->method('findByFieldKey') + ->willReturnMap([ + ['region', null], + ['cost_center', $existingDefinition], + ]); + + $this->fieldDefinitionService->expects($this->once()) + ->method('create') + ->with($this->callback(static fn (array $definition): bool => $definition['field_key'] === 'region' && $definition['created_at'] === '2026-03-10T08:00:00+00:00' && $definition['updated_at'] === '2026-03-10T08:00:00+00:00')) + ->willReturn($createdDefinition); + + $this->fieldDefinitionService->expects($this->once()) + ->method('update') + ->with( + $existingDefinition, + $this->callback(static fn (array $definition): bool => $definition['field_key'] === 'cost_center' && $definition['label'] === 'Cost center' && $definition['sort_order'] === 2 && $definition['updated_at'] === '2026-03-11T09:30:00+00:00'), + ) + ->willReturn($updatedDefinition); + + $this->fieldValueService->expects($this->exactly(2)) + ->method('findByFieldDefinitionIdAndUserUid') + ->willReturnMap([ + [8, 'bob', null], + [7, 'alice', $existingValue], + ]); + + $this->fieldValueService->expects($this->once()) + ->method('serializeForResponse') + ->with($existingValue) + ->willReturn([ + 'id' => 1, + 'field_definition_id' => 7, + 'user_uid' => 'alice', + 'value' => ['value' => 'legacy'], + 'current_visibility' => 'users', + 'updated_by_uid' => 'legacy-admin', + 'updated_at' => '2026-03-15T12:00:00+00:00', + ]); + + $this->fieldValueService->expects($this->exactly(2)) + ->method('upsert') + ->with( + $this->callback(static fn (FieldDefinition $definition): bool => in_array($definition->getFieldKey(), ['region', 'cost_center'], true)), + $this->callback(static fn (string $userUid): bool => in_array($userUid, ['bob', 'alice'], true)), + $this->callback(static fn (mixed $value): bool => in_array($value, ['emea', 'finance'], true)), + $this->callback(static fn (string $updatedByUid): bool => in_array($updatedByUid, ['admin', 'ops-admin'], true)), + 'users', + $this->callback(static fn (\DateTimeInterface $updatedAt): bool => in_array($updatedAt->format(DATE_ATOM), ['2026-03-15T12:00:00+00:00'], true)), + ); + + $this->connection->expects($this->once())->method('beginTransaction'); + $this->connection->expects($this->once())->method('commit'); + $this->connection->expects($this->never())->method('rollBack'); + + $summary = $this->service->import(['schema_version' => 1], false); + + $this->assertSame([ + 'created_definitions' => 1, + 'updated_definitions' => 1, + 'skipped_definitions' => 0, + 'created_values' => 1, + 'updated_values' => 1, + 'skipped_values' => 0, + ], $summary); + } + + /** + * @return array{ + * schema_version: int, + * definitions: list>, + * values: list>, + * } + */ + private function buildNormalizedPayload(): array { + return [ + 'schema_version' => 1, + 'definitions' => [ + [ + 'field_key' => 'region', + 'label' => 'Region', + 'type' => 'text', + 'admin_only' => false, + 'user_editable' => false, + 'user_visible' => true, + 'initial_visibility' => 'users', + 'sort_order' => 0, + 'active' => true, + 'created_at' => '2026-03-10T08:00:00+00:00', + 'updated_at' => '2026-03-10T08:00:00+00:00', + ], + [ + 'field_key' => 'cost_center', + 'label' => 'Cost center', + 'type' => 'text', + 'admin_only' => false, + 'user_editable' => false, + 'user_visible' => true, + 'initial_visibility' => 'users', + 'sort_order' => 2, + 'active' => true, + 'created_at' => '2026-03-01T12:00:00+00:00', + 'updated_at' => '2026-03-11T09:30:00+00:00', + ], + ], + 'values' => [ + [ + 'field_key' => 'region', + 'user_uid' => 'bob', + 'value' => ['value' => 'emea'], + 'current_visibility' => 'users', + 'updated_by_uid' => 'admin', + 'updated_at' => '2026-03-15T12:00:00+00:00', + ], + [ + 'field_key' => 'cost_center', + 'user_uid' => 'alice', + 'value' => ['value' => 'finance'], + 'current_visibility' => 'users', + 'updated_by_uid' => 'ops-admin', + 'updated_at' => '2026-03-15T12:00:00+00:00', + ], + ], + ]; + } + + private function buildDefinition(int $id, string $fieldKey, string $label, int $sortOrder, bool $active): FieldDefinition { + $definition = new FieldDefinition(); + $definition->setId($id); + $definition->setFieldKey($fieldKey); + $definition->setLabel($label); + $definition->setType('text'); + $definition->setAdminOnly(false); + $definition->setUserEditable(false); + $definition->setUserVisible(true); + $definition->setInitialVisibility('users'); + $definition->setSortOrder($sortOrder); + $definition->setActive($active); + $definition->setCreatedAt(new \DateTime('2026-03-01T12:00:00+00:00')); + $definition->setUpdatedAt(new \DateTime('2026-03-02T12:00:00+00:00')); + return $definition; + } + + private function buildValue(int $fieldDefinitionId, string $userUid, array $value, string $currentVisibility, string $updatedByUid): FieldValue { + $fieldValue = new FieldValue(); + $fieldValue->setId(1); + $fieldValue->setFieldDefinitionId($fieldDefinitionId); + $fieldValue->setUserUid($userUid); + $fieldValue->setValueJson(json_encode($value, JSON_THROW_ON_ERROR)); + $fieldValue->setCurrentVisibility($currentVisibility); + $fieldValue->setUpdatedByUid($updatedByUid); + $fieldValue->setUpdatedAt(new \DateTime('2026-03-15T12:00:00+00:00')); + return $fieldValue; + } +} diff --git a/tests/php/Unit/Service/FieldDefinitionServiceTest.php b/tests/php/Unit/Service/FieldDefinitionServiceTest.php index 58efffc..f464bf2 100644 --- a/tests/php/Unit/Service/FieldDefinitionServiceTest.php +++ b/tests/php/Unit/Service/FieldDefinitionServiceTest.php @@ -82,6 +82,33 @@ public function testCreatePersistsValidatedDefinition(): void { $this->assertSame('performance_score', $created->getFieldKey()); } + public function testCreatePreservesImportedTimestamps(): void { + $this->fieldDefinitionMapper + ->method('findByFieldKey') + ->willReturn(null); + + $this->fieldDefinitionMapper + ->expects($this->once()) + ->method('insert') + ->with($this->callback(function (FieldDefinition $definition): bool { + $this->assertSame('2026-03-10T08:00:00+00:00', $definition->getCreatedAt()->format(DATE_ATOM)); + $this->assertSame('2026-03-11T09:30:00+00:00', $definition->getUpdatedAt()->format(DATE_ATOM)); + return true; + })) + ->willReturnCallback(static fn (FieldDefinition $definition): FieldDefinition => $definition); + + $created = $this->service->create([ + 'field_key' => 'region', + 'label' => 'Region', + 'type' => FieldType::TEXT->value, + 'created_at' => '2026-03-10T08:00:00+00:00', + 'updated_at' => '2026-03-11T09:30:00+00:00', + ]); + + $this->assertSame('2026-03-10T08:00:00+00:00', $created->getCreatedAt()->format(DATE_ATOM)); + $this->assertSame('2026-03-11T09:30:00+00:00', $created->getUpdatedAt()->format(DATE_ATOM)); + } + public function testUpdateRejectsFieldKeyRename(): void { $existing = new FieldDefinition(); $existing->setId(7); @@ -121,4 +148,37 @@ public function testUpdateRejectsTypeChangeWhenValuesExist(): void { 'type' => FieldType::NUMBER->value, ]); } + + public function testUpdatePreservesImportedUpdatedAt(): void { + $existing = new FieldDefinition(); + $existing->setId(7); + $existing->setFieldKey('cpf'); + $existing->setLabel('CPF'); + $existing->setType(FieldType::TEXT->value); + $existing->setAdminOnly(false); + $existing->setUserEditable(false); + $existing->setUserVisible(true); + $existing->setInitialVisibility('private'); + $existing->setSortOrder(0); + $existing->setActive(true); + $existing->setCreatedAt(new \DateTime('2026-03-01T00:00:00+00:00')); + $existing->setUpdatedAt(new \DateTime('2026-03-02T00:00:00+00:00')); + + $this->fieldDefinitionMapper + ->expects($this->once()) + ->method('update') + ->with($this->callback(function (FieldDefinition $definition): bool { + $this->assertSame('2026-03-12T10:00:00+00:00', $definition->getUpdatedAt()->format(DATE_ATOM)); + return true; + })) + ->willReturnCallback(static fn (FieldDefinition $definition): FieldDefinition => $definition); + + $updated = $this->service->update($existing, [ + 'label' => 'CPF', + 'type' => FieldType::TEXT->value, + 'updated_at' => '2026-03-12T10:00:00+00:00', + ]); + + $this->assertSame('2026-03-12T10:00:00+00:00', $updated->getUpdatedAt()->format(DATE_ATOM)); + } } diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index 0d44d4f..64995c1 100644 --- a/tests/php/Unit/Service/FieldValueServiceTest.php +++ b/tests/php/Unit/Service/FieldValueServiceTest.php @@ -71,6 +71,37 @@ public function testUpsertPersistsSerializedValue(): void { $this->assertSame('{"value":9.5}', $stored->getValueJson()); } + public function testUpsertPreservesImportedUpdatedAt(): void { + $definition = $this->buildDefinition(FieldType::TEXT->value); + $definition->setId(3); + $definition->setInitialVisibility('users'); + + $this->fieldValueMapper + ->method('findByFieldDefinitionIdAndUserUid') + ->with(3, 'alice') + ->willReturn(null); + + $this->fieldValueMapper + ->expects($this->once()) + ->method('insert') + ->with($this->callback(function (FieldValue $value): bool { + $this->assertSame('2026-03-12T14:00:00+00:00', $value->getUpdatedAt()->format(DATE_ATOM)); + return true; + })) + ->willReturnCallback(static fn (FieldValue $value): FieldValue => $value); + + $stored = $this->service->upsert( + $definition, + 'alice', + 'finance', + 'admin', + 'users', + new \DateTimeImmutable('2026-03-12T14:00:00+00:00'), + ); + + $this->assertSame('2026-03-12T14:00:00+00:00', $stored->getUpdatedAt()->format(DATE_ATOM)); + } + public function testSerializeForResponseReturnsDecodedPayload(): void { $value = new FieldValue(); $value->setId(10); diff --git a/tests/php/Unit/Service/ImportPayloadValidatorTest.php b/tests/php/Unit/Service/ImportPayloadValidatorTest.php new file mode 100644 index 0000000..ba8c675 --- /dev/null +++ b/tests/php/Unit/Service/ImportPayloadValidatorTest.php @@ -0,0 +1,153 @@ +fieldDefinitionService = $this->createMock(FieldDefinitionService::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->validator = new ImportPayloadValidator( + new FieldDefinitionValidator(), + $this->fieldDefinitionService, + $this->userManager, + ); + } + + public function testValidateAcceptsVersionedPayload(): void { + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('cost_center') + ->willReturn(null); + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with('alice') + ->willReturn(true); + + $validated = $this->validator->validate([ + 'schema_version' => 1, + 'definitions' => [[ + 'field_key' => 'cost_center', + 'label' => 'Cost center', + 'type' => FieldType::TEXT->value, + 'admin_only' => false, + 'user_editable' => false, + 'user_visible' => true, + 'initial_visibility' => 'users', + 'sort_order' => 1, + 'active' => true, + 'created_at' => '2026-03-10T08:00:00+00:00', + 'updated_at' => '2026-03-11T09:30:00+00:00', + ]], + 'values' => [[ + 'field_key' => 'cost_center', + 'user_uid' => 'alice', + 'value' => ['value' => 'finance'], + 'current_visibility' => 'users', + 'updated_by_uid' => 'admin', + 'updated_at' => '2026-03-15T12:00:00+00:00', + ]], + ]); + + $this->assertSame(1, $validated['schema_version']); + $this->assertSame('cost_center', $validated['definitions'][0]['field_key']); + $this->assertSame('2026-03-10T08:00:00+00:00', $validated['definitions'][0]['created_at']); + $this->assertSame('2026-03-11T09:30:00+00:00', $validated['definitions'][0]['updated_at']); + $this->assertSame(['value' => 'finance'], $validated['values'][0]['value']); + } + + public function testRejectUnsupportedSchemaVersion(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('schema_version must be 1'); + + $this->validator->validate([ + 'schema_version' => 2, + 'definitions' => [], + 'values' => [], + ]); + } + + public function testRejectMissingDestinationUser(): void { + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('cost_center') + ->willReturn(null); + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with('ghost') + ->willReturn(false); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('values[0].user_uid does not exist in destination instance'); + + $this->validator->validate([ + 'schema_version' => 1, + 'definitions' => [[ + 'field_key' => 'cost_center', + 'label' => 'Cost center', + 'type' => FieldType::TEXT->value, + ]], + 'values' => [[ + 'field_key' => 'cost_center', + 'user_uid' => 'ghost', + 'value' => ['value' => 'finance'], + 'current_visibility' => 'users', + 'updated_by_uid' => 'admin', + 'updated_at' => '2026-03-15T12:00:00+00:00', + ]], + ]); + } + + public function testRejectIncompatibleExistingDefinition(): void { + $existingDefinition = new FieldDefinition(); + $existingDefinition->setId(7); + $existingDefinition->setFieldKey('cost_center'); + $existingDefinition->setLabel('Cost center'); + $existingDefinition->setType(FieldType::NUMBER->value); + $existingDefinition->setAdminOnly(false); + $existingDefinition->setUserEditable(false); + $existingDefinition->setUserVisible(true); + $existingDefinition->setInitialVisibility('users'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('cost_center') + ->willReturn($existingDefinition); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('definitions[0].field_key conflicts with an incompatible existing definition'); + + $this->validator->validate([ + 'schema_version' => 1, + 'definitions' => [[ + 'field_key' => 'cost_center', + 'label' => 'Cost center', + 'type' => FieldType::TEXT->value, + ]], + 'values' => [], + ]); + } +}