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
17 changes: 17 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,21 @@ Vrij en open source onder de EUPL-1.2-licentie.
<step>OCA\Planninq\Repair\MigrateAppConfigKeys</step>
<step>OCA\Planninq\Repair\MigrateUserPreferences</step>
<step>OCA\Planninq\Repair\MigrateRegisterSlug</step>
<!--
ONE LEVEL DOWN FROM MigrateRegisterSlug, AND THE SAME TRAP.
`timeEntry` is a slug three apps declared — humaniq's HR
booking, pipelinq's billing record and this app's project
booking — and a slug is global per organisation, so
SchemaMapper::find() answered whichever row it reached first.
humaniq owns the hours; this record is the PLANNING side of one,
and keeps a `timeEntry` reference to the humaniq booking.

Renaming the row BEFORE InitializeSettings is what makes it a
rename: the import matches by (application, slug) and its
not-found branch CREATES a second schema, orphaning every
existing entry silently while the app reads an empty list.
-->
<step>OCA\Planninq\Repair\RenameTimeEntrySchemaSlug</step>
<step>OCA\Planninq\Repair\InitializeSettings</step>
<step>OCA\Planninq\Repair\ReconcileDueReminderOverrides</step>
</post-migration>
Expand All @@ -178,6 +193,8 @@ Vrij en open source onder de EUPL-1.2-licentie.
happen here or the import below forks a second, empty register.
-->
<step>OCA\Planninq\Repair\MigrateRegisterSlug</step>
<!-- See the note in <post-migration>. -->
<step>OCA\Planninq\Repair\RenameTimeEntrySchemaSlug</step>
<step>OCA\Planninq\Repair\InitializeSettings</step>
</install>
</repair-steps>
Expand Down
4 changes: 2 additions & 2 deletions lib/Portal/PortalContributionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ private function externalEmployeeContribution(): array {
[
'id' => 'contractorTimeEntries',
'register' => self::REGISTER,
'schema' => 'timeEntry',
'schema' => 'plannedTimeEntry',
'scopeField' => 'contractorRef',
'scopeClaim' => 'contractorRef',
'label' => 'My time entries',
Expand Down Expand Up @@ -219,7 +219,7 @@ private function externalEmployeeContribution(): array {
'type' => 'create',
'label' => 'Log time',
'register' => self::REGISTER,
'schema' => 'timeEntry',
'schema' => 'plannedTimeEntry',
'fields' => [
'task',
'date',
Expand Down
206 changes: 206 additions & 0 deletions lib/Repair/RenameTimeEntrySchemaSlug.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<?php

/**
* Planninq RenameTimeEntrySchemaSlug repair step.
*
* Moves the time-entry schema's slug from `timeEntry` to `plannedTimeEntry`
* 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 project booking. `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 PLANNING side of one —
* the task it was booked against, the contractor and the hourly rate — and
* keeps a `timeEntry` reference to the humaniq booking.
*
* 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.
*
* 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\Planninq\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://planninq.nl
*/

declare(strict_types=1);

namespace OCA\Planninq\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 = 'plannedTimeEntry';

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

/**
* 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 planninq 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
6 changes: 3 additions & 3 deletions lib/Settings/planninq_mock_register.json
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@
{
"@self": {
"register": "planninq",
"schema": "timeEntry",
"schema": "plannedTimeEntry",
"slug": "timeentry-timeentry-1-1"
},
"task": "00000000-0000-4000-8000-000000000000",
Expand All @@ -337,7 +337,7 @@
{
"@self": {
"register": "planninq",
"schema": "timeEntry",
"schema": "plannedTimeEntry",
"slug": "timeentry-timeentry-2-2"
},
"task": "00000000-0000-4000-8000-000000000001",
Expand All @@ -350,7 +350,7 @@
{
"@self": {
"register": "planninq",
"schema": "timeEntry",
"schema": "plannedTimeEntry",
"slug": "timeentry-timeentry-3-3"
},
"task": "00000000-0000-4000-8000-000000000002",
Expand Down
21 changes: 15 additions & 6 deletions lib/Settings/planninq_register.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"project",
"projectPhase",
"column",
"timeEntry",
"plannedTimeEntry",
"label",
"dependency"
],
Expand Down Expand Up @@ -901,8 +901,8 @@
}
}
},
"timeEntry": {
"slug": "timeEntry",
"plannedTimeEntry": {
"slug": "plannedTimeEntry",
"icon": "TimerOutline",
"version": "0.2.0",
"title": "Time Entry",
Expand Down Expand Up @@ -990,6 +990,15 @@
]
},
"properties": {
"timeEntry": {
"title": "Urenboeking",
"title:en": "Time entry",
"type": "string",
"description": "De humaniq `TimeEntry` waarop deze uren geboekt zijn. Een platte uuid en geen `$ref`: humaniq's register is een ander register, en ADR-062 regel 7 geeft een doel buiten dit register geen `$ref`.",
"description:en": "The humaniq `TimeEntry` these hours are booked on. A plain uuid and not a `$ref`: humaniq's register is a different register, and ADR-062 rule 7 gives a cross-register target no `$ref`.",
"format": "uuid",
"facetable": true
},
"task": {
"title": "Task",
"type": "string",
Expand Down Expand Up @@ -1533,7 +1542,7 @@
{
"@self": {
"register": "planninq",
"schema": "timeEntry",
"schema": "plannedTimeEntry",
"slug": "te-fix-login-2026-04-01"
},
"task": "00000000-0000-4000-d000-000000000001",
Expand All @@ -1545,7 +1554,7 @@
{
"@self": {
"register": "planninq",
"schema": "timeEntry",
"schema": "plannedTimeEntry",
"slug": "te-fix-login-2026-04-02"
},
"task": "00000000-0000-4000-d000-000000000001",
Expand All @@ -1557,7 +1566,7 @@
{
"@self": {
"register": "planninq",
"schema": "timeEntry",
"schema": "plannedTimeEntry",
"slug": "te-k8s-2026-04-01"
},
"task": "00000000-0000-4000-d000-000000000004",
Expand Down
10 changes: 5 additions & 5 deletions src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,10 @@
"title": "Time spent",
"config": {
"widgets": [
{ "id": "time-entries", "title": "Entries", "type": "stat", "content": { "label": "Entries", "icon": "ClockOutline", "route": { "name": "Timesheet" }, "format": { "style": "decimal", "decimals": 0 }, "source": { "register": "planninq", "schema": "timeEntry", "metric": "count" } } },
{ "id": "time-minutes", "title": "Minutes logged", "type": "stat", "content": { "label": "Minutes logged", "icon": "TimerOutline", "route": { "name": "Timesheet" }, "format": { "style": "decimal", "decimals": 0 }, "source": { "register": "planninq", "schema": "timeEntry", "metric": "sum", "field": "duration" } } },
{ "id": "time-by-user", "title": "Per person", "type": "chart", "content": { "chartKind": "bar", "legendPosition": "bottom", "emptyLabel": "Nothing logged yet", "dataSource": { "register": "planninq", "schema": "timeEntry", "aggregate": { "groupBy": "user", "metric": "sum", "sumField": "duration", "topN": 10, "otherBucket": true } } } },
{ "id": "time-recent", "title": "Most recent", "type": "object-table", "content": { "register": "planninq", "schema": "timeEntry", "filter": {}, "sort": { "field": "date", "dir": "desc" }, "limit": 8, "hideHeader": true, "emptyText": "Nothing logged yet", "viewAllRoute": { "name": "Timesheet" }, "viewAllLabel": "Open the timesheet", "columns": [ { "key": "date", "label": "Date" }, { "key": "user", "label": "Who" }, { "key": "duration", "label": "Minutes" }, { "key": "description", "label": "What" } ] } }
{ "id": "time-entries", "title": "Entries", "type": "stat", "content": { "label": "Entries", "icon": "ClockOutline", "route": { "name": "Timesheet" }, "format": { "style": "decimal", "decimals": 0 }, "source": { "register": "planninq", "schema": "plannedTimeEntry", "metric": "count" } } },
{ "id": "time-minutes", "title": "Minutes logged", "type": "stat", "content": { "label": "Minutes logged", "icon": "TimerOutline", "route": { "name": "Timesheet" }, "format": { "style": "decimal", "decimals": 0 }, "source": { "register": "planninq", "schema": "plannedTimeEntry", "metric": "sum", "field": "duration" } } },
{ "id": "time-by-user", "title": "Per person", "type": "chart", "content": { "chartKind": "bar", "legendPosition": "bottom", "emptyLabel": "Nothing logged yet", "dataSource": { "register": "planninq", "schema": "plannedTimeEntry", "aggregate": { "groupBy": "user", "metric": "sum", "sumField": "duration", "topN": 10, "otherBucket": true } } } },
{ "id": "time-recent", "title": "Most recent", "type": "object-table", "content": { "register": "planninq", "schema": "plannedTimeEntry", "filter": {}, "sort": { "field": "date", "dir": "desc" }, "limit": 8, "hideHeader": true, "emptyText": "Nothing logged yet", "viewAllRoute": { "name": "Timesheet" }, "viewAllLabel": "Open the timesheet", "columns": [ { "key": "date", "label": "Date" }, { "key": "user", "label": "Who" }, { "key": "duration", "label": "Minutes" }, { "key": "description", "label": "What" } ] } }
],
"layout": [
{ "id": "1", "widgetId": "time-entries", "gridX": 0, "gridY": 0, "gridWidth": 6, "gridHeight": 2, "showTitle": false },
Expand Down Expand Up @@ -296,7 +296,7 @@
},
{
"registerSlug": "planninq",
"schemaSlug": "timeEntry",
"schemaSlug": "plannedTimeEntry",
"urlTemplate": "/apps/planninq/projects/{project}?task={task}",
"displayName": "Time entry"
}
Expand Down
2 changes: 1 addition & 1 deletion src/store/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const REGISTER = 'planninq'
const PROJECT_SCHEMA = 'project'
const COLUMN_SCHEMA = 'column'
const TASK_SCHEMA = 'task'
const TIME_ENTRY_SCHEMA = 'timeEntry'
const TIME_ENTRY_SCHEMA = 'plannedTimeEntry'

/**
* Largest page OpenRegister will return. Asking for more is silently capped.
Expand Down
2 changes: 1 addition & 1 deletion src/store/timeEntries.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { useObjectStore } from './objectStore.js'
// the register ROW: OR resolves a register by slug and by nothing else, so the
// literal and the row move in the same release or neither resolves.
const REGISTER = 'planninq'
const TIME_ENTRY_SCHEMA = 'timeEntry'
const TIME_ENTRY_SCHEMA = 'plannedTimeEntry'

export const useTimeEntriesStore = defineStore('timeEntries', {
state: () => ({
Expand Down
Loading
Loading