From d4d35895de63f4ce8cfd9eb4548385763d8937cf Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 5 Sep 2026 21:40:31 +0200 Subject: [PATCH] feat(schemas): namespace the CRM task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `task` was claimed by three apps: planninq, dossiq and this one. They share `description`, `priority` and `status` — what any task-shaped record carries, and nothing that identifies the record. planninq's project task is the largest and keeps the bare slug. dossiq's becomes `caseTask`; this is the CRM task, raised from a lead, a ticket or the KCC werkplek. THE DECOYS HERE ARE A VOCABULARY, NOT STRAY WORDS, which makes them easier to misjudge than `contract`'s GDPR enum: - ACTIVITY TYPES. ActivityTimelineService maps a source type to an activity type (`'emailLink' => 'email'`, `'task' => 'task'`). The siblings give it away: `interaction` is not a schema either, it queries tickets by ticketType. The whole source-type vocabulary stays. - NOTIFICATION SUBJECTS. NotificationService branches on `$entityType === 'task'` to pick `task_assigned` and passes `objectType: 'task'` to Nextcloud's notification API. Routing, not a schema lookup. Both left alone. The schema references moved: four register lists, the descriptor, TASK_SCHEMA_SLUG, SchemaMapService's entity type, the exportable schema list and the werkplek dialog's save. Config key stays `task_schema`. Full suite: 2,822 tests, 11,095 assertions, exit 0. phpcs, phpstan, psalm exit 0. The repair map now carries ten renames and its test asserts all ten. --- lib/Repair/RenameCollidingSchemaSlugs.php | 4 +++ lib/Service/CallbackService.php | 2 +- lib/Service/Marketing/JourneyStepRunner.php | 2 +- lib/Service/SchemaMapService.php | 2 +- lib/Service/SettingsLoadService.php | 4 ++- lib/Settings/pipelinq_mock_register.json | 6 ++-- lib/Settings/pipelinq_register.json | 6 ++-- .../register.d/40-pos-cash-management.json | 2 +- .../50-pos-end-of-day-bookkeeping.json | 2 +- .../register.d/60-pos-split-tender.json | 2 +- lib/Settings/register.d/97-lead-activity.json | 2 +- .../namespace-the-crm-task/proposal.md | 28 ++++++++++++++++ .../specs/openregister-integration/spec.md | 32 +++++++++++++++++++ .../changes/namespace-the-crm-task/tasks.md | 14 ++++++++ .../werkplek/WerkplekNewTaskDialog.vue | 2 +- src/config/objectTypes.js | 2 +- src/manifest.d/85-kcc-werkplek.json | 2 +- src/manifest.json | 8 ++--- src/views/export/ExportJobForm.vue | 2 +- .../Repair/RenameCollidingSchemaSlugsTest.php | 5 ++- tests/Unit/Service/CallbackServiceTest.php | 2 +- .../Lifecycle/SchemaLifecycleGraphTest.php | 4 +-- .../Marketing/JourneyStepRunnerTest.php | 2 +- 23 files changed, 110 insertions(+), 27 deletions(-) create mode 100644 openspec/changes/namespace-the-crm-task/proposal.md create mode 100644 openspec/changes/namespace-the-crm-task/specs/openregister-integration/spec.md create mode 100644 openspec/changes/namespace-the-crm-task/tasks.md diff --git a/lib/Repair/RenameCollidingSchemaSlugs.php b/lib/Repair/RenameCollidingSchemaSlugs.php index bdc25b13b..ced56ca9a 100644 --- a/lib/Repair/RenameCollidingSchemaSlugs.php +++ b/lib/Repair/RenameCollidingSchemaSlugs.php @@ -107,6 +107,10 @@ class RenameCollidingSchemaSlugs implements IRepairStep { // claimed by three apps and all three share only `name`, so all three // namespace: shillinq keeps the bare slug, stackiq took catalogService. 'service' => ['to' => 'appointmentService', 'with' => 'shillinq, stackiq'], + // The CRM task. `task` was claimed by three apps and they share only + // `description`, `priority` and `status`. planninq's project task is the + // largest and keeps the bare slug; dossiq took caseTask. + 'task' => ['to' => 'crmTask', 'with' => 'planninq, dossiq'], ]; /** diff --git a/lib/Service/CallbackService.php b/lib/Service/CallbackService.php index 54c460b71..936f7f48d 100644 --- a/lib/Service/CallbackService.php +++ b/lib/Service/CallbackService.php @@ -51,7 +51,7 @@ class CallbackService { * * @var string */ - private const TASK_SCHEMA_SLUG = 'task'; + private const TASK_SCHEMA_SLUG = 'crmTask'; /** * Fallback status-transition graph used only when the schema declaration is diff --git a/lib/Service/Marketing/JourneyStepRunner.php b/lib/Service/Marketing/JourneyStepRunner.php index 898108601..b1661e7eb 100644 --- a/lib/Service/Marketing/JourneyStepRunner.php +++ b/lib/Service/Marketing/JourneyStepRunner.php @@ -263,7 +263,7 @@ private function createTask(array $action, string $contactId): array { $payload['assigneeUserId'] = $assignee; } - $stored = $this->store->save(schemaSlug: $this->store->schemaSlug('task_schema', 'task'), payload: $payload); + $stored = $this->store->save(schemaSlug: $this->store->schemaSlug('task_schema', 'crmTask'), payload: $payload); if ($stored === null) { return ['state' => 'failed', 'reason' => 'task_write_failed']; } diff --git a/lib/Service/SchemaMapService.php b/lib/Service/SchemaMapService.php index d32466974..26d0f1d6f 100644 --- a/lib/Service/SchemaMapService.php +++ b/lib/Service/SchemaMapService.php @@ -54,7 +54,7 @@ class SchemaMapService { // 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', + 'task_schema' => 'crmTask', 'posTransaction_schema' => 'posTransaction', // POS staff PIN + role permissions (pos-staff-pin-permissions). 'posRole_schema' => 'posRole', diff --git a/lib/Service/SettingsLoadService.php b/lib/Service/SettingsLoadService.php index fea59c508..40514bdba 100644 --- a/lib/Service/SettingsLoadService.php +++ b/lib/Service/SettingsLoadService.php @@ -52,7 +52,7 @@ class SettingsLoadService { 'productCategory', 'billingCategory', 'leadProduct', - 'task', + 'crmTask', 'relationship', 'queue', 'skill', @@ -319,6 +319,8 @@ private function applyRegisterConfig(array $importResult): void { 'appointmentBooking' => 'booking_schema', // The bookable service, same reasoning as the booking above. 'appointmentService' => 'service_schema', + // The CRM task, same reasoning as the appointment schemas above. + 'crmTask' => 'task_schema', ]; /** diff --git a/lib/Settings/pipelinq_mock_register.json b/lib/Settings/pipelinq_mock_register.json index b32846233..067940d93 100644 --- a/lib/Settings/pipelinq_mock_register.json +++ b/lib/Settings/pipelinq_mock_register.json @@ -6117,7 +6117,7 @@ { "@self": { "register": "pipelinq", - "schema": "task", + "schema": "crmTask", "slug": "task-task-1-1" }, "subject": "Voorbeeld Subject 1", @@ -6148,7 +6148,7 @@ { "@self": { "register": "pipelinq", - "schema": "task", + "schema": "crmTask", "slug": "task-task-2-2" }, "subject": "Voorbeeld Subject 2", @@ -6179,7 +6179,7 @@ { "@self": { "register": "pipelinq", - "schema": "task", + "schema": "crmTask", "slug": "task-task-3-3" }, "subject": "Voorbeeld Subject 3", diff --git a/lib/Settings/pipelinq_register.json b/lib/Settings/pipelinq_register.json index f66b8717b..c044b36bb 100644 --- a/lib/Settings/pipelinq_register.json +++ b/lib/Settings/pipelinq_register.json @@ -31,7 +31,7 @@ "productCategory", "leadProduct", "contactmoment", - "task", + "crmTask", "relationship", "skill", "agentProfile", @@ -1426,9 +1426,9 @@ } } }, - "task": { + "crmTask": { "x-schema-org": "schema:Action", - "slug": "task", + "slug": "crmTask", "x-openregister-notifications": { "taskCompleted": { "trigger": { diff --git a/lib/Settings/register.d/40-pos-cash-management.json b/lib/Settings/register.d/40-pos-cash-management.json index 7451caf2f..22fec5e54 100644 --- a/lib/Settings/register.d/40-pos-cash-management.json +++ b/lib/Settings/register.d/40-pos-cash-management.json @@ -13,7 +13,7 @@ "productCategory", "leadProduct", "contactmoment", - "task", + "crmTask", "kennisartikel", "kenniscategorie", "kennisfeedback", diff --git a/lib/Settings/register.d/50-pos-end-of-day-bookkeeping.json b/lib/Settings/register.d/50-pos-end-of-day-bookkeeping.json index f14f563cd..a55cbae02 100644 --- a/lib/Settings/register.d/50-pos-end-of-day-bookkeeping.json +++ b/lib/Settings/register.d/50-pos-end-of-day-bookkeeping.json @@ -13,7 +13,7 @@ "productCategory", "leadProduct", "contactmoment", - "task", + "crmTask", "kennisartikel", "kenniscategorie", "kennisfeedback", diff --git a/lib/Settings/register.d/60-pos-split-tender.json b/lib/Settings/register.d/60-pos-split-tender.json index 8cfdd034f..d527a7470 100644 --- a/lib/Settings/register.d/60-pos-split-tender.json +++ b/lib/Settings/register.d/60-pos-split-tender.json @@ -13,7 +13,7 @@ "productCategory", "leadProduct", "contactmoment", - "task", + "crmTask", "kennisartikel", "kenniscategorie", "kennisfeedback", diff --git a/lib/Settings/register.d/97-lead-activity.json b/lib/Settings/register.d/97-lead-activity.json index 10dcc4235..e5cbc82d3 100644 --- a/lib/Settings/register.d/97-lead-activity.json +++ b/lib/Settings/register.d/97-lead-activity.json @@ -6,7 +6,7 @@ }, "components": { "schemas": { - "task": { + "crmTask": { "properties": { "lead": { "type": "string", diff --git a/openspec/changes/namespace-the-crm-task/proposal.md b/openspec/changes/namespace-the-crm-task/proposal.md new file mode 100644 index 000000000..e13caba53 --- /dev/null +++ b/openspec/changes/namespace-the-crm-task/proposal.md @@ -0,0 +1,28 @@ +# Namespace the CRM task + +## Why + +`task` was claimed by three apps: planninq, dossiq and this one. They share +`description`, `priority` and `status` — the attributes any task-shaped record +carries, and nothing that identifies the record. + +planninq's project task is the largest of the three and keeps the bare slug. +dossiq's becomes `caseTask`; this one is the CRM task, raised from a lead, a +ticket or the KCC werkplek. + +## The decoys, which are a vocabulary rather than stray words + +`task` is also an internal type name in two places, and neither is a schema: + +- **Activity types.** `ActivityTimelineService` maps a source type to an + activity type (`'emailLink' => 'email'`, `'task' => 'task'`). Its siblings + give it away: `interaction` is not a schema either, it queries tickets by + `ticketType`. The source-type vocabulary stays. +- **Notification subjects.** `NotificationService` branches on + `$entityType === 'task'` to pick `task_assigned`, and passes + `objectType: 'task'` to Nextcloud's notification API. That is routing, not a + schema lookup. + +Both were left alone. The schema references — the register lists, the +descriptor, `TASK_SCHEMA_SLUG`, `SchemaMapService`'s entity type, the exportable +schema list and the werkplek dialog's save — all moved. diff --git a/openspec/changes/namespace-the-crm-task/specs/openregister-integration/spec.md b/openspec/changes/namespace-the-crm-task/specs/openregister-integration/spec.md new file mode 100644 index 000000000..38e54a7f7 --- /dev/null +++ b/openspec/changes/namespace-the-crm-task/specs/openregister-integration/spec.md @@ -0,0 +1,32 @@ +# OpenRegister integration + +## ADDED Requirements + +### Requirement: The CRM task is namespaced (REQ-ORI-047) + +The CRM task schema SHALL be `crmTask` and SHALL NOT be `task`. planninq's +project task keeps the bare slug; dossiq uses `caseTask`. + +The three claiming schemas share `description`, `priority` and `status` alone, +so all three are renamed apart rather than folded onto one owner. + +The rename SHALL NOT touch `task` where it is an activity type in +`ActivityTimelineService` or a notification entity type or object type in +`NotificationService`. Those are internal vocabularies: `interaction` and +`emailLink` sit beside `task` in the first, and neither is a schema. + +A repair step SHALL rename the row IN PLACE before the register import, scoped +to this app's own rows. + +The config key SHALL remain `task_schema`. + +#### Scenario: The slug is renamed in place + +- **GIVEN** an install carrying a pipelinq-owned `task` schema +- **WHEN** the repair step runs +- **THEN** the row keeps its schema id, and so its shard table and objects. + +#### Scenario: The activity timeline still classifies a task + +- **WHEN** a task-sourced timeline entry is classified +- **THEN** its activity type is still `task`. diff --git a/openspec/changes/namespace-the-crm-task/tasks.md b/openspec/changes/namespace-the-crm-task/tasks.md new file mode 100644 index 000000000..8d6df8481 --- /dev/null +++ b/openspec/changes/namespace-the-crm-task/tasks.md @@ -0,0 +1,14 @@ +# Tasks + +- [x] 1.1 Move the key, slug and the four register-list entries + **files**: lib/Settings/pipelinq_register.json, lib/Settings/register.d/40-pos-cash-management.json, lib/Settings/register.d/50-pos-end-of-day-bookkeeping.json, lib/Settings/register.d/60-pos-split-tender.json, lib/Settings/register.d/97-lead-activity.json +- [x] 1.2 Follow the slug in the mock register and the manifests + **files**: lib/Settings/pipelinq_mock_register.json, src/manifest.json, src/manifest.d/85-kcc-werkplek.json +- [x] 2.1 Follow the slug in the callback service, schema map and journey runner + **files**: lib/Service/CallbackService.php, lib/Service/SchemaMapService.php, lib/Service/Marketing/JourneyStepRunner.php +- [x] 2.2 Follow the slug in the object-type map, export list and werkplek dialog + **files**: src/config/objectTypes.js, src/views/export/ExportJobForm.vue, src/components/werkplek/WerkplekNewTaskDialog.vue +- [x] 3.1 Move the slug in `SCHEMA_SLUGS`, pin the config key, extend the map to ten renames + **files**: lib/Service/SettingsLoadService.php, lib/Repair/RenameCollidingSchemaSlugs.php, tests/Unit/Repair/RenameCollidingSchemaSlugsTest.php +- [x] 4.1 Repoint the schema-slug test stubs, leaving activity types alone + **files**: tests/Unit/Service/CallbackServiceTest.php, tests/Unit/Service/Lifecycle/SchemaLifecycleGraphTest.php, tests/Unit/Service/Marketing/JourneyStepRunnerTest.php diff --git a/src/components/werkplek/WerkplekNewTaskDialog.vue b/src/components/werkplek/WerkplekNewTaskDialog.vue index 3e27117d6..3d5078b13 100644 --- a/src/components/werkplek/WerkplekNewTaskDialog.vue +++ b/src/components/werkplek/WerkplekNewTaskDialog.vue @@ -160,7 +160,7 @@ export default { status: 'open', } try { - const result = await this.objectStore.saveObject('task', payload) + const result = await this.objectStore.saveObject('crmTask', payload) if (!result) { try { showError( diff --git a/src/config/objectTypes.js b/src/config/objectTypes.js index 4ee1a6822..32e2f0a3b 100644 --- a/src/config/objectTypes.js +++ b/src/config/objectTypes.js @@ -170,7 +170,7 @@ export function objectTypes() { description: t(APP, 'Billable categories and tags'), }, { - slug: 'task', + slug: 'crmTask', group: 'core', label: t(APP, 'Task'), description: t(APP, 'Work items / tasks'), diff --git a/src/manifest.d/85-kcc-werkplek.json b/src/manifest.d/85-kcc-werkplek.json index 4e2aab2c7..60c15ad02 100644 --- a/src/manifest.d/85-kcc-werkplek.json +++ b/src/manifest.d/85-kcc-werkplek.json @@ -70,7 +70,7 @@ "title": "Tasks", "content": { "register": "pipelinq", - "schema": "task", + "schema": "crmTask", "filter": { "assigneeUserId": "@me", "status": "open" diff --git a/src/manifest.json b/src/manifest.json index e28ba3834..802a3652e 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2923,7 +2923,7 @@ "icon": "ClipboardCheckOutline", "content": { "register": "pipelinq", - "schema": "task", + "schema": "crmTask", "filter": { "lead": "@objectId" }, @@ -3117,7 +3117,7 @@ "title": "Tasks", "config": { "register": "pipelinq", - "schema": "task", + "schema": "crmTask", "documentationUrl": "https://pipelinq.conduction.nl", "showViewAction": false, "actions": [ @@ -3151,7 +3151,7 @@ "component": "CnWizardDialog", "config": { "register": "pipelinq", - "schema": "task" + "schema": "crmTask" } }, { @@ -3161,7 +3161,7 @@ "title": "Task", "config": { "register": "pipelinq", - "schema": "task", + "schema": "crmTask", "documentationUrl": "https://pipelinq.conduction.nl", "_note": "Task/action-item archetype (bespoke-detail-pages). A task is a to-do an agent works to completion, so the body leads with the execution fields (type, status, priority, deadline, assignee user/group, callbackPhoneNumber, preferredTimeSlot, attempts, resultText) 8-wide and the source records it hangs off (client, request, originating contactmoment) at 4-wide. Documents full-width for any attached result artefact. NO email/calendar body widgets — task declares no comms linkedType; a callback task carries the phone number as a field, not a live mailbox. No child collection, so no object-list.", "widgets": [ diff --git a/src/views/export/ExportJobForm.vue b/src/views/export/ExportJobForm.vue index e4ba3dc97..5ca33b7f2 100644 --- a/src/views/export/ExportJobForm.vue +++ b/src/views/export/ExportJobForm.vue @@ -119,7 +119,7 @@ import { useObjectStore } from '../../store/modules/object.js' // `contactmoment` schemas are one schema now, discriminated by `ticketType`. // Exporting `ticket` therefore covers all three; narrow to a single subtype // with a row filter (e.g. `ticketType = 'complaint'`). -const EXPORTABLE_SCHEMAS = ['client', 'contact', 'lead', 'ticket', 'task', 'product'] +const EXPORTABLE_SCHEMAS = ['client', 'contact', 'lead', 'ticket', 'crmTask', 'product'] const FORMATS = ['csv', 'parquet', 'jsonl'] const MODES = ['full', 'incremental'] diff --git a/tests/Unit/Repair/RenameCollidingSchemaSlugsTest.php b/tests/Unit/Repair/RenameCollidingSchemaSlugsTest.php index 3cf070148..00f026b30 100644 --- a/tests/Unit/Repair/RenameCollidingSchemaSlugsTest.php +++ b/tests/Unit/Repair/RenameCollidingSchemaSlugsTest.php @@ -123,13 +123,14 @@ public function testRenamesEverySlugInPlace(): void { 'message' => [607], 'booking' => [608], 'service' => [609], + 'task' => [610], ] ); $statements = &$this->captureStatements(); $this->step->run($this->createMock(IOutput::class)); - $this->assertCount(9, $statements, 'exactly nine rows may be rewritten'); + $this->assertCount(10, $statements, 'exactly ten rows may be rewritten'); foreach ($statements as $statement) { $this->assertStringContainsString('openregister_schemas', $statement[0]); $this->assertStringContainsString('SET slug', $statement[0]); @@ -145,6 +146,7 @@ public function testRenamesEverySlugInPlace(): void { $this->assertContains(['channelMessage', 607], $written); $this->assertContains(['appointmentBooking', 608], $written); $this->assertContains(['appointmentService', 609], $written); + $this->assertContains(['crmTask', 610], $written); }//end testRenamesEverySlugInPlace() @@ -165,6 +167,7 @@ public function testIsANoOpWhenTheOldSlugsAreAbsent(): void { 'channelMessage' => [607], 'appointmentBooking' => [608], 'appointmentService' => [609], + 'crmTask' => [610], ] ); $this->db->expects($this->never())->method('executeStatement'); diff --git a/tests/Unit/Service/CallbackServiceTest.php b/tests/Unit/Service/CallbackServiceTest.php index 72cf8517c..88012ff1f 100644 --- a/tests/Unit/Service/CallbackServiceTest.php +++ b/tests/Unit/Service/CallbackServiceTest.php @@ -294,7 +294,7 @@ public function testTransitionGraphIsSourcedFromSchemaDeclaration(): void { // The schema declaration matches the documented mirror constant. $graph = (new \OCA\Pipelinq\Service\Lifecycle\SchemaLifecycleGraph( settingsDir: __DIR__ . '/../../../lib/Settings' - ))->adjacencyFor(schemaSlug: 'task'); + ))->adjacencyFor(schemaSlug: 'crmTask'); $this->assertSame(CallbackService::ALLOWED_TRANSITIONS, $graph); }//end testTransitionGraphIsSourcedFromSchemaDeclaration() diff --git a/tests/Unit/Service/Lifecycle/SchemaLifecycleGraphTest.php b/tests/Unit/Service/Lifecycle/SchemaLifecycleGraphTest.php index b07fcfdfd..1bbcecb73 100644 --- a/tests/Unit/Service/Lifecycle/SchemaLifecycleGraphTest.php +++ b/tests/Unit/Service/Lifecycle/SchemaLifecycleGraphTest.php @@ -52,7 +52,7 @@ public function testTaskAdjacencyMatchesDeclaredGraph(): void { 'completed' => ['open'], 'expired' => ['open'], ], - actual: $this->graph()->adjacencyFor(schemaSlug: 'task') + actual: $this->graph()->adjacencyFor(schemaSlug: 'crmTask') ); }//end testTaskAdjacencyMatchesDeclaredGraph() @@ -202,6 +202,6 @@ public function testUnknownSchemaYieldsEmptyMap(): void { */ public function testUnreadableSettingsDirYieldsEmptyMap(): void { $graph = new SchemaLifecycleGraph(settingsDir: '/nonexistent/path/Settings'); - $this->assertSame(expected: [], actual: $graph->adjacencyFor(schemaSlug: 'task')); + $this->assertSame(expected: [], actual: $graph->adjacencyFor(schemaSlug: 'crmTask')); }//end testUnreadableSettingsDirYieldsEmptyMap() }//end class diff --git a/tests/Unit/Service/Marketing/JourneyStepRunnerTest.php b/tests/Unit/Service/Marketing/JourneyStepRunnerTest.php index 46e24fec7..b0fc06f6f 100644 --- a/tests/Unit/Service/Marketing/JourneyStepRunnerTest.php +++ b/tests/Unit/Service/Marketing/JourneyStepRunnerTest.php @@ -183,7 +183,7 @@ public function testATaskStepDoesNotConsultTheConsentGate(): void { $this->assertSame('task-created', $outcomes[0]['state']); - $tasks = $this->store->findAll('task'); + $tasks = $this->store->findAll('crmTask'); $this->assertSame('Call about the renewal', $tasks[0]['subject']); $this->assertSame('client-1', $tasks[0]['clientId']); }//end testATaskStepDoesNotConsultTheConsentGate()