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);
+ }
+}