Skip to content
Open
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
83 changes: 79 additions & 4 deletions lib/Search/ObjectsProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string>
*/
private array $nameCache = [];

/**
* Constructor for the ObjectsProvider class
*
Expand All @@ -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
*/
Expand All @@ -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()

/**
Expand Down Expand Up @@ -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
*
Expand All @@ -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.
Expand Down
212 changes: 212 additions & 0 deletions openspec/changes/nextcloud-entity-relations/design.md
Original file line number Diff line number Diff line change
@@ -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
```
48 changes: 48 additions & 0 deletions openspec/changes/nextcloud-entity-relations/proposal.md
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading