Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,15 @@ Vrij en open source onder de EUPL-1.2-licentie.
updates the schema in place and keeps its id, and its shard table.
-->
<step>OCA\Pipelinq\Repair\RenameLoyaltyAccountSchemaSlug</step>
<!--
Same reason, one slug over. `timeEntry` is global per organisation
and three apps declared one — humaniq's HR booking, planninq's
project booking and this app's billing/WIP record — so
SchemaMapper::find() answered whichever it reached first. humaniq
owns the hours; this record is the BILLING side of one, and keeps
a `timeEntry` reference to the booking it bills.
-->
<step>OCA\Pipelinq\Repair\RenameTimeEntrySchemaSlug</step>
<step>OCA\Pipelinq\Repair\InitializeSettings</step>
<!--
After InitializeSettings imports the register: the schema edit
Expand Down
209 changes: 209 additions & 0 deletions lib/Repair/RenameTimeEntrySchemaSlug.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<?php

/**
* Pipelinq RenameTimeEntrySchemaSlug repair step.
*
* Moves the time-entry schema's slug from `timeEntry` to `billingTimeEntry`
* IN PLACE, before InitializeSettings imports the register.
*
* WHY IT MOVES AT ALL. A schema slug is global per organisation, and three apps
* declared a `timeEntry`: humaniq's HR booking, planninq's project booking, and
* this app's billing/WIP record. `SchemaMapper::find()` matches `LOWER(slug)`,
* so whichever row it reached first answered for all three. humaniq is the
* agreed owner of the hours; this app's record is the BILLING side of one —
* client, lead, billing category, approval, WIP sync, invoice batch — and keeps
* a `timeEntry` reference to the humaniq booking it bills.
*
* OpenRegister's import matches an existing schema by (application, slug):
* ImportHandler calls `findByApplicationAndSlug()` and creates a NEW schema when that
* misses. A slug rename in the shipped register fragment therefore does not rename
* anything — it CREATES a second schema and silently orphans the first, together with
* every object already written against it. The old schema keeps its shard table and its
* rows; the app resolves the new id and reads an empty collection. Nothing errors.
*
* That is why this step exists and why it must run FIRST. Renaming the row before the
* import means the import finds the schema it was always going to find, keeps its id,
* and updates it in place — so the shard table, and the objects in it, stay attached.
*
* The app-config KEY is a separate concern and deliberately does not move; see
* {@see \OCA\Pipelinq\Service\SettingsLoadService::SCHEMA_CONFIG_KEYS}.
*
* Idempotent: a no-op once the slug is already namespaced, and a no-op on an install that
* never had the schema. Refuses when both slugs exist, because picking one would decide
* which set of objects to abandon — a choice this step must not make silently.
*
* @category Repair
* @package OCA\Pipelinq\Repair
*
* @author Conduction <info@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
* SPDX-License-Identifier: EUPL-1.2
*
* @version GIT: <git_id>
*
* @link https://pipelinq.nl
*/

declare(strict_types=1);

namespace OCA\Pipelinq\Repair;

use OCP\DB\Exception;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use Psr\Log\LoggerInterface;

/**
* Renames the time-entry schema slug in place, ahead of the register import.
*
* @spec exclude No canonical spec covers the fleet-wide slug collision
* migration. Pointing this at an existing spec would report conformance to a
* requirement that says nothing about it.
*/
class RenameTimeEntrySchemaSlug implements IRepairStep {
/**
* The slug this step migrates away from.
*
* @var string
*/
private const OLD_SLUG = 'timeEntry';

/**
* The namespaced slug the register fragment now declares.
*
* @var string
*/
private const NEW_SLUG = 'billingTimeEntry';

/**
* The owning application, as stored on the schema row.
*
* @var string
*/
private const APPLICATION = 'pipelinq';

/**
* Constructor.
*
* @param IDBConnection $db Database connection.
* @param LoggerInterface $logger Logger.
*/
public function __construct(
private readonly IDBConnection $db,
private readonly LoggerInterface $logger,
) {
}//end __construct()

/**
* Human-readable step name.
*
* @return string
*
* @spec exclude No canonical spec covers the fleet-wide slug collision
* migration. Pointing this at an existing spec would report conformance to a
* requirement that says nothing about it.
*/
public function getName(): string {
return 'Namespace the pipelinq time-entry schema slug';
}//end getName()

/**
* Rename the slug, unless doing so would be ambiguous.
*
* @param IOutput $output Repair output.
*
* @return void
*
* @spec exclude No canonical spec covers the fleet-wide slug collision
* migration. Pointing this at an existing spec would report conformance to a
* requirement that says nothing about it.
*/
public function run(IOutput $output): void {
$old = $this->schemaIds(slug: self::OLD_SLUG);
$new = $this->schemaIds(slug: self::NEW_SLUG);

if ($old === null || $new === null) {
$output->info('RenameTimeEntrySchemaSlug: schema table unreadable; leaving the slug alone.');
return;
}

if ($old === []) {
$output->info('RenameTimeEntrySchemaSlug: no time-entry schema on this install; nothing to do.');
return;
}

if ($new !== []) {
// Both slugs present: each may own objects, and renaming would collide
// with the English row. Abandoning either set of objects is not a call
// a repair step gets to make without being asked.
$this->logger->warning(
'RenameTimeEntrySchemaSlug: both slugs exist; refusing to merge them.',
['old' => $old, 'new' => $new]
);
$output->warning(
'RenameTimeEntrySchemaSlug: both `' . self::OLD_SLUG . '` and `' . self::NEW_SLUG
. '` exist; refusing to merge them. Resolve by hand.'
);
return;
}

if (count($old) > 1) {
$this->logger->warning(
'RenameTimeEntrySchemaSlug: duplicate time-entry slugs; refusing to guess.',
['ids' => $old]
);
$output->warning('RenameTimeEntrySchemaSlug: duplicate `' . self::OLD_SLUG . '` schemas; refusing to guess.');
return;
}

try {
$this->db->executeStatement(
'UPDATE `*PREFIX*openregister_schemas` SET slug = ? WHERE id = ?',
[self::NEW_SLUG, $old[0]]
);
} catch (Exception $e) {
// A failure here is safe: the import then creates a new schema rather
// than updating this one, which is the pre-existing behaviour. Loud,
// because the objects on the old schema stop being reachable.
$this->logger->error(
'RenameTimeEntrySchemaSlug: slug rename failed; the import will create a second schema.',
['id' => $old[0], 'exception' => $e->getMessage()]
);
$output->warning('RenameTimeEntrySchemaSlug: slug rename failed; see the log.');
return;
}

$output->info(
'RenameTimeEntrySchemaSlug: schema ' . $old[0] . ' renamed `'
. self::OLD_SLUG . '` -> `' . self::NEW_SLUG . '`; its objects stay attached.'
);
}//end run()

/**
* Ids of this application's schemas carrying the given slug.
*
* @param string $slug The schema slug to look for.
*
* @return array<int, mixed>|null The ids, or null when the table cannot be read.
*/
private function schemaIds(string $slug): ?array {
try {
$rows = $this->db->executeQuery(
'SELECT id FROM `*PREFIX*openregister_schemas` WHERE slug = ? AND application = ?',
[$slug, self::APPLICATION]
)->fetchAll(\PDO::FETCH_COLUMN);

return array_values((array)$rows);
} catch (Exception $e) {
$this->logger->warning(
'RenameTimeEntrySchemaSlug: could not read the schema table; skipping.',
['exception' => $e->getMessage()]
);
return null;
}
}//end schemaIds()
}//end class
7 changes: 6 additions & 1 deletion lib/Service/SchemaMapService.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ class SchemaMapService {
'pipeline_schema' => 'pipeline',
'skill_schema' => 'skill',
'agentProfile_schema' => 'agentProfile',
'timeEntry_schema' => 'timeEntry',
// The entity type follows the renamed slug; the app-config KEY deliberately
// does not, matching `klantLoyaltyAccount_schema` below. `timeEntry` was
// global per organisation and three apps declared one — humaniq's HR
// booking, planninq's project booking and this app's billing/WIP record —
// so `SchemaMapper::find()` answered whichever it reached first.
'timeEntry_schema' => 'billingTimeEntry',
'task_schema' => 'task',
'posTransaction_schema' => 'posTransaction',
// POS staff PIN + role permissions (pos-staff-pin-permissions).
Expand Down
2 changes: 1 addition & 1 deletion lib/Service/SettingsLoadService.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class SettingsLoadService {
'skill',
'agentProfile',
// Time & WIP tracking (time-wip).
'timeEntry',
'billingTimeEntry',
'posTransaction',
'posTransactionLine',
// POS split-tender schemas (pos-split-tender). Without these two slugs
Expand Down
2 changes: 1 addition & 1 deletion lib/Service/WipSyncNotifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public function notifyFailure(string $title, string $uuid): void {
parameters: [
'title' => $title,
],
objectType: 'timeEntry',
objectType: 'billingTimeEntry',
objectId: $uuid
);
}
Expand Down
6 changes: 3 additions & 3 deletions lib/Settings/pipelinq_mock_register.json
Original file line number Diff line number Diff line change
Expand Up @@ -6408,7 +6408,7 @@
{
"@self": {
"register": "pipelinq",
"schema": "timeEntry",
"schema": "billingTimeEntry",
"slug": "timeentry-voorbeeld-title-1-1"
},
"title": "Voorbeeld Title 1",
Expand All @@ -6432,7 +6432,7 @@
{
"@self": {
"register": "pipelinq",
"schema": "timeEntry",
"schema": "billingTimeEntry",
"slug": "timeentry-voorbeeld-title-2-2"
},
"title": "Voorbeeld Title 2",
Expand All @@ -6456,7 +6456,7 @@
{
"@self": {
"register": "pipelinq",
"schema": "timeEntry",
"schema": "billingTimeEntry",
"slug": "timeentry-voorbeeld-title-3-3"
},
"title": "Voorbeeld Title 3",
Expand Down
16 changes: 8 additions & 8 deletions lib/Settings/register.d/90-time-wip.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
"registers": {
"pipelinq": {
"schemas": [
"timeEntry"
"billingTimeEntry"
]
}
},
"schemas": {
"timeEntry": {
"slug": "timeEntry",
"billingTimeEntry": {
"slug": "billingTimeEntry",
"title": "Time Entry",
"icon": "ClockOutline",
"version": "1.2.0",
Expand Down Expand Up @@ -123,7 +123,7 @@
{
"@self": {
"register": "pipelinq",
"schema": "timeEntry",
"schema": "billingTimeEntry",
"slug": "time-entry-wip-synced-1"
},
"title": "Adviesgesprek intake De Vries BV",
Expand All @@ -140,7 +140,7 @@
{
"@self": {
"register": "pipelinq",
"schema": "timeEntry",
"schema": "billingTimeEntry",
"slug": "time-entry-wip-synced-2"
},
"title": "Technische implementatie OpenRegister module",
Expand All @@ -157,7 +157,7 @@
{
"@self": {
"register": "pipelinq",
"schema": "timeEntry",
"schema": "billingTimeEntry",
"slug": "time-entry-wip-synced-3"
},
"title": "Onderzoek algoritmisch routeren vergunningaanvragen",
Expand All @@ -174,7 +174,7 @@
{
"@self": {
"register": "pipelinq",
"schema": "timeEntry",
"schema": "billingTimeEntry",
"slug": "time-entry-wip-pending-1"
},
"title": "Projectopstartgesprek subsidieportaal GroenNet",
Expand All @@ -191,7 +191,7 @@
{
"@self": {
"register": "pipelinq",
"schema": "timeEntry",
"schema": "billingTimeEntry",
"slug": "time-entry-wip-failed-1"
},
"title": "ZZP-advies supply chain optimalisatie",
Expand Down
Loading
Loading