diff --git a/lib/Search/ObjectsProvider.php b/lib/Search/ObjectsProvider.php index 48d47855a..290b7b003 100644 --- a/lib/Search/ObjectsProvider.php +++ b/lib/Search/ObjectsProvider.php @@ -21,6 +21,8 @@ namespace OCA\OpenRegister\Search; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\DeepLinkRegistryService; use OCA\OpenRegister\Service\ObjectService; use OCP\IL10N; @@ -78,6 +80,27 @@ class ObjectsProvider implements IFilteringProvider */ private readonly DeepLinkRegistryService $deepLinkRegistry; + /** + * Schema mapper for resolving schema names + * + * @var SchemaMapper + */ + private readonly SchemaMapper $schemaMapper; + + /** + * Register mapper for resolving register names + * + * @var RegisterMapper + */ + private readonly RegisterMapper $registerMapper; + + /** + * Cache for schema/register names to avoid repeated lookups + * + * @var array + */ + private array $nameCache = []; + /** * Constructor for the ObjectsProvider class * @@ -86,6 +109,8 @@ class ObjectsProvider implements IFilteringProvider * @param ObjectService $objectService The object service for search operations * @param LoggerInterface $logger Logger for debugging search operations * @param DeepLinkRegistryService $deepLinkRegistry Deep link registry for URL resolution + * @param SchemaMapper $schemaMapper Schema mapper for resolving schema names + * @param RegisterMapper $registerMapper Register mapper for resolving register names * * @return void */ @@ -94,13 +119,17 @@ public function __construct( IURLGenerator $urlGenerator, ObjectService $objectService, LoggerInterface $logger, - DeepLinkRegistryService $deepLinkRegistry + DeepLinkRegistryService $deepLinkRegistry, + SchemaMapper $schemaMapper, + RegisterMapper $registerMapper ) { $this->l10n = $l10n; $this->urlGenerator = $urlGenerator; $this->objectService = $objectService; $this->logger = $logger; $this->deepLinkRegistry = $deepLinkRegistry; + $this->schemaMapper = $schemaMapper; + $this->registerMapper = $registerMapper; }//end __construct() /** @@ -396,6 +425,52 @@ public function search(IUser $user, ISearchQuery $query): SearchResult ); }//end search() + /** + * Resolve a schema ID to its human-readable title. + * + * @param int $schemaId The schema ID + * + * @return string The schema title or the ID as fallback + */ + private function resolveSchemaName(int $schemaId): string + { + $key = 'schema_'.$schemaId; + if (isset($this->nameCache[$key]) === false) { + try { + $schema = $this->schemaMapper->find($schemaId); + $title = $schema->getTitle(); + $this->nameCache[$key] = ($title !== null && $title !== '' ? $title : (string) $schemaId); + } catch (\Exception $e) { + $this->nameCache[$key] = (string) $schemaId; + } + } + + return $this->nameCache[$key]; + }//end resolveSchemaName() + + /** + * Resolve a register ID to its human-readable title. + * + * @param int $registerId The register ID + * + * @return string The register title or the ID as fallback + */ + private function resolveRegisterName(int $registerId): string + { + $key = 'register_'.$registerId; + if (isset($this->nameCache[$key]) === false) { + try { + $register = $this->registerMapper->find($registerId); + $title = $register->getTitle(); + $this->nameCache[$key] = ($title !== null && $title !== '' ? $title : (string) $registerId); + } catch (\Exception $e) { + $this->nameCache[$key] = (string) $registerId; + } + } + + return $this->nameCache[$key]; + }//end resolveRegisterName() + /** * Build a descriptive text for search results * @@ -410,13 +485,13 @@ private function buildDescription(array $object): string { $parts = []; - // Add schema/register information if available. + // Add schema/register names (resolved from IDs) if available. if (empty($object['schema']) === false) { - $parts[] = $this->l10n->t('Schema: %s', $object['schema']); + $parts[] = $this->resolveSchemaName(schemaId: (int) $object['schema']); } if (empty($object['register']) === false) { - $parts[] = $this->l10n->t('Register: %s', $object['register']); + $parts[] = $this->resolveRegisterName(registerId: (int) $object['register']); } // Add summary/description if available. diff --git a/openspec/changes/nextcloud-entity-relations/design.md b/openspec/changes/nextcloud-entity-relations/design.md new file mode 100644 index 000000000..1f014bb9e --- /dev/null +++ b/openspec/changes/nextcloud-entity-relations/design.md @@ -0,0 +1,212 @@ +# Design: Nextcloud Entity Relations + +## Approach + +Extend the established object-interactions pattern (NoteService wraps ICommentsManager, TaskService wraps CalDavBackend) to four new entity types. Each integration follows the same layered architecture: + +``` +Controller (REST API) → Service (NC API wrapper) → Nextcloud Subsystem + → Link Table (for email/contact/deck lookups) + → ObjectCleanupListener (cascade on delete) + → Event Dispatcher (CloudEvents) +``` + +## Architecture Decisions + +### AD-1: Relation Tables vs. Custom Properties Only + +**Decision**: Use dual storage for emails, contacts, and deck cards — a relation table AND (where applicable) custom properties on the NC entity. + +**Why**: CalDAV/CardDAV custom properties (`X-OPENREGISTER-*`) enable discovery from the NC entity side, but querying "all emails for object X" across IMAP is not feasible. Relation tables provide O(1) lookups by object UUID. The existing TaskService uses only CalDAV properties because CalDavBackend supports searching by custom property; Mail and Deck do not. + +**Trade-off**: Extra migration, extra cleanup logic. Worth it for query performance. + +### AD-2: Emails Are Link-Only (No Send/Compose) + +**Decision**: EmailService only links existing Mail messages to objects. Sending email is out of scope (handled by n8n workflows). + +**Why**: The Mail app owns the SMTP pipeline. Duplicating send logic would create maintenance burden and divergent behavior. n8n workflows already handle automated notifications. + +### AD-3: Calendar Events Unlink (Don't Delete) on Object Deletion + +**Decision**: When an object is deleted, linked VEVENTs have their X-OPENREGISTER-* properties removed but are NOT deleted. + +**Why**: Calendar events may involve external participants. Deleting a meeting because a case object was deleted would be surprising and potentially disruptive. + +### AD-4: Contact Role as First-Class Field + +**Decision**: Each contact-object link has a `role` field (e.g., "applicant", "handler", "advisor"). + +**Why**: The same contact may be linked to multiple objects in different capacities. Role enables filtering ("show me all cases where Jan is the applicant") and display ("Applicant: Jan de Vries"). + +### AD-5: Deck Integration via OCA\Deck\Service Classes + +**Decision**: Use Deck's internal PHP service classes (`CardService`, `BoardService`, `StackService`) rather than the OCS REST API. + +**Why**: Same-server PHP calls avoid HTTP overhead and authentication complexity. Deck services are injectable via DI when the app is installed. + +## Files Affected + +### New Files (Backend) + +| File | Purpose | +|------|---------| +| `lib/Service/EmailService.php` | Wraps Mail message lookups, manages `openregister_email_links` | +| `lib/Service/CalendarEventService.php` | Wraps CalDAV VEVENT operations, mirrors TaskService pattern | +| `lib/Service/ContactService.php` | Wraps CardDAV vCard operations, manages `openregister_contact_links` | +| `lib/Service/DeckCardService.php` | Wraps Deck card operations, manages `openregister_deck_links` | +| `lib/Controller/EmailsController.php` | REST endpoints for email relations | +| `lib/Controller/CalendarEventsController.php` | REST endpoints for calendar event relations | +| `lib/Controller/ContactsController.php` | REST endpoints for contact relations | +| `lib/Controller/DeckController.php` | REST endpoints for deck card relations | +| `lib/Controller/RelationsController.php` | Unified relations endpoint | +| `lib/Db/EmailLink.php` | Entity for `openregister_email_links` | +| `lib/Db/EmailLinkMapper.php` | Mapper for email links | +| `lib/Db/ContactLink.php` | Entity for `openregister_contact_links` | +| `lib/Db/ContactLinkMapper.php` | Mapper for contact links | +| `lib/Db/DeckLink.php` | Entity for `openregister_deck_links` | +| `lib/Db/DeckLinkMapper.php` | Mapper for deck links | +| `lib/Migration/VersionXDateYYYY_entity_relations.php` | Database migration for 3 link tables | + +### Modified Files (Backend) + +| File | Change | +|------|--------| +| `appinfo/routes.php` | Add routes for emails, events, contacts, deck, relations | +| `lib/Listener/ObjectCleanupListener.php` | Extend with cleanup for 4 new entity types | +| `lib/AppInfo/Application.php` | Register new services and event listeners | + +### New Files (Frontend) + +| File | Purpose | +|------|---------| +| `src/entities/emailLink/` | Store, entity definition, API calls | +| `src/entities/calendarEvent/` | Store, entity definition, API calls | +| `src/entities/contactLink/` | Store, entity definition, API calls | +| `src/entities/deckLink/` | Store, entity definition, API calls | +| `src/views/objects/tabs/EmailsTab.vue` | Email relations tab on object detail | +| `src/views/objects/tabs/EventsTab.vue` | Calendar events tab | +| `src/views/objects/tabs/ContactsTab.vue` | Contacts tab | +| `src/views/objects/tabs/DeckTab.vue` | Deck cards tab | +| `src/views/objects/tabs/RelationsTab.vue` | Unified timeline view | + +## API Routes (to add to routes.php) + +```php +// Email relations +['name' => 'emails#index', 'url' => '/api/objects/{register}/{schema}/{id}/emails', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], +['name' => 'emails#create', 'url' => '/api/objects/{register}/{schema}/{id}/emails', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], +['name' => 'emails#destroy', 'url' => '/api/objects/{register}/{schema}/{id}/emails/{emailId}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+', 'emailId' => '\d+']], +['name' => 'emails#search', 'url' => '/api/emails/search', 'verb' => 'GET'], + +// Calendar event relations +['name' => 'calendarEvents#index', 'url' => '/api/objects/{register}/{schema}/{id}/events', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], +['name' => 'calendarEvents#create', 'url' => '/api/objects/{register}/{schema}/{id}/events', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], +['name' => 'calendarEvents#link', 'url' => '/api/objects/{register}/{schema}/{id}/events/link', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], +['name' => 'calendarEvents#destroy', 'url' => '/api/objects/{register}/{schema}/{id}/events/{eventId}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+', 'eventId' => '[^/]+']], + +// Contact relations +['name' => 'contacts#index', 'url' => '/api/objects/{register}/{schema}/{id}/contacts', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], +['name' => 'contacts#create', 'url' => '/api/objects/{register}/{schema}/{id}/contacts', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], +['name' => 'contacts#update', 'url' => '/api/objects/{register}/{schema}/{id}/contacts/{contactId}', 'verb' => 'PUT', 'requirements' => ['id' => '[^/]+', 'contactId' => '\d+']], +['name' => 'contacts#destroy', 'url' => '/api/objects/{register}/{schema}/{id}/contacts/{contactId}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+', 'contactId' => '\d+']], +['name' => 'contacts#objects', 'url' => '/api/contacts/{contactUid}/objects', 'verb' => 'GET', 'requirements' => ['contactUid' => '[^/]+']], + +// Deck card relations +['name' => 'deck#index', 'url' => '/api/objects/{register}/{schema}/{id}/deck', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], +['name' => 'deck#create', 'url' => '/api/objects/{register}/{schema}/{id}/deck', 'verb' => 'POST', 'requirements' => ['id' => '[^/]+']], +['name' => 'deck#destroy', 'url' => '/api/objects/{register}/{schema}/{id}/deck/{deckId}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+', 'deckId' => '\d+']], +['name' => 'deck#objects', 'url' => '/api/deck/boards/{boardId}/objects', 'verb' => 'GET', 'requirements' => ['boardId' => '\d+']], + +// Unified relations +['name' => 'relations#index', 'url' => '/api/objects/{register}/{schema}/{id}/relations', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']], +``` + +## Database Migration + +Three new tables: + +```sql +-- Email links (Mail message → Object) +CREATE TABLE openregister_email_links ( + id INT AUTO_INCREMENT PRIMARY KEY, + object_uuid VARCHAR(36) NOT NULL, + register_id INT NOT NULL, + mail_account_id INT NOT NULL, + mail_message_id INT NOT NULL, + mail_message_uid VARCHAR(255), + subject VARCHAR(512), + sender VARCHAR(255), + date DATETIME, + linked_by VARCHAR(64) NOT NULL, + linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY idx_email_object (object_uuid, mail_message_id), + INDEX idx_email_object_uuid (object_uuid), + INDEX idx_email_sender (sender) +); + +-- Contact links (vCard → Object) +CREATE TABLE openregister_contact_links ( + id INT AUTO_INCREMENT PRIMARY KEY, + object_uuid VARCHAR(36) NOT NULL, + register_id INT NOT NULL, + contact_uid VARCHAR(255) NOT NULL, + addressbook_id INT NOT NULL, + contact_uri VARCHAR(512) NOT NULL, + display_name VARCHAR(255), + email VARCHAR(255), + role VARCHAR(64), + linked_by VARCHAR(64) NOT NULL, + linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_contact_object (object_uuid), + INDEX idx_contact_uid (contact_uid), + INDEX idx_contact_role (role) +); + +-- Deck links (Deck card → Object) +CREATE TABLE openregister_deck_links ( + id INT AUTO_INCREMENT PRIMARY KEY, + object_uuid VARCHAR(36) NOT NULL, + register_id INT NOT NULL, + board_id INT NOT NULL, + stack_id INT NOT NULL, + card_id INT NOT NULL, + card_title VARCHAR(255), + linked_by VARCHAR(64) NOT NULL, + linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY idx_deck_object_card (object_uuid, card_id), + INDEX idx_deck_object (object_uuid), + INDEX idx_deck_board (board_id) +); +``` + +Note: Calendar events use CalDAV properties only (same as tasks) — no separate table needed. + +## Service Dependency Map + +``` +EmailService +├── Mail\Db\MessageMapper (read mail messages) +├── EmailLinkMapper (manage link table) +├── IUserSession +└── LoggerInterface + +CalendarEventService +├── CalDavBackend (same as TaskService) +├── IUserSession +└── LoggerInterface + +ContactService +├── CalDavBackend (CardDAV shares the DAV backend) +├── ContactLinkMapper (manage link table) +├── IUserSession +└── LoggerInterface + +DeckCardService +├── OCA\Deck\Service\CardService (when Deck installed) +├── OCA\Deck\Service\StackService +├── DeckLinkMapper (manage link table) +├── IAppManager (check if Deck is installed) +├── IUserSession +└── LoggerInterface +``` diff --git a/openspec/changes/nextcloud-entity-relations/proposal.md b/openspec/changes/nextcloud-entity-relations/proposal.md new file mode 100644 index 000000000..893fdd39e --- /dev/null +++ b/openspec/changes/nextcloud-entity-relations/proposal.md @@ -0,0 +1,48 @@ +# Nextcloud Entity Relations + +## Problem + +OpenRegister objects currently support linking to Nextcloud files (IRootFolder), notes (ICommentsManager), and tasks (CalDAV VTODO). However, other core Nextcloud entities — emails, calendar events, contacts, and Deck cards — cannot be related to objects. This limits the ability of consuming apps (Procest, Pipelinq, ZaakAfhandelApp) to present a complete picture of all activities and stakeholders associated with a case/object. + +The existing object-interactions spec established the pattern: wrap a Nextcloud subsystem, expose sub-resource endpoints under `/api/objects/{register}/{schema}/{id}/`, and handle cleanup on deletion. This change extends that pattern to four new entity types. + +## Context + +- **Existing integrations**: Files (IRootFolder), Notes (ICommentsManager), Tasks (CalDAV VTODO) +- **Established pattern**: Service wraps NC API, Controller exposes REST endpoints, ObjectCleanupListener cascades on delete +- **Consuming apps**: Procest (case management workflows), Pipelinq (pipeline/kanban workflows), ZaakAfhandelApp (ZGW case handling) +- **Key principle**: We do NOT sync/import NC entities into OpenRegister objects. We CREATE RELATIONS between OR objects and existing NC entities. The NC entity remains the source of truth; OR stores only the reference. + +## Proposed Solution + +Add four new integration services following the existing pattern: + +1. **EmailService** — Link Nextcloud Mail messages to objects. Read-only references (emails are immutable). Uses the Nextcloud Mail app's internal API or database to resolve message metadata. +2. **CalendarEventService** — Link CalDAV VEVENT entries to objects, similar to how TaskService links VTODO. Uses X-OPENREGISTER-* custom properties and RFC 9253 LINK property. +3. **ContactService** — Link CardDAV vCard contacts to objects. Uses X-OPENREGISTER-* custom properties to tag contacts with object references. +4. **DeckCardService** — Link Nextcloud Deck cards to objects. Uses Deck's OCS API to create/manage board cards and store object references. + +Each integration follows the same sub-resource endpoint pattern: +``` +GET /api/objects/{register}/{schema}/{id}/{entity} +POST /api/objects/{register}/{schema}/{id}/{entity} +DELETE /api/objects/{register}/{schema}/{id}/{entity}/{entityId} +``` + +## Scope + +### In scope +- Email relation service and API (link existing emails to objects) +- Calendar event relation service and API (link/create VEVENT on objects) +- Contact relation service and API (link/create vCard contacts on objects) +- Deck card relation service and API (link/create Deck cards on objects) +- Cleanup on object deletion for all four entity types +- Audit trail entries for relation mutations +- Event dispatching for relation changes +- Frontend components for viewing/managing relations on object detail pages + +### Out of scope +- Sending emails from OpenRegister (that's n8n's job) +- Syncing/importing entities as OR objects (we only store references) +- Full CRUD on the NC entity itself (managed via native NC apps) +- Nextcloud Talk/Spreed integration (separate future change) diff --git a/openspec/changes/nextcloud-entity-relations/specs/nextcloud-entity-relations/spec.md b/openspec/changes/nextcloud-entity-relations/specs/nextcloud-entity-relations/spec.md new file mode 100644 index 000000000..ddee9f40a --- /dev/null +++ b/openspec/changes/nextcloud-entity-relations/specs/nextcloud-entity-relations/spec.md @@ -0,0 +1,441 @@ +--- +status: proposed +--- + +# Nextcloud Entity Relations + +## Purpose + +OpenRegister objects need to relate to the full spectrum of Nextcloud PIM entities — emails, calendar events, contacts, and Deck cards — so that consuming apps (Procest, Pipelinq, ZaakAfhandelApp) can present a unified view of all activities, stakeholders, and deadlines associated with an object. This spec extends the existing object-interactions pattern (files, notes, tasks) to four new entity types using the same architectural approach: thin service wrappers around Nextcloud APIs with standardized sub-resource endpoints. + +**Key principle**: OpenRegister does NOT import or sync these entities. It stores REFERENCES (relations) that point to the canonical entity in its native Nextcloud subsystem. The Nextcloud entity remains the source of truth. + +**Standards**: RFC 5545 (iCalendar/VEVENT), RFC 6350 (vCard), RFC 9253 (iCalendar LINK property), Nextcloud Mail Integration API, Nextcloud Deck OCS API +**Cross-references**: [object-interactions](../../../specs/object-interactions/spec.md), [event-driven-architecture](../../../specs/event-driven-architecture/spec.md), [audit-trail-immutable](../../../specs/audit-trail-immutable/spec.md) + +--- + +## Requirements + +### Requirement: Email Relations via Nextcloud Mail + +The system SHALL provide an `EmailService` that links Nextcloud Mail messages to OpenRegister objects. Email relations are READ-ONLY references — emails are immutable and managed by the Mail app. The relation is stored as an `openregister_email_links` database table mapping object UUIDs to Mail message IDs. + +#### Rationale + +Emails are a primary communication channel in case management. A case handler receives an application by email, exchanges correspondence with citizens and colleagues, and needs all related emails visible on the case object. Unlike tasks (CalDAV) and notes (Comments), Nextcloud Mail does not have a generic entity-linking API, so we store the relation in our own table. + +#### Storage Model + +``` +openregister_email_links +├── id (int, PK, autoincrement) +├── object_uuid (string, indexed) — the OpenRegister object UUID +├── mail_account_id (int) — Nextcloud Mail account ID +├── mail_message_id (int) — Nextcloud Mail internal message ID +├── mail_message_uid (string) — IMAP message UID for reference +├── subject (string) — cached subject line for display without Mail API call +├── sender (string) — cached sender address +├── date (datetime) — cached send date +├── linked_by (string) — user who created the link +├── linked_at (datetime) — when the link was created +└── register_id (int, indexed) — for scoping/cleanup +``` + +#### Scenario: Link an existing email to an object +- **GIVEN** an authenticated user `behandelaar-1` and an object with UUID `abc-123` +- **WHEN** a POST request is sent to `/api/objects/{register}/{schema}/abc-123/emails` with body `{"mailAccountId": 1, "mailMessageId": 42}` +- **THEN** the system MUST verify the email exists by querying Nextcloud Mail's message table +- **AND** create a record in `openregister_email_links` with the object UUID and mail message reference +- **AND** cache the subject, sender, and date from the mail message +- **AND** return HTTP 201 with the email link as JSON including `id`, `objectUuid`, `mailAccountId`, `mailMessageId`, `subject`, `sender`, `date`, `linkedBy`, `linkedAt` + +#### Scenario: List email relations for an object +- **GIVEN** object `abc-123` has 4 linked emails +- **WHEN** a GET request is sent to `/api/objects/{register}/{schema}/abc-123/emails?limit=10&offset=0` +- **THEN** the response MUST return `{"results": [...], "total": 4}` with all 4 email links +- **AND** each link MUST include: `id`, `mailAccountId`, `mailMessageId`, `subject`, `sender`, `date`, `linkedBy`, `linkedAt` +- **AND** results MUST be ordered by `date` descending (newest first) + +#### Scenario: Remove an email relation +- **GIVEN** email link with ID 7 exists on object `abc-123` +- **WHEN** a DELETE request is sent to `/api/objects/{register}/{schema}/abc-123/emails/7` +- **THEN** the record MUST be removed from `openregister_email_links` +- **AND** the actual email in Nextcloud Mail MUST NOT be deleted +- **AND** the response MUST return HTTP 200 with `{"success": true}` + +#### Scenario: Link email that does not exist +- **GIVEN** a POST request with `mailMessageId: 99999` that does not exist in Nextcloud Mail +- **WHEN** the system verifies the email +- **THEN** the API MUST return HTTP 404 with `{"error": "Mail message not found"}` + +#### Scenario: Prevent duplicate email links +- **GIVEN** email message 42 is already linked to object `abc-123` +- **WHEN** a POST request tries to link the same email again +- **THEN** the API MUST return HTTP 409 with `{"error": "Email already linked to this object"}` + +#### Scenario: Search objects by linked email +- **GIVEN** multiple objects have email links +- **WHEN** a GET request is sent to `/api/emails/search?sender=burger@test.local` +- **THEN** the response MUST return all objects that have a linked email from that sender +- **AND** this enables cross-object email thread tracking + +--- + +### Requirement: Calendar Event Relations via CalDAV VEVENT + +The system SHALL provide a `CalendarEventService` that creates, reads, and deletes CalDAV VEVENT items linked to OpenRegister objects. This follows the exact same pattern as `TaskService` (VTODO), but for calendar events. Each VEVENT MUST include `X-OPENREGISTER-REGISTER`, `X-OPENREGISTER-SCHEMA`, and `X-OPENREGISTER-OBJECT` custom properties, plus an RFC 9253 LINK property. + +#### Rationale + +Cases have associated deadlines, hearings, meetings, and milestones that are best represented as calendar events. Unlike tasks (which track work items), calendar events represent time-bound occurrences that may involve multiple participants. Storing them in CalDAV ensures they appear in the user's Nextcloud Calendar app. + +#### Scenario: Create a calendar event linked to an object +- **GIVEN** an object with UUID `abc-123` in register 5, schema 12 +- **WHEN** a POST request is sent to `/api/objects/5/12/abc-123/events` with body: + ```json + { + "summary": "Welstandscommissie - dakkapel Kerkstraat 42", + "dtstart": "2026-03-25T13:00:00Z", + "dtend": "2026-03-25T15:00:00Z", + "location": "Raadzaal - Stadskantoor", + "description": "Behandeling aanvraag ZK-2026-0142", + "attendees": ["behandelaar@test.local"] + } + ``` +- **THEN** a VEVENT MUST be created in the user's default calendar with: + - `X-OPENREGISTER-REGISTER:5` + - `X-OPENREGISTER-SCHEMA:12` + - `X-OPENREGISTER-OBJECT:abc-123` + - `LINK;LINKREL="related";VALUE=URI:/apps/openregister/api/objects/5/12/abc-123` + - `SUMMARY`, `DTSTART`, `DTEND`, `LOCATION`, `DESCRIPTION`, `ATTENDEE` as provided +- **AND** the response MUST return HTTP 201 with the event as JSON including `id`, `uid`, `calendarId`, `summary`, `dtstart`, `dtend`, `location`, `description`, `attendees`, `objectUuid`, `registerId`, `schemaId` + +#### Scenario: List calendar events for an object +- **GIVEN** 2 VEVENTs exist with `X-OPENREGISTER-OBJECT:abc-123` +- **WHEN** a GET request is sent to `/api/objects/5/12/abc-123/events` +- **THEN** the response MUST return `{"results": [...], "total": 2}` with all 2 events +- **AND** each event MUST include: `id` (URI), `uid`, `calendarId`, `summary`, `dtstart`, `dtend`, `location`, `description`, `attendees`, `status`, `objectUuid`, `registerId`, `schemaId` + +#### Scenario: Link an existing calendar event to an object +- **GIVEN** a VEVENT already exists in the user's calendar (e.g., created via NC Calendar UI) +- **WHEN** a POST request is sent to `/api/objects/5/12/abc-123/events/link` with `{"calendarId": 1, "eventUri": "meeting-123.ics"}` +- **THEN** the system MUST update the VEVENT to add X-OPENREGISTER-* properties +- **AND** return HTTP 200 with the updated event JSON + +#### Scenario: Delete a calendar event relation +- **GIVEN** a VEVENT linked to object `abc-123` +- **WHEN** a DELETE request is sent to `/api/objects/5/12/abc-123/events/{eventId}` +- **THEN** the X-OPENREGISTER-* properties MUST be removed from the VEVENT +- **AND** the VEVENT itself MUST remain in the calendar (only the link is removed) +- **AND** the response MUST return `{"success": true}` + +#### Scenario: Force-delete calendar event with object +- **GIVEN** a VEVENT linked to object `abc-123` and the object is being deleted +- **WHEN** `ObjectCleanupListener` handles `ObjectDeletedEvent` +- **THEN** the X-OPENREGISTER-* properties MUST be removed from all linked VEVENTs +- **AND** the VEVENTs MUST NOT be deleted (only unlinked) + +#### Scenario: Calendar selection for events +- **GIVEN** the user has calendars `personal` (VEVENT+VTODO) and `birthdays` (VEVENT only) +- **WHEN** an event is created via the API +- **THEN** the service MUST use the user's default calendar or the first VEVENT-supporting calendar +- **AND** optionally accept a `calendarId` parameter to target a specific calendar + +--- + +### Requirement: Contact Relations via CardDAV + +The system SHALL provide a `ContactService` that links CardDAV vCard contacts to OpenRegister objects. Contacts represent stakeholders (citizens, applicants, suppliers, colleagues) associated with a case/object. The relation is stored via X-OPENREGISTER-* custom properties on the vCard AND in an `openregister_contact_links` table for efficient querying. + +#### Rationale + +Every case has stakeholders — the citizen who filed the application, the colleague who handles it, the external advisor who reviews it. These people exist as contacts in Nextcloud's address book. Linking them to objects allows consuming apps to show "who is involved" and find all cases a contact is involved in. + +#### Storage Model (dual storage) + +**vCard custom properties** (on the contact itself): +``` +X-OPENREGISTER-OBJECT:abc-123 +X-OPENREGISTER-ROLE:applicant +``` + +**Database table** (for efficient querying): +``` +openregister_contact_links +├── id (int, PK, autoincrement) +├── object_uuid (string, indexed) +├── contact_uid (string) — vCard UID +├── addressbook_id (int) — CardDAV addressbook ID +├── contact_uri (string) — vCard URI in addressbook +├── display_name (string) — cached FN from vCard +├── email (string, nullable) — cached primary email +├── role (string, nullable) — e.g., "applicant", "handler", "advisor", "supplier" +├── linked_by (string) — user who created the link +├── linked_at (datetime) +└── register_id (int, indexed) +``` + +#### Scenario: Link an existing contact to an object +- **GIVEN** an authenticated user and an object with UUID `abc-123` +- **WHEN** a POST request is sent to `/api/objects/{register}/{schema}/abc-123/contacts` with body `{"addressbookId": 1, "contactUri": "jan-de-vries.vcf", "role": "applicant"}` +- **THEN** the system MUST verify the contact exists via `CalDavBackend` (addressbook backend) +- **AND** add `X-OPENREGISTER-OBJECT:abc-123` and `X-OPENREGISTER-ROLE:applicant` properties to the vCard +- **AND** create a record in `openregister_contact_links` with cached display name and email +- **AND** return HTTP 201 with the contact link as JSON including `id`, `objectUuid`, `contactUid`, `displayName`, `email`, `role`, `linkedBy`, `linkedAt` + +#### Scenario: Create a new contact and link to object +- **GIVEN** an authenticated user and an object with UUID `abc-123` +- **WHEN** a POST request is sent to `/api/objects/{register}/{schema}/abc-123/contacts` with body: + ```json + { + "fullName": "Jan de Vries", + "email": "jan@example.nl", + "phone": "+31612345678", + "role": "applicant" + } + ``` +- **THEN** a new vCard MUST be created in the user's default address book with the provided properties and X-OPENREGISTER-* properties +- **AND** a record MUST be created in `openregister_contact_links` +- **AND** the response MUST return HTTP 201 with the contact link JSON + +#### Scenario: List contacts for an object +- **GIVEN** object `abc-123` has 3 linked contacts (applicant, handler, advisor) +- **WHEN** a GET request is sent to `/api/objects/{register}/{schema}/abc-123/contacts` +- **THEN** the response MUST return `{"results": [...], "total": 3}` +- **AND** each contact MUST include: `id`, `contactUid`, `addressbookId`, `displayName`, `email`, `phone`, `role`, `linkedBy`, `linkedAt` + +#### Scenario: Update contact role on an object +- **GIVEN** contact link with ID 5 exists with role `"applicant"` +- **WHEN** a PUT request is sent to `/api/objects/{register}/{schema}/abc-123/contacts/5` with `{"role": "co-applicant"}` +- **THEN** the role MUST be updated in both the `openregister_contact_links` table and the vCard's `X-OPENREGISTER-ROLE` property +- **AND** the response MUST return the updated contact link JSON + +#### Scenario: Remove a contact relation +- **GIVEN** contact link with ID 5 exists on object `abc-123` +- **WHEN** a DELETE request is sent to `/api/objects/{register}/{schema}/abc-123/contacts/5` +- **THEN** the record MUST be removed from `openregister_contact_links` +- **AND** the `X-OPENREGISTER-OBJECT` and `X-OPENREGISTER-ROLE` properties MUST be removed from the vCard +- **AND** the vCard itself MUST NOT be deleted +- **AND** the response MUST return HTTP 200 with `{"success": true}` + +#### Scenario: Find all objects linked to a contact +- **GIVEN** contact `jan-de-vries` is linked to objects `abc-123` and `def-456` +- **WHEN** a GET request is sent to `/api/contacts/{contactUid}/objects` +- **THEN** the response MUST return both objects with their respective roles +- **AND** this enables the "case history for this person" view in consuming apps + +#### Scenario: Contact with multiple object links +- **GIVEN** contact `jan-de-vries` is already linked to object `abc-123` as applicant +- **WHEN** the same contact is linked to object `def-456` as co-applicant +- **THEN** the vCard MUST contain multiple `X-OPENREGISTER-OBJECT` properties +- **AND** both links MUST exist in the database table + +--- + +### Requirement: Deck Card Relations via Nextcloud Deck API + +The system SHALL provide a `DeckCardService` that links Nextcloud Deck cards to OpenRegister objects. Deck provides kanban-style boards, stacks (columns), and cards. Linking cards to objects enables workflow visualization where each card represents a case/object moving through process stages. + +#### Rationale + +Pipelinq and Procest use pipeline/kanban views. Deck is Nextcloud's native kanban tool. By linking Deck cards to objects, case managers get a visual workflow board where cards are backed by OpenRegister data. Moving a card between stacks can trigger status changes on the object. + +#### Storage Model + +``` +openregister_deck_links +├── id (int, PK, autoincrement) +├── object_uuid (string, indexed) +├── board_id (int) — Deck board ID +├── stack_id (int) — Deck stack (column) ID +├── card_id (int) — Deck card ID +├── card_title (string) — cached card title +├── linked_by (string) +├── linked_at (datetime) +└── register_id (int, indexed) +``` + +#### Scenario: Create a Deck card linked to an object +- **GIVEN** an authenticated user, an object with UUID `abc-123`, and a Deck board with ID 1 +- **WHEN** a POST request is sent to `/api/objects/{register}/{schema}/abc-123/deck` with body: + ```json + { + "boardId": 1, + "stackId": 2, + "title": "ZK-2026-0142 - Omgevingsvergunning dakkapel", + "description": "Behandeling aanvraag omgevingsvergunning" + } + ``` +- **THEN** a card MUST be created via the Deck API (`OCA\Deck\Service\CardService`) +- **AND** the card description MUST include a link back to the object: `[Object: abc-123](/apps/openregister/api/objects/{register}/{schema}/abc-123)` +- **AND** a record MUST be created in `openregister_deck_links` +- **AND** the response MUST return HTTP 201 with the deck link as JSON including `id`, `objectUuid`, `boardId`, `stackId`, `cardId`, `cardTitle`, `linkedBy`, `linkedAt` + +#### Scenario: Link an existing Deck card to an object +- **GIVEN** a Deck card with ID 15 already exists +- **WHEN** a POST request is sent to `/api/objects/{register}/{schema}/abc-123/deck` with body `{"cardId": 15}` +- **THEN** the system MUST verify the card exists via Deck API +- **AND** update the card description to include the object link +- **AND** create a record in `openregister_deck_links` +- **AND** return HTTP 201 with the deck link JSON + +#### Scenario: List Deck cards for an object +- **GIVEN** object `abc-123` is linked to 2 Deck cards (one in "Nieuw", one in "In behandeling") +- **WHEN** a GET request is sent to `/api/objects/{register}/{schema}/abc-123/deck` +- **THEN** the response MUST return `{"results": [...], "total": 2}` +- **AND** each link MUST include: `id`, `boardId`, `stackId`, `cardId`, `cardTitle`, `stackTitle`, `linkedBy`, `linkedAt` +- **AND** the `stackTitle` MUST be resolved from the Deck API (e.g., "Nieuw", "In behandeling") + +#### Scenario: Remove a Deck card relation +- **GIVEN** deck link with ID 3 exists on object `abc-123` +- **WHEN** a DELETE request is sent to `/api/objects/{register}/{schema}/abc-123/deck/3` +- **THEN** the record MUST be removed from `openregister_deck_links` +- **AND** the Deck card itself MUST NOT be deleted (only the link is removed) +- **AND** the object link MUST be removed from the card description +- **AND** the response MUST return HTTP 200 with `{"success": true}` + +#### Scenario: Find objects by Deck board +- **GIVEN** a Deck board "Vergunningen Pipeline" with cards linked to multiple objects +- **WHEN** a GET request is sent to `/api/deck/boards/{boardId}/objects` +- **THEN** the response MUST return all objects linked to cards on that board +- **AND** include the stack (column) each object's card is in + +--- + +### Requirement: Unified Relations API + +The system SHALL provide a unified endpoint to retrieve ALL relations (files, notes, tasks, emails, events, contacts, deck cards) for an object in a single request. This enables consuming apps to build a complete "object dossier" view without multiple API calls. + +#### Scenario: Get all relations for an object +- **GIVEN** object `abc-123` has 2 files, 3 notes, 1 task, 4 emails, 2 events, 3 contacts, and 1 deck card +- **WHEN** a GET request is sent to `/api/objects/{register}/{schema}/abc-123/relations` +- **THEN** the response MUST return: + ```json + { + "files": {"results": [...], "total": 2}, + "notes": {"results": [...], "total": 3}, + "tasks": {"results": [...], "total": 1}, + "emails": {"results": [...], "total": 4}, + "events": {"results": [...], "total": 2}, + "contacts": {"results": [...], "total": 3}, + "deck": {"results": [...], "total": 1} + } + ``` + +#### Scenario: Filter relations by type +- **GIVEN** the unified relations endpoint +- **WHEN** a GET request includes `?types=emails,contacts` +- **THEN** only email and contact relations MUST be returned + +#### Scenario: Relations timeline view +- **GIVEN** all relations have a date field (creation date, send date, event date) +- **WHEN** a GET request is sent to `/api/objects/{register}/{schema}/abc-123/relations?view=timeline` +- **THEN** all relations MUST be returned in a flat array sorted by date +- **AND** each item MUST include a `type` field ("file", "note", "task", "email", "event", "contact", "deck") + +--- + +### Requirement: Object Deletion Cleanup for New Entity Types + +The `ObjectCleanupListener` SHALL be extended to handle cleanup of email links, calendar event links, contact links, and deck card links when an object is deleted. This follows the existing cleanup pattern for notes and tasks. + +#### Scenario: Delete object with email links +- **GIVEN** object `abc-123` has 4 email links +- **WHEN** the object is deleted (triggering `ObjectDeletedEvent`) +- **THEN** all 4 records in `openregister_email_links` with `object_uuid: "abc-123"` MUST be deleted +- **AND** the actual emails in Nextcloud Mail MUST NOT be affected + +#### Scenario: Delete object with calendar event links +- **GIVEN** object `abc-123` has 2 linked VEVENTs +- **WHEN** the object is deleted +- **THEN** X-OPENREGISTER-* properties MUST be removed from both VEVENTs +- **AND** the VEVENTs MUST remain in the calendar + +#### Scenario: Delete object with contact links +- **GIVEN** object `abc-123` has 3 linked contacts +- **WHEN** the object is deleted +- **THEN** all 3 records in `openregister_contact_links` MUST be deleted +- **AND** X-OPENREGISTER-* properties referencing `abc-123` MUST be removed from the vCards +- **AND** the vCards MUST NOT be deleted + +#### Scenario: Delete object with Deck card links +- **GIVEN** object `abc-123` has 1 linked Deck card +- **WHEN** the object is deleted +- **THEN** the record in `openregister_deck_links` MUST be deleted +- **AND** the object link MUST be removed from the Deck card description +- **AND** the Deck card MUST NOT be deleted + +#### Scenario: Partial cleanup failure does not block deletion +- **GIVEN** an object with relations across all entity types +- **WHEN** the cleanup of one entity type fails (e.g., Deck API unavailable) +- **THEN** cleanup of other entity types MUST still proceed +- **AND** the failure MUST be logged as a warning +- **AND** the object deletion MUST NOT be blocked + +--- + +### Requirement: Event Dispatching for Relation Changes + +The system SHALL fire typed events when relations are created or removed. These events follow the CloudEvents format from [event-driven-architecture](../../../specs/event-driven-architecture/spec.md). + +#### Scenario: Email link created fires event +- **GIVEN** an email is linked to object `abc-123` +- **THEN** an event `nl.openregister.object.email.linked` MUST be dispatched with the object UUID and mail message details + +#### Scenario: Contact linked fires event +- **GIVEN** a contact is linked to object `abc-123` with role `applicant` +- **THEN** an event `nl.openregister.object.contact.linked` MUST be dispatched with the object UUID, contact UID, and role + +#### Scenario: Calendar event linked fires event +- **GIVEN** a calendar event is linked to object `abc-123` +- **THEN** an event `nl.openregister.object.event.linked` MUST be dispatched with the object UUID and event summary/dates + +#### Scenario: Deck card linked fires event +- **GIVEN** a Deck card is linked to object `abc-123` +- **THEN** an event `nl.openregister.object.deck.linked` MUST be dispatched with the object UUID, board ID, and card title + +#### Scenario: Relation removed fires event +- **GIVEN** any relation is removed from an object +- **THEN** an `*.unlinked` event MUST be dispatched (e.g., `nl.openregister.object.email.unlinked`) + +--- + +### Requirement: Audit Trail for Relation Mutations + +All relation mutations SHALL generate audit trail entries per [audit-trail-immutable](../../../specs/audit-trail-immutable/spec.md). + +#### Scenario: Audit entries for relation actions +- **GIVEN** the following relation actions occur +- **THEN** the corresponding audit entries MUST be created: + - `email.linked` / `email.unlinked` + - `event.linked` / `event.unlinked` / `event.created` + - `contact.linked` / `contact.unlinked` / `contact.created` + - `deck.linked` / `deck.unlinked` / `deck.created` + +--- + +### Requirement: Graceful Degradation When NC Apps Are Disabled + +The system SHALL gracefully handle cases where a required Nextcloud app (Mail, Deck) is not installed or disabled. CalDAV/CardDAV are core Nextcloud features and always available; Mail and Deck are optional apps. + +#### Scenario: Mail app not installed +- **GIVEN** the Nextcloud Mail app is not installed +- **WHEN** a request is made to `/api/objects/{register}/{schema}/{id}/emails` +- **THEN** the API MUST return HTTP 501 with `{"error": "Nextcloud Mail app is not installed", "code": "APP_NOT_AVAILABLE"}` + +#### Scenario: Deck app not installed +- **GIVEN** the Nextcloud Deck app is not installed +- **WHEN** a request is made to `/api/objects/{register}/{schema}/{id}/deck` +- **THEN** the API MUST return HTTP 501 with `{"error": "Nextcloud Deck app is not installed", "code": "APP_NOT_AVAILABLE"}` + +#### Scenario: Relations API with missing apps +- **GIVEN** the unified relations endpoint and Mail app is not installed +- **WHEN** a GET request is sent to `/api/objects/{register}/{schema}/{id}/relations` +- **THEN** the `emails` section MUST be omitted from the response (not an error) +- **AND** all other relation types MUST still be returned normally + +#### Scenario: CalDAV/CardDAV always available +- **GIVEN** CalDAV and CardDAV are core Nextcloud services +- **WHEN** calendar event or contact relation endpoints are called +- **THEN** these MUST always work regardless of which apps are installed diff --git a/openspec/changes/nextcloud-entity-relations/tasks.md b/openspec/changes/nextcloud-entity-relations/tasks.md new file mode 100644 index 000000000..39fcb6ef6 --- /dev/null +++ b/openspec/changes/nextcloud-entity-relations/tasks.md @@ -0,0 +1,74 @@ +# Tasks: Nextcloud Entity Relations + +## Database & Infrastructure +- [ ] Create database migration for openregister_email_links, openregister_contact_links, openregister_deck_links tables +- [ ] Create EmailLink entity and EmailLinkMapper +- [ ] Create ContactLink entity and ContactLinkMapper +- [ ] Create DeckLink entity and DeckLinkMapper + +## Email Relations +- [ ] Implement EmailService (link/unlink/list emails, verify mail message exists) +- [ ] Implement EmailsController with REST endpoints +- [ ] Add email routes to routes.php +- [ ] Add email search endpoint (find objects by sender) +- [ ] Handle Mail app not installed (HTTP 501 graceful degradation) + +## Calendar Event Relations +- [ ] Implement CalendarEventService (create/link/unlink VEVENT with X-OPENREGISTER-* properties) +- [ ] Implement CalendarEventsController with REST endpoints +- [ ] Add calendar event routes to routes.php +- [ ] Implement calendar selection (find first VEVENT-supporting calendar) +- [ ] Handle attendees in VEVENT creation + +## Contact Relations +- [ ] Implement ContactService (link/create/unlink vCard contacts with X-OPENREGISTER-* properties) +- [ ] Implement ContactsController with REST endpoints +- [ ] Add contact routes to routes.php +- [ ] Implement role management on contact-object links +- [ ] Implement reverse lookup (find objects for a contact) +- [ ] Handle dual storage (vCard properties + database table) + +## Deck Card Relations +- [ ] Implement DeckCardService (create/link/unlink Deck cards) +- [ ] Implement DeckController with REST endpoints +- [ ] Add deck routes to routes.php +- [ ] Implement board-level object listing +- [ ] Handle Deck app not installed (HTTP 501 graceful degradation) + +## Unified Relations API +- [ ] Implement RelationsController with unified endpoint +- [ ] Support type filtering (?types=emails,contacts) +- [ ] Support timeline view (?view=timeline) + +## Cleanup & Events +- [ ] Extend ObjectCleanupListener for email links cleanup +- [ ] Extend ObjectCleanupListener for calendar event unlinking +- [ ] Extend ObjectCleanupListener for contact links cleanup +- [ ] Extend ObjectCleanupListener for deck links cleanup +- [ ] Add CloudEvents for email.linked/unlinked +- [ ] Add CloudEvents for event.linked/unlinked/created +- [ ] Add CloudEvents for contact.linked/unlinked/created +- [ ] Add CloudEvents for deck.linked/unlinked/created +- [ ] Add audit trail entries for all relation mutations + +## Service Registration +- [ ] Register new services in Application.php +- [ ] Register event listeners for cleanup + +## Frontend +- [ ] Create EmailsTab.vue component for object detail +- [ ] Create EventsTab.vue component for object detail +- [ ] Create ContactsTab.vue component for object detail +- [ ] Create DeckTab.vue component for object detail +- [ ] Create RelationsTab.vue unified timeline component +- [ ] Add entity stores for email/event/contact/deck links + +## Testing +- [ ] Unit tests for EmailService +- [ ] Unit tests for CalendarEventService +- [ ] Unit tests for ContactService +- [ ] Unit tests for DeckCardService +- [ ] Unit tests for RelationsController +- [ ] Integration tests with Greenmail (email linking) +- [ ] Integration tests with CalDAV (event creation) +- [ ] Integration tests with CardDAV (contact linking) diff --git a/openspec/changes/tmlo-metadata/proposal.md b/openspec/changes/tmlo-metadata/proposal.md new file mode 100644 index 000000000..b86ecf5b4 --- /dev/null +++ b/openspec/changes/tmlo-metadata/proposal.md @@ -0,0 +1,97 @@ +# Proposal: TMLO Metadata Standard Support + +## Summary + +Add optional TMLO (Toepassingsprofiel Metadatastandaard Lokale Overheden) metadata fields to OpenRegister objects. When enabled on a register or schema, objects automatically receive TMLO-compliant archival metadata (classification, retention, destruction date, archive status, etc.). This makes any app using OpenRegister -- Procest, Pipelinq, Docudesk, and others -- archival-compliant by default without each app implementing TMLO separately. + +## Problem + +Dutch municipalities must comply with TMLO for archival metadata on government records. Currently OpenRegister objects have no structured archival metadata conforming to the TMLO standard. Each consuming app would need to implement its own archival metadata layer, leading to inconsistency, duplication, and compliance gaps. + +TMLO is the local government profile of MDTO (Metadatastandaard voor Duurzaam Toegankelijke Overheidsinformatie), which is the national standard from Rijksoverheid. Both standards feed into the e-Depot ecosystem maintained by the Nationaal Archief. + +## Demand Evidence + +- **TMLO**: 54 tender sources explicitly requiring TMLO compliance +- **MDTO**: 73 tender sources requiring MDTO (the national standard that TMLO profiles) +- **e-Depot**: 56 tender sources requiring e-Depot integration (which depends on TMLO/MDTO metadata) +- **Digital archiving**: 141 tender sources requiring digital archiving capabilities + +### Sample Requirements from Tenders + +1. Municipalities require TMLO-compliant metadata on all zaakdossiers before transfer to e-Depot +2. Archival metadata must include classificatie, archiefnominatie, archiefactiedatum, and bewaarTermijn +3. Objects must carry vernietigingsCategorie linked to VNG Selectielijst result types +4. Systems must support export in MDTO/TMLO XML format for e-Depot ingest +5. Archival status transitions (actief, semi-statisch, overgebracht, vernietigd) must be tracked with audit trail + +## Scope + +### In Scope + +- **TMLO metadata schema**: Add TMLO-compliant fields to OpenRegister objects -- classificatie, archiefnominatie, archiefactiedatum, archiefstatus, bewaarTermijn, vernietigingsCategorie +- **Configurable per register**: Enable or disable TMLO metadata per register, so only registers that need archival compliance carry the overhead +- **Auto-populate metadata**: Automatically fill metadata fields based on schema/register-level settings (default retention periods, default classification, default archiefnominatie) +- **TMLO export format**: Generate TMLO/MDTO-compliant XML for e-Depot integration and archival transfer +- **Metadata validation**: Enforce required TMLO fields before allowing archival status transitions (e.g., cannot set archiefstatus to "overgebracht" without archiefactiedatum) +- **MDTO compatibility**: Ensure metadata model aligns with MDTO as the parent standard -- TMLO is the local government profile of MDTO +- **Archival status query endpoints**: API endpoints to query objects by archival status (e.g., "ready for destruction", "transferred to e-Depot", "permanently retained") + +### Out of Scope + +- Actual destruction execution (see: `archival-destruction-workflow`) +- e-Depot transfer protocol/connection (see: `edepot-transfer`) +- Retention period calculation engine (see: `retention-management`) +- DMS-level document management features + +## Features + +1. **TMLO metadata schema** -- Structured metadata fields conforming to TMLO 1.2: classificatie, archiefnominatie (blijvend bewaren / vernietigen), archiefactiedatum, archiefstatus (actief / semi-statisch / overgebracht / vernietigd), bewaarTermijn, vernietigingsCategorie +2. **Register-level TMLO toggle** -- Configurable per register: enable/disable TMLO metadata. When enabled, all objects in that register carry TMLO fields +3. **Auto-populate defaults** -- Schema and register settings define default retention periods, classification codes, and archiefnominatie. New objects inherit these defaults automatically +4. **TMLO/MDTO export** -- Export objects with their TMLO metadata in MDTO-compliant XML format, suitable for e-Depot ingest workflows +5. **Metadata validation rules** -- Required-field validation before archival status changes. Configurable per register to enforce completeness before transfer or destruction +6. **MDTO compatibility layer** -- TMLO is the local government profile of MDTO. The metadata model supports both, allowing central government apps to use MDTO directly +7. **Archival status query API** -- Endpoints to filter and retrieve objects by archiefstatus, archiefactiedatum ranges, and vernietigingsCategorie for batch operations + +## Acceptance Criteria + +1. A register can be configured to enable TMLO metadata on its objects +2. When TMLO is enabled, all objects in that register carry the six core TMLO fields +3. Default values for TMLO fields can be configured at register and schema level +4. New objects automatically inherit TMLO defaults from their schema/register configuration +5. Archival status transitions are validated -- required fields must be present before status change +6. Objects can be exported in MDTO-compliant XML format including all TMLO metadata +7. API endpoints allow querying objects by archiefstatus and archiefactiedatum range +8. TMLO metadata is stored as first-class object metadata (not custom properties) + +## Dependencies + +- OpenRegister Register and Schema entities for TMLO configuration storage +- OpenRegister ObjectService for metadata management +- `retention-management` change for retention period calculation (complementary, not blocking) +- `edepot-transfer` change for actual e-Depot connection (uses TMLO export as input) + +## Standards & Regulations + +- **TMLO 1.2** -- Toepassingsprofiel Metadatastandaard Lokale Overheden (Nationaal Archief) +- **MDTO** -- Metadatastandaard voor Duurzaam Toegankelijke Overheidsinformatie (Rijksoverheid) +- **e-Depot** -- Nationaal Archief digital repository standards +- **GEMMA Archiefregistratiecomponent** -- Reference architecture for archival registration in municipalities +- **Archiefwet 1995** -- Dutch Archives Act +- **Selectielijst gemeenten** -- VNG retention schedule for municipal records + +## Impact + +All apps storing data in OpenRegister benefit automatically from TMLO compliance: +- **Procest** -- Process/zaak records get archival metadata +- **Pipelinq** -- Pipeline objects can be classified and retained +- **Docudesk** -- Document metadata includes TMLO fields for archival transfer +- **ZaakAfhandelApp** -- Zaak handling inherits archival compliance +- **OpenCatalogi** -- Catalog items carry proper archival metadata + +## Notes + +- This change complements `retention-management` (which handles retention period calculation) and `edepot-transfer` (which handles the actual transfer protocol). TMLO metadata provides the data model that both depend on. +- TMLO 1.2 is the current version maintained by the Nationaal Archief. The metadata model should be versioned to support future TMLO updates. +- MDTO is increasingly replacing TMLO as the primary standard. The implementation should treat MDTO as the base and TMLO as a profile/subset.