Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
b74fba5
feat(command): add data import cli command
vitormattos Mar 16, 2026
e53127d
feat(service): add import payload validator
vitormattos Mar 16, 2026
7a50ab5
feat(service): add transactional data import service
vitormattos Mar 16, 2026
d89fc50
feat(command): version export payload contract
vitormattos Mar 16, 2026
e669ab1
feat(appinfo): register data import command
vitormattos Mar 16, 2026
e025eb7
test(command): cover data import cli command
vitormattos Mar 16, 2026
dc2f0f2
test(service): cover import payload validator
vitormattos Mar 16, 2026
edbd403
test(service): cover transactional data import service
vitormattos Mar 16, 2026
ee51128
test(command): cover versioned export payload
vitormattos Mar 16, 2026
34d11cb
fix(appinfo): guard boot request access
vitormattos Mar 16, 2026
1c098d6
fix(command): handle export payload build failures
vitormattos Mar 16, 2026
4aa9f75
chore(command): normalize import command formatting
vitormattos Mar 16, 2026
613fe93
fix(listener): restore php81 compatibility
vitormattos Mar 16, 2026
e7aed07
fix(service): honor imported metadata in data import
vitormattos Mar 16, 2026
e0a92cc
fix(service): preserve imported definition timestamps
vitormattos Mar 16, 2026
cdc7741
fix(service): preserve imported field value timestamps
vitormattos Mar 16, 2026
80bf160
fix(service): preserve import definition metadata
vitormattos Mar 16, 2026
ed75f17
chore(psalm): align target php with ci
vitormattos Mar 16, 2026
113c40b
test(appinfo): cover boot in cli context
vitormattos Mar 16, 2026
5a423be
test(command): cover export payload failures
vitormattos Mar 16, 2026
ed9fe87
chore(test): normalize import command test formatting
vitormattos Mar 16, 2026
3f0864b
test(service): cover data import metadata
vitormattos Mar 16, 2026
3bf5b99
test(service): cover imported definition timestamps
vitormattos Mar 16, 2026
81e1956
test(service): cover imported field value timestamps
vitormattos Mar 16, 2026
cf0f886
test(service): cover import definition metadata
vitormattos Mar 16, 2026
ab4c6b5
refactor(service): accept datetime interface in field value service
vitormattos Mar 16, 2026
73eacb0
test(service): cover datetime interface support
vitormattos Mar 16, 2026
fa8bf8f
chore(psalm): restore php82 minimum
vitormattos Mar 16, 2026
4753f8f
refactor(listener): restore readonly listener
vitormattos Mar 16, 2026
0b12067
style(ci): wrap lint php workflow arguments
vitormattos Mar 16, 2026
bafb75f
style(ci): wrap psalm workflow arguments
vitormattos Mar 16, 2026
abaf219
style(ci): wrap phpunit workflow arguments
vitormattos Mar 16, 2026
29330c7
revert(ci): drop cosmetic lint workflow reformat
vitormattos Mar 16, 2026
6a50afa
revert(ci): drop cosmetic psalm workflow reformat
vitormattos Mar 16, 2026
b6014f5
revert(ci): drop cosmetic phpunit workflow reformat
vitormattos Mar 16, 2026
041025f
refactor(app): inject request in bootstrap
vitormattos Mar 16, 2026
f319117
test(app): drop deprecated server container mock
vitormattos Mar 16, 2026
650ef4c
test(api): resolve contract base url dynamically
vitormattos Mar 16, 2026
f492f19
docs(api): declare value metadata in response types
vitormattos Mar 16, 2026
2366d54
docs(openapi): regenerate administration schema
vitormattos Mar 16, 2026
d9b0f82
docs(openapi): regenerate full schema
vitormattos Mar 16, 2026
4df5cf9
docs(openapi): regenerate default schema
vitormattos Mar 16, 2026
da52fa3
feat(frontend): expose value metadata fields
vitormattos Mar 16, 2026
e6cea0b
test(frontend): update admin value fixture
vitormattos Mar 16, 2026
c9ce20f
style(service): wrap field value upsert signature
vitormattos Mar 16, 2026
51a6aa8
build(frontend): add openapi type generation script
vitormattos Mar 16, 2026
70e979c
build(frontend): install openapi type generator
vitormattos Mar 16, 2026
20b65e3
ci(openapi): regenerate typescript api types
vitormattos Mar 16, 2026
4bb3ee9
build(types): generate self-service openapi types
vitormattos Mar 16, 2026
cf1b209
build(types): generate administration openapi types
vitormattos Mar 16, 2026
af7e1d6
build(types): generate full openapi types
vitormattos Mar 16, 2026
4bcdd21
refactor(frontend): alias generated openapi types
vitormattos Mar 16, 2026
196814a
refactor(frontend): type api client from openapi
vitormattos Mar 16, 2026
f9688b8
refactor(frontend): move shared types entrypoint to index
vitormattos Mar 16, 2026
8b5096f
chore(reuse): cover generated openapi types
vitormattos Mar 16, 2026
01cd542
fix(listener): remove php 8.2 readonly class
vitormattos Mar 16, 2026
1f09954
fix(psalm): align php version with support matrix
vitormattos Mar 16, 2026
3909236
test(listener): add unrelated event regression
vitormattos Mar 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
1 change: 1 addition & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform
</repair-steps>
<commands>
<command>OCA\ProfileFields\Command\Data\Export</command>
<command>OCA\ProfileFields\Command\Data\Import</command>
<command>OCA\ProfileFields\Command\Data\Clear</command>
<command>OCA\ProfileFields\Command\Developer\Reset</command>
</commands>
Expand Down
27 changes: 22 additions & 5 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 33 additions & 12 deletions lib/Command/Data/Export.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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('<error>Failed to build export payload.</error>');
$output->writeln(sprintf('<error>%s</error>', $exception->getMessage()));
return self::FAILURE;
}

try {
$json = json_encode(
Expand Down Expand Up @@ -88,16 +103,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int
/**
* @return array<string, mixed>
*/
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(),
Expand Down
98 changes: 98 additions & 0 deletions lib/Command/Data/Import.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

declare(strict_types=1);

namespace OCA\ProfileFields\Command\Data;

use JsonException;
use OCA\ProfileFields\Service\DataImportService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class Import extends Command {
public function __construct(
private DataImportService $dataImportService,
) {
parent::__construct();
}

#[\Override]
protected function configure(): void {
$this
->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('<error>Please provide --input with a JSON file path.</error>');
return self::FAILURE;
}

$rawPayload = @file_get_contents($sourcePath);
if ($rawPayload === false) {
$output->writeln(sprintf('<error>Could not read import payload from %s.</error>', $sourcePath));
return self::FAILURE;
}

try {
$decodedPayload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
$output->writeln('<error>Failed to decode import payload JSON.</error>');
$output->writeln(sprintf('<error>%s</error>', $exception->getMessage()));
return self::FAILURE;
}

if (!is_array($decodedPayload)) {
$output->writeln('<error>Import payload must decode to a JSON object.</error>');
return self::FAILURE;
}

try {
$summary = $this->dataImportService->import($decodedPayload, (bool)$input->getOption('dry-run'));
} catch (\Throwable $throwable) {
$output->writeln('<error>Import validation failed.</error>');
$output->writeln(sprintf('<error>%s</error>', $throwable->getMessage()));
return self::FAILURE;
}

$output->writeln((bool)$input->getOption('dry-run')
? '<info>Profile Fields data import dry-run completed.</info>'
: '<info>Profile Fields data imported.</info>');
$output->writeln(sprintf(
'<info>Definitions: %d created, %d updated, %d skipped.</info>',
$summary['created_definitions'],
$summary['updated_definitions'],
$summary['skipped_definitions'],
));
$output->writeln(sprintf(
'<info>Values: %d created, %d updated, %d skipped.</info>',
$summary['created_values'],
$summary['updated_values'],
$summary['skipped_values'],
));

return self::SUCCESS;
}
}
2 changes: 1 addition & 1 deletion lib/Listener/BeforeTemplateRenderedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
/**
* @template-implements IEventListener<Event>
*/
readonly class BeforeTemplateRenderedListener implements IEventListener {
class BeforeTemplateRenderedListener implements IEventListener {
#[\Override]
public function handle(Event $event): void {
if ($event::class !== '\\OCA\\Settings\\Events\\BeforeTemplateRenderedEvent') {
Expand Down
2 changes: 2 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading