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