From b74fba544d511c9b84f080bf8eba6c7220f1482a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:03:57 -0300 Subject: [PATCH 01/58] feat(command): add data import cli command Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Command/Data/Import.php | 98 +++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 lib/Command/Data/Import.php diff --git a/lib/Command/Data/Import.php b/lib/Command/Data/Import.php new file mode 100644 index 0000000..62c0fbb --- /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; + } +} \ No newline at end of file From e53127db04393e91077157d7a4ab79e1135d849c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:03:57 -0300 Subject: [PATCH 02/58] feat(service): add import payload validator Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/ImportPayloadValidator.php | 247 +++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 lib/Service/ImportPayloadValidator.php diff --git a/lib/Service/ImportPayloadValidator.php b/lib/Service/ImportPayloadValidator.php new file mode 100644 index 0000000..62a04ee --- /dev/null +++ b/lib/Service/ImportPayloadValidator.php @@ -0,0 +1,247 @@ + $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); + $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; + } + + 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{ + * 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']; + } +} \ No newline at end of file From 7a50ab5807db8fadbb4ee4841df5eb85e50d918b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:03:58 -0300 Subject: [PATCH 03/58] feat(service): add transactional data import service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/DataImportService.php | 283 ++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 lib/Service/DataImportService.php diff --git a/lib/Service/DataImportService.php b/lib/Service/DataImportService.php new file mode 100644 index 0000000..0bb5427 --- /dev/null +++ b/lib/Service/DataImportService.php @@ -0,0 +1,283 @@ + $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'], + ); + $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'], + ); + $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, + * } $definition + */ + private function definitionNeedsUpdate(FieldDefinition $existingDefinition, array $definition): bool { + return $existingDefinition->getLabel() !== $definition['label'] + || $existingDefinition->getSortOrder() !== $definition['sort_order'] + || $existingDefinition->getActive() !== $definition['active']; + } + + /** + * @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']; + } +} \ No newline at end of file From d89fc509a684b442eefcdfa1320feaffa82ed5fa Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:03:58 -0300 Subject: [PATCH 04/58] feat(command): version export payload contract Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Command/Data/Export.php | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/Command/Data/Export.php b/lib/Command/Data/Export.php index c119858..686ff8b 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,14 +49,21 @@ protected function configure(): void { #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { + $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(), - $this->fieldDefinitionService->findAllOrdered(), + $definitions, ), 'values' => array_map( - fn (FieldValue $value): array => $this->serializeValue($value), + fn (FieldValue $value): array => $this->serializeValue($value, $fieldKeysByDefinitionId), $this->fieldValueMapper->findAllOrdered(), ), ]; @@ -88,16 +97,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(), From e669ab19db6ae433e2f4de4ef3c71dd981a4d227 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:03:58 -0300 Subject: [PATCH 05/58] feat(appinfo): register data import command Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- appinfo/info.xml | 1 + 1 file changed, 1 insertion(+) 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 From e025eb7259eab53bfb5da60992a3a7ae824a8753 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:03:58 -0300 Subject: [PATCH 06/58] test(command): cover data import cli command Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Command/Data/ImportTest.php | 84 ++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/php/Unit/Command/Data/ImportTest.php diff --git a/tests/php/Unit/Command/Data/ImportTest.php b/tests/php/Unit/Command/Data/ImportTest.php new file mode 100644 index 0000000..f582a51 --- /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); + } +} \ No newline at end of file From dc2f0f2c8a3affd422c9adb87eb2f95da5c1bbc1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:03:58 -0300 Subject: [PATCH 07/58] test(service): cover import payload validator Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Service/ImportPayloadValidatorTest.php | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/php/Unit/Service/ImportPayloadValidatorTest.php diff --git a/tests/php/Unit/Service/ImportPayloadValidatorTest.php b/tests/php/Unit/Service/ImportPayloadValidatorTest.php new file mode 100644 index 0000000..952b23c --- /dev/null +++ b/tests/php/Unit/Service/ImportPayloadValidatorTest.php @@ -0,0 +1,149 @@ +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, + ]], + '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(['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' => [], + ]); + } +} \ No newline at end of file From edbd403147bfd5416a9b34e9ecf0f0ec11a515cf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:03:58 -0300 Subject: [PATCH 08/58] test(service): cover transactional data import service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Service/DataImportServiceTest.php | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 tests/php/Unit/Service/DataImportServiceTest.php diff --git a/tests/php/Unit/Service/DataImportServiceTest.php b/tests/php/Unit/Service/DataImportServiceTest.php new file mode 100644 index 0000000..440b110 --- /dev/null +++ b/tests/php/Unit/Service/DataImportServiceTest.php @@ -0,0 +1,251 @@ +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); + $existingValue = $this->buildValue(7, 'alice', ['value' => 'finance'], 'users', 'ops-admin'); + + $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')) + ->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), + ) + ->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->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, + ], + [ + '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, + ], + ], + '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; + } +} \ No newline at end of file From ee51128535d4dac99f376284fa3a701bc737d8fe Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:03:58 -0300 Subject: [PATCH 09/58] test(command): cover versioned export payload Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Command/Data/ExportTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/php/Unit/Command/Data/ExportTest.php b/tests/php/Unit/Command/Data/ExportTest.php index 0f490bc..efb101a 100644 --- a/tests/php/Unit/Command/Data/ExportTest.php +++ b/tests/php/Unit/Command/Data/ExportTest.php @@ -72,8 +72,10 @@ 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']); } From 34d11cb55fa79fd0a9a019d29a07b963e996193f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:25:17 -0300 Subject: [PATCH 10/58] fix(appinfo): guard boot request access Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/AppInfo/Application.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 42c7f20..e8f3a28 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -39,17 +39,27 @@ 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(); + $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; From 1c098d6ba1687d62bae19865a327a5e53efc5d50 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:25:22 -0300 Subject: [PATCH 11/58] fix(command): handle export payload build failures Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Command/Data/Export.php | 40 +++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/lib/Command/Data/Export.php b/lib/Command/Data/Export.php index 686ff8b..30391d8 100644 --- a/lib/Command/Data/Export.php +++ b/lib/Command/Data/Export.php @@ -49,24 +49,30 @@ protected function configure(): void { #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { - $definitions = $this->fieldDefinitionService->findAllOrdered(); - $fieldKeysByDefinitionId = []; - foreach ($definitions as $definition) { - $fieldKeysByDefinitionId[$definition->getId()] = $definition->getFieldKey(); - } + 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(), - ), - ]; + $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( From 4aa9f7559182d0c57bbdc95a925524db87c6cff0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:25:27 -0300 Subject: [PATCH 12/58] chore(command): normalize import command formatting Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Command/Data/Import.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Command/Data/Import.php b/lib/Command/Data/Import.php index 62c0fbb..d70962b 100644 --- a/lib/Command/Data/Import.php +++ b/lib/Command/Data/Import.php @@ -95,4 +95,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } -} \ No newline at end of file +} From 613fe93536b7cd6e4cf1a83cc676c28730b26a92 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:25:44 -0300 Subject: [PATCH 13/58] fix(listener): restore php81 compatibility Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Listener/BeforeTemplateRenderedListener.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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') { From e7aed07a829f7072f5884958cdb0eafe5edf319d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:25:52 -0300 Subject: [PATCH 14/58] fix(service): honor imported metadata in data import Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/DataImportService.php | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/Service/DataImportService.php b/lib/Service/DataImportService.php index 0bb5427..9971469 100644 --- a/lib/Service/DataImportService.php +++ b/lib/Service/DataImportService.php @@ -9,11 +9,9 @@ namespace OCA\ProfileFields\Service; +use DateTime; use OCA\ProfileFields\Db\FieldDefinition; use OCA\ProfileFields\Db\FieldValue; -use OCA\ProfileFields\Service\FieldDefinitionService; -use OCA\ProfileFields\Service\FieldValueService; -use OCA\ProfileFields\Service\ImportPayloadValidator; use OCP\IDBConnection; class DataImportService { @@ -78,6 +76,8 @@ public function import(array $payload, bool $dryRun = false): array { * initial_visibility: 'private'|'users'|'public', * sort_order: int, * active: bool, + * created_at?: non-empty-string, + * updated_at?: non-empty-string, * }> $definitions * @param array{ * created_definitions: int, @@ -157,6 +157,8 @@ private function collectValueSummary(array $values, array &$summary): void { * initial_visibility: 'private'|'users'|'public', * sort_order: int, * active: bool, + * created_at?: non-empty-string, + * updated_at?: non-empty-string, * }> $definitions * @param array{ * created_definitions: int, @@ -223,6 +225,7 @@ private function persistValues(array $values, array $definitionsByFieldKey, arra $value['value']['value'], $value['updated_by_uid'], $value['current_visibility'], + new DateTime($value['updated_at']), ); $summary['created_values']++; continue; @@ -235,6 +238,7 @@ private function persistValues(array $values, array $definitionsByFieldKey, arra $value['value']['value'], $value['updated_by_uid'], $value['current_visibility'], + new DateTime($value['updated_at']), ); $summary['updated_values']++; continue; @@ -255,12 +259,15 @@ private function persistValues(array $values, array $definitionsByFieldKey, arra * 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']; + || $existingDefinition->getActive() !== $definition['active'] + || (($definition['updated_at'] ?? null) !== null && $existingDefinition->getUpdatedAt()->format(DATE_ATOM) !== $definition['updated_at']); } /** @@ -278,6 +285,7 @@ private function valueNeedsUpdate(FieldValue $existingValue, array $value): bool return $serializedValue['value'] !== $value['value'] || $serializedValue['current_visibility'] !== $value['current_visibility'] - || $serializedValue['updated_by_uid'] !== $value['updated_by_uid']; + || $serializedValue['updated_by_uid'] !== $value['updated_by_uid'] + || $serializedValue['updated_at'] !== $value['updated_at']; } -} \ No newline at end of file +} From e0a92cc9f9d4336efb12e9783107369b5faf826f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:25:58 -0300 Subject: [PATCH 15/58] fix(service): preserve imported definition timestamps Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FieldDefinitionService.php | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 */ From cdc7741cb1ead7cf472e408179fff303692ce16a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:26:04 -0300 Subject: [PATCH 16/58] fix(service): preserve imported field value timestamps Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FieldValueService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index f50f5b1..8bf46b3 100644 --- a/lib/Service/FieldValueService.php +++ b/lib/Service/FieldValueService.php @@ -27,7 +27,7 @@ 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, ?DateTime $updatedAt = null): FieldValue { $normalizedValue = $this->normalizeValue($definition, $rawValue); $visibility = $currentVisibility ?? $definition->getInitialVisibility(); if (!FieldVisibility::isValid($visibility)) { @@ -40,7 +40,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($updatedAt ?? new DateTime()); if ($entity->getId() === null) { return $this->fieldValueMapper->insert($entity); From 80bf1606fb5ac6dd151d04072eb948b095193363 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:26:12 -0300 Subject: [PATCH 17/58] fix(service): preserve import definition metadata Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/ImportPayloadValidator.php | 35 +++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/Service/ImportPayloadValidator.php b/lib/Service/ImportPayloadValidator.php index 62a04ee..323c6e6 100644 --- a/lib/Service/ImportPayloadValidator.php +++ b/lib/Service/ImportPayloadValidator.php @@ -39,6 +39,8 @@ public function __construct( * initial_visibility: 'private'|'users'|'public', * sort_order: int, * active: bool, + * created_at?: non-empty-string, + * updated_at?: non-empty-string, * }>, * values: list */ private function validateDefinitions(array $definitions): array { @@ -89,6 +93,8 @@ private function validateDefinitions(array $definitions): array { } $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])) { @@ -101,6 +107,12 @@ private function validateDefinitions(array $definitions): array { } $normalizedDefinitions[$fieldKey] = $validatedDefinition; + if ($createdAt !== null) { + $normalizedDefinitions[$fieldKey]['created_at'] = $createdAt; + } + if ($updatedAt !== null) { + $normalizedDefinitions[$fieldKey]['updated_at'] = $updatedAt; + } } return $normalizedDefinitions; @@ -118,6 +130,8 @@ private function validateDefinitions(array $definitions): array { * initial_visibility: 'private'|'users'|'public', * sort_order: int, * active: bool, + * created_at?: non-empty-string, + * updated_at?: non-empty-string, * }> $definitions * @return list $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, @@ -244,4 +277,4 @@ private function isCompatibleDefinition(FieldDefinition $existingDefinition, arr && $existingDefinition->getUserVisible() === $definition['user_visible'] && $existingDefinition->getInitialVisibility() === $definition['initial_visibility']; } -} \ No newline at end of file +} From ed75f179bda02b0c332eaf7d9419008807f46979 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:26:32 -0300 Subject: [PATCH 18/58] chore(psalm): align target php with ci Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- psalm.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 113c40bd4c505dba75b9738cb352b9b1a83207ba Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:26:43 -0300 Subject: [PATCH 19/58] test(appinfo): cover boot in cli context Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/AppInfo/ApplicationTest.php | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/php/Unit/AppInfo/ApplicationTest.php diff --git a/tests/php/Unit/AppInfo/ApplicationTest.php b/tests/php/Unit/AppInfo/ApplicationTest.php new file mode 100644 index 0000000..df38021 --- /dev/null +++ b/tests/php/Unit/AppInfo/ApplicationTest.php @@ -0,0 +1,45 @@ +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')); + + $serverContainer = $this->createMock(IServerContainer::class); + $serverContainer->expects($this->once()) + ->method('get') + ->with(IRequest::class) + ->willReturn($request); + + $bootContext = $this->createMock(IBootContext::class); + $bootContext->expects($this->once()) + ->method('getServerContainer') + ->willReturn($serverContainer); + + $application = new Application(); + + $application->boot($bootContext); + + self::assertTrue(true); + } +} From 5a423be19f74963742d38c3de3707664a96a1db6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:26:50 -0300 Subject: [PATCH 20/58] test(command): cover export payload failures Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Command/Data/ExportTest.php | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/php/Unit/Command/Data/ExportTest.php b/tests/php/Unit/Command/Data/ExportTest.php index efb101a..0df1f3f 100644 --- a/tests/php/Unit/Command/Data/ExportTest.php +++ b/tests/php/Unit/Command/Data/ExportTest.php @@ -79,4 +79,30 @@ public function testExecuteExportsDefinitionsAndValuesAsJson(): void { 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()); + } } From ed9fe87a960096bc873dddf3bf6c0590b77a4861 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:26:57 -0300 Subject: [PATCH 21/58] chore(test): normalize import command test formatting Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Command/Data/ImportTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/Unit/Command/Data/ImportTest.php b/tests/php/Unit/Command/Data/ImportTest.php index f582a51..a929daf 100644 --- a/tests/php/Unit/Command/Data/ImportTest.php +++ b/tests/php/Unit/Command/Data/ImportTest.php @@ -81,4 +81,4 @@ public function testExecuteFailsForInvalidJsonInput(): void { @unlink($payloadFile); } -} \ No newline at end of file +} From 3f0864b23e2e2f3dfde3e45967f7bd695b16cdbd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:27:05 -0300 Subject: [PATCH 22/58] test(service): cover data import metadata Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Service/DataImportServiceTest.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/php/Unit/Service/DataImportServiceTest.php b/tests/php/Unit/Service/DataImportServiceTest.php index 440b110..97bb7aa 100644 --- a/tests/php/Unit/Service/DataImportServiceTest.php +++ b/tests/php/Unit/Service/DataImportServiceTest.php @@ -42,7 +42,9 @@ protected function setUp(): void { 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') @@ -108,14 +110,14 @@ public function testImportPersistsCreatesAndUpdatesInsideTransaction(): void { $this->fieldDefinitionService->expects($this->once()) ->method('create') - ->with($this->callback(static fn (array $definition): bool => $definition['field_key'] === 'region')) + ->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), + $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); @@ -147,6 +149,7 @@ public function testImportPersistsCreatesAndUpdatesInsideTransaction(): void { $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'); @@ -186,6 +189,8 @@ private function buildNormalizedPayload(): array { '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', @@ -197,6 +202,8 @@ private function buildNormalizedPayload(): array { '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' => [ @@ -248,4 +255,4 @@ private function buildValue(int $fieldDefinitionId, string $userUid, array $valu $fieldValue->setUpdatedAt(new \DateTime('2026-03-15T12:00:00+00:00')); return $fieldValue; } -} \ No newline at end of file +} From 3bf5b990515b73ef80fa4dea0281bf2a7fcdd6d8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:27:14 -0300 Subject: [PATCH 23/58] test(service): cover imported definition timestamps Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Service/FieldDefinitionServiceTest.php | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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)); + } } From 81e1956df772d8de84858a6c3173f9385b089327 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:27:29 -0300 Subject: [PATCH 24/58] test(service): cover imported field value timestamps Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Service/FieldValueServiceTest.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index 0d44d4f..131e01f 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 \DateTime('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); From cf0f886e0f7c29c959681f8f6c9338b0648255d3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:27:35 -0300 Subject: [PATCH 25/58] test(service): cover import definition metadata Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Service/ImportPayloadValidatorTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/php/Unit/Service/ImportPayloadValidatorTest.php b/tests/php/Unit/Service/ImportPayloadValidatorTest.php index 952b23c..ba8c675 100644 --- a/tests/php/Unit/Service/ImportPayloadValidatorTest.php +++ b/tests/php/Unit/Service/ImportPayloadValidatorTest.php @@ -58,6 +58,8 @@ public function testValidateAcceptsVersionedPayload(): void { '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', @@ -71,6 +73,8 @@ public function testValidateAcceptsVersionedPayload(): void { $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']); } @@ -146,4 +150,4 @@ public function testRejectIncompatibleExistingDefinition(): void { 'values' => [], ]); } -} \ No newline at end of file +} From ab4c6b52694892aedb3b890a145f6b81d44f733f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:35:40 -0300 Subject: [PATCH 26/58] refactor(service): accept datetime interface in field value service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FieldValueService.php | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index 8bf46b3..f4becff 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,7 @@ 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, ?DateTime $updatedAt = 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 +41,7 @@ public function upsert(FieldDefinition $definition, string $userUid, array|strin $entity->setValueJson($this->encodeValue($normalizedValue)); $entity->setCurrentVisibility($visibility); $entity->setUpdatedByUid($updatedByUid); - $entity->setUpdatedAt($updatedAt ?? new DateTime()); + $entity->setUpdatedAt($this->asMutableDateTime($updatedAt)); if ($entity->getId() === null) { return $this->fieldValueMapper->insert($entity); @@ -101,7 +102,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 +181,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(); + } } From 73eacb040882327a3a8fd01736e84195c600496a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:35:49 -0300 Subject: [PATCH 27/58] test(service): cover datetime interface support Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Service/FieldValueServiceTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index 131e01f..64995c1 100644 --- a/tests/php/Unit/Service/FieldValueServiceTest.php +++ b/tests/php/Unit/Service/FieldValueServiceTest.php @@ -96,7 +96,7 @@ public function testUpsertPreservesImportedUpdatedAt(): void { 'finance', 'admin', 'users', - new \DateTime('2026-03-12T14:00:00+00:00'), + new \DateTimeImmutable('2026-03-12T14:00:00+00:00'), ); $this->assertSame('2026-03-12T14:00:00+00:00', $stored->getUpdatedAt()->format(DATE_ATOM)); From fa8bf8f5d8b29f195b984666fa32145b154b958a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:42:55 -0300 Subject: [PATCH 28/58] chore(psalm): restore php82 minimum Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- psalm.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psalm.xml b/psalm.xml index 22e35d0..47c0422 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.1" + phpVersion="8.2" 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" From 4753f8f9f501fc6407bdd0abbd8645cf20ec15fb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:43:04 -0300 Subject: [PATCH 29/58] refactor(listener): restore readonly listener Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Listener/BeforeTemplateRenderedListener.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Listener/BeforeTemplateRenderedListener.php b/lib/Listener/BeforeTemplateRenderedListener.php index 7ce18fa..5dbbc6b 100644 --- a/lib/Listener/BeforeTemplateRenderedListener.php +++ b/lib/Listener/BeforeTemplateRenderedListener.php @@ -16,7 +16,7 @@ /** * @template-implements IEventListener */ -class BeforeTemplateRenderedListener implements IEventListener { +readonly class BeforeTemplateRenderedListener implements IEventListener { #[\Override] public function handle(Event $event): void { if ($event::class !== '\\OCA\\Settings\\Events\\BeforeTemplateRenderedEvent') { From 0b12067edc3a62243f785e8104eec24fca469973 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:46:18 -0300 Subject: [PATCH 30/58] style(ci): wrap lint php workflow arguments Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/lint-php.yml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint-php.yml b/.github/workflows/lint-php.yml index 2229f23..9ee2c21 100644 --- a/.github/workflows/lint-php.yml +++ b/.github/workflows/lint-php.yml @@ -50,7 +50,29 @@ jobs: uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: ${{ matrix.php-versions }} - extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite + extensions: >- + bz2, + ctype, + curl, + dom, + fileinfo, + gd, + iconv, + intl, + json, + libxml, + mbstring, + openssl, + pcntl, + posix, + session, + simplexml, + xmlreader, + xmlwriter, + zip, + zlib, + sqlite, + pdo_sqlite coverage: none ini-file: development env: @@ -75,4 +97,7 @@ jobs: steps: - name: Summary status - run: if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then exit 1; fi + run: | + if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then + exit 1 + fi From bafb75fc02d5bb48161109b57e72fec680324c14 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:46:19 -0300 Subject: [PATCH 31/58] style(ci): wrap psalm workflow arguments Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/psalm.yml | 42 +++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 76a7cd6..36b12e5 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -30,13 +30,38 @@ jobs: uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2 - name: Check enforcement of minimum PHP version ${{ steps.versions.outputs.php-min }} in psalm.xml - run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}"' psalm.xml + run: | + grep \ + 'phpVersion="${{ steps.versions.outputs.php-min }}"' \ + psalm.xml - name: Set up php${{ steps.versions.outputs.php-available }} uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: ${{ steps.versions.outputs.php-available }} - extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite + extensions: >- + bz2, + ctype, + curl, + dom, + fileinfo, + gd, + iconv, + intl, + json, + libxml, + mbstring, + openssl, + pcntl, + posix, + session, + simplexml, + xmlreader, + xmlwriter, + zip, + zlib, + sqlite, + pdo_sqlite coverage: none ini-file: development ini-values: disable_functions= @@ -52,7 +77,16 @@ jobs: run: composer require --dev roave/security-advisories:dev-latest - name: Install nextcloud/ocp - run: composer require --dev nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} --ignore-platform-reqs --with-dependencies + run: | + composer require --dev \ + nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} \ + --ignore-platform-reqs \ + --with-dependencies - name: Run Psalm - run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github + run: | + composer run psalm -- \ + --threads=1 \ + --monochrome \ + --no-progress \ + --output-format=github From abaf219d7d2e3080a9a7f63d09555ef59c47df77 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:46:21 -0300 Subject: [PATCH 32/58] style(ci): wrap phpunit workflow arguments Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/phpunit-sqlite.yml | 29 ++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phpunit-sqlite.yml b/.github/workflows/phpunit-sqlite.yml index 4ec8092..2d7ca97 100644 --- a/.github/workflows/phpunit-sqlite.yml +++ b/.github/workflows/phpunit-sqlite.yml @@ -92,7 +92,29 @@ jobs: uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: ${{ matrix.php-versions }} - extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite + extensions: >- + bz2, + ctype, + curl, + dom, + fileinfo, + gd, + iconv, + intl, + json, + libxml, + mbstring, + openssl, + pcntl, + posix, + session, + simplexml, + xmlreader, + xmlwriter, + zip, + zlib, + sqlite, + pdo_sqlite coverage: none ini-file: development ini-values: disable_functions= @@ -136,4 +158,7 @@ jobs: steps: - name: Summary status - run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-sqlite.result != 'success' }}; then exit 1; fi + run: | + if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-sqlite.result != 'success' }}; then + exit 1 + fi From 29330c71e3754af9bc257e82068dc56aca0e84e8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:49:59 -0300 Subject: [PATCH 33/58] revert(ci): drop cosmetic lint workflow reformat Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/lint-php.yml | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/.github/workflows/lint-php.yml b/.github/workflows/lint-php.yml index 9ee2c21..2229f23 100644 --- a/.github/workflows/lint-php.yml +++ b/.github/workflows/lint-php.yml @@ -50,29 +50,7 @@ jobs: uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: ${{ matrix.php-versions }} - extensions: >- - bz2, - ctype, - curl, - dom, - fileinfo, - gd, - iconv, - intl, - json, - libxml, - mbstring, - openssl, - pcntl, - posix, - session, - simplexml, - xmlreader, - xmlwriter, - zip, - zlib, - sqlite, - pdo_sqlite + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite coverage: none ini-file: development env: @@ -97,7 +75,4 @@ jobs: steps: - name: Summary status - run: | - if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then - exit 1 - fi + run: if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then exit 1; fi From 6a50afa410b5276a9c456b753e267bdfb4b72584 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:50:01 -0300 Subject: [PATCH 34/58] revert(ci): drop cosmetic psalm workflow reformat Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/psalm.yml | 42 ++++--------------------------------- 1 file changed, 4 insertions(+), 38 deletions(-) diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 36b12e5..76a7cd6 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -30,38 +30,13 @@ jobs: uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2 - name: Check enforcement of minimum PHP version ${{ steps.versions.outputs.php-min }} in psalm.xml - run: | - grep \ - 'phpVersion="${{ steps.versions.outputs.php-min }}"' \ - psalm.xml + run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}"' psalm.xml - name: Set up php${{ steps.versions.outputs.php-available }} uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: ${{ steps.versions.outputs.php-available }} - extensions: >- - bz2, - ctype, - curl, - dom, - fileinfo, - gd, - iconv, - intl, - json, - libxml, - mbstring, - openssl, - pcntl, - posix, - session, - simplexml, - xmlreader, - xmlwriter, - zip, - zlib, - sqlite, - pdo_sqlite + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite coverage: none ini-file: development ini-values: disable_functions= @@ -77,16 +52,7 @@ jobs: run: composer require --dev roave/security-advisories:dev-latest - name: Install nextcloud/ocp - run: | - composer require --dev \ - nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} \ - --ignore-platform-reqs \ - --with-dependencies + run: composer require --dev nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} --ignore-platform-reqs --with-dependencies - name: Run Psalm - run: | - composer run psalm -- \ - --threads=1 \ - --monochrome \ - --no-progress \ - --output-format=github + run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github From b6014f5deb8c75ec49ded1bb2950ca5eae1d20ff Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:50:06 -0300 Subject: [PATCH 35/58] revert(ci): drop cosmetic phpunit workflow reformat Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/phpunit-sqlite.yml | 29 ++-------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/.github/workflows/phpunit-sqlite.yml b/.github/workflows/phpunit-sqlite.yml index 2d7ca97..4ec8092 100644 --- a/.github/workflows/phpunit-sqlite.yml +++ b/.github/workflows/phpunit-sqlite.yml @@ -92,29 +92,7 @@ jobs: uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: php-version: ${{ matrix.php-versions }} - extensions: >- - bz2, - ctype, - curl, - dom, - fileinfo, - gd, - iconv, - intl, - json, - libxml, - mbstring, - openssl, - pcntl, - posix, - session, - simplexml, - xmlreader, - xmlwriter, - zip, - zlib, - sqlite, - pdo_sqlite + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite coverage: none ini-file: development ini-values: disable_functions= @@ -158,7 +136,4 @@ jobs: steps: - name: Summary status - run: | - if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-sqlite.result != 'success' }}; then - exit 1 - fi + run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-sqlite.result != 'success' }}; then exit 1; fi From 041025f86fb88ea2126a2f3f75d767fd2ace7e82 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:00:13 -0300 Subject: [PATCH 36/58] refactor(app): inject request in bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/AppInfo/Application.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e8f3a28..bafae82 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -38,7 +38,14 @@ public function register(IRegistrationContext $context): void { #[\Override] public function boot(IBootContext $context): void { - $request = $context->getServerContainer()->get(IRequest::class); + 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()); From f31911791d57e50e2e9f0268e43be41bb7bcfb95 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:00:13 -0300 Subject: [PATCH 37/58] test(app): drop deprecated server container mock Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/AppInfo/ApplicationTest.php | 24 ++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/php/Unit/AppInfo/ApplicationTest.php b/tests/php/Unit/AppInfo/ApplicationTest.php index df38021..6f32355 100644 --- a/tests/php/Unit/AppInfo/ApplicationTest.php +++ b/tests/php/Unit/AppInfo/ApplicationTest.php @@ -12,7 +12,6 @@ use OCA\ProfileFields\AppInfo\Application; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\IRequest; -use OCP\IServerContainer; use PHPUnit\Framework\TestCase; class ApplicationTest extends TestCase { @@ -25,16 +24,25 @@ public function testBootIgnoresUnsupportedRequestContext(): void { ->method('getRequestUri') ->willThrowException(new \RuntimeException('unsupported uri context')); - $serverContainer = $this->createMock(IServerContainer::class); - $serverContainer->expects($this->once()) - ->method('get') - ->with(IRequest::class) - ->willReturn($request); + $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('getServerContainer') - ->willReturn($serverContainer); + ->method('injectFn') + ->willThrowException(new \RuntimeException('request unavailable')); $application = new Application(); From 650ef4cbb28fa5f4034ca1c857bfbd6ab4c07e40 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:00:13 -0300 Subject: [PATCH 38/58] test(api): resolve contract base url dynamically Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Api/ApiTestCase.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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')) { From f492f1960c04bf744bf96ecfd7b9ec6840370683 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:00:14 -0300 Subject: [PATCH 39/58] docs(api): declare value metadata in response types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/ResponseDefinitions.php | 2 ++ 1 file changed, 2 insertions(+) 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, From 2366d54f50fcb29583bbeee7f299eca5f029ca26 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:00:14 -0300 Subject: [PATCH 40/58] docs(openapi): regenerate administration schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-administration.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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" } } }, From d9b0f828ee49939bb2354181bc85bd3573517cfc Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:00:14 -0300 Subject: [PATCH 41/58] docs(openapi): regenerate full schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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" } } }, From 4df5cf991662b5820a7a2633829819beb8f46c4b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:00:14 -0300 Subject: [PATCH 42/58] docs(openapi): regenerate default schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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" } } }, From da52fa36ba7659e5c5261e83d5cb3052c9acc49e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:00:15 -0300 Subject: [PATCH 43/58] feat(frontend): expose value metadata fields Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types.ts b/src/types.ts index 46e6d80..dbd9279 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,8 @@ export interface FieldValueRecord { user_uid: string value: FieldValuePayload current_visibility: FieldVisibility + updated_by_uid: string + updated_at: string } export interface EditableField { From e6cea0b2930d11399190d5c2d16fb97c2ea7310d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:00:15 -0300 Subject: [PATCH 44/58] test(frontend): update admin value fixture Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/tests/utils/adminFieldValues.spec.ts | 2 ++ 1 file changed, 2 insertions(+) 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', () => { From c9ce20f0454481a364c92f8b0e971aa531fcf0da Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:00:15 -0300 Subject: [PATCH 45/58] style(service): wrap field value upsert signature Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FieldValueService.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index f4becff..bae4c92 100644 --- a/lib/Service/FieldValueService.php +++ b/lib/Service/FieldValueService.php @@ -28,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, ?DateTimeInterface $updatedAt = 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)) { From 51a6aa8fe12bb76f90c0862005ac0eb136c20d0a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:12:46 -0300 Subject: [PATCH 46/58] build(frontend): add openapi type generation script Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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" } From 70e979c8075974e224a5bc2809656f88ae4d23cf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:12:46 -0300 Subject: [PATCH 47/58] build(frontend): install openapi type generator Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- package-lock.json | 290 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) 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", From 20b65e3da25a7245cece0c1f2ca136f8469638f4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:12:46 -0300 Subject: [PATCH 48/58] ci(openapi): regenerate typescript api types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/openapi.yml | 4 ++++ 1 file changed, 4 insertions(+) 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)" From 4bb3ee969ead82888be0691b5cf85c047e4c4afa Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:12:46 -0300 Subject: [PATCH 49/58] build(types): generate self-service openapi types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi.ts | 378 +++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 src/types/openapi/openapi.ts 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; + }; + }; + }; + }; + }; + }; + }; +} From cf1b209dde4a4026a5eb16a9903076d899a8ba41 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:12:47 -0300 Subject: [PATCH 50/58] build(types): generate administration openapi types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-administration.ts | 677 ++++++++++++++++++++ 1 file changed, 677 insertions(+) create mode 100644 src/types/openapi/openapi-administration.ts 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; + }; + }; + }; + }; + }; + }; + }; +} From af7e1d6d1156f41e0800afcf72ca0ca95a33fbcc Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:12:48 -0300 Subject: [PATCH 51/58] build(types): generate full openapi types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-full.ts | 1017 +++++++++++++++++++++++++++++ 1 file changed, 1017 insertions(+) create mode 100644 src/types/openapi/openapi-full.ts 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; + }; + }; + }; + }; + }; + }; + }; +} From 4bcdd2110c76c99500cfe5c07919eeae753cb22e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:12:48 -0300 Subject: [PATCH 52/58] refactor(frontend): alias generated openapi types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types.ts | 75 +++++++++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/src/types.ts b/src/types.ts index dbd9279..4442ffb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,50 +1,59 @@ // 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 +import type { components as ApiComponents, operations as ApiOperations } from './types/openapi/openapi-full' + +type ApiJsonBody = TRequestBody extends { + content: { + 'application/json': infer Body + } } + ? Body + : never -export interface FieldValueRecord { - id: number - field_definition_id: number - user_uid: string - value: FieldValuePayload - current_visibility: FieldVisibility - updated_by_uid: string - updated_at: string +type ApiOperationRequestBody = TOperation extends { + requestBody?: infer RequestBody } + ? NonNullable + : never + +type ApiRequestJsonBody = ApiJsonBody> -export interface EditableField { +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 - can_edit: boolean } +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 interface AdminEditableField { +export type AdminEditableField = { definition: FieldDefinition value: FieldValueRecord | null } -export interface ApiError { +export type ApiError = { message: string } From 196814a0fe73434cc2e77043b9fea254ce6e4d27 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:12:49 -0300 Subject: [PATCH 53/58] refactor(frontend): type api client from openapi Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/api.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) 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, }) From f9688b8028354942d7c67d92763de6555ea423d3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:17:03 -0300 Subject: [PATCH 54/58] refactor(frontend): move shared types entrypoint to index Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/{types.ts => types/index.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/{types.ts => types/index.ts} (98%) diff --git a/src/types.ts b/src/types/index.ts similarity index 98% rename from src/types.ts rename to src/types/index.ts index 4442ffb..7b2a4ba 100644 --- a/src/types.ts +++ b/src/types/index.ts @@ -1,7 +1,7 @@ // 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 './types/openapi/openapi-full' +import type { components as ApiComponents, operations as ApiOperations } from './openapi/openapi-full' type ApiJsonBody = TRequestBody extends { content: { From 8b5096f08f148bf0aac980a4bb7ff329e05f15ef Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:20:59 -0300 Subject: [PATCH 55/58] chore(reuse): cover generated openapi types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- REUSE.toml | 1 + 1 file changed, 1 insertion(+) 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", From 01cd542f4a3c49be1e34aab88b8afbbcde56c310 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:21:00 -0300 Subject: [PATCH 56/58] fix(listener): remove php 8.2 readonly class Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Listener/BeforeTemplateRenderedListener.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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') { From 1f0995484f553c4c0f1932083a04c4b2aebefbaf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:21:01 -0300 Subject: [PATCH 57/58] fix(psalm): align php version with support matrix Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- psalm.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 39092362e2a8b7e8b8820eaebedf0fbbbd3033bd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:21:01 -0300 Subject: [PATCH 58/58] test(listener): add unrelated event regression Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../BeforeTemplateRenderedListenerTest.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/php/Unit/Listener/BeforeTemplateRenderedListenerTest.php 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); + } +}