Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/features.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
"summary": "Integrate OpenRegister with Nextcloud's Activity app so that all CRUD operations on Objects, Registers, and Schemas are visible in the standard Nextcloud activity stream, dashboard activity widget, and (optionally) email notifications. This gives users and administrators a clear, auditable timeline of who changed what and when, using the standard `OCP\\Activity` API (IManager, IProvider, IFilter, ActivitySettings).",
"docsUrl": "openspec/specs/activity-provider/spec.md"
},
{
"slug": "ai-mcp",
"title": "AI MCP — LLPhant Tool Bridge",
"summary": "OpenRegister exposes Model Context Protocol (MCP) tools to LLPhant-driven chat agents through two cooperating mechanisms: (1) an event-driven `ToolRegistry` that lets every installed Nextcloud app contribute LLPhant `ToolInterface` instances to the chat agent's tool-loop, and (2) an `McpProviderBridge` adapter that lifts `IMcpToolProvider` implementations (the per-app MCP plugin contract) into LLPhant function descriptors. The bridge handles the impedance mismatch between MCP's dot-namespaced tool ids and LLPhant/OpenAI/Ollama function-name constraints (no dots), and collapses JSON-Schema nullable types into the scalar strings LLPhant's `Parameter` accepts.",
"docsUrl": "openspec/specs/ai-mcp/spec.md"
},
{
"slug": "approval-workflow",
"title": "Approval Workflow",
Expand Down Expand Up @@ -71,12 +77,24 @@
"summary": "Remove the dedicated `published`/`depublished` object metadata system from OpenRegister. The RBAC `$now` dynamic variable replaces this functionality, allowing publication control via authorization rules rather than dedicated metadata columns. This eliminates a parallel publication-state mechanism that overlapped with — and frequently conflicted with — the existing RBAC time-based access controls.",
"docsUrl": "openspec/specs/deprecate-published-metadata/spec.md"
},
{
"slug": "entity-management-modals",
"title": "Entity Management Modals",
"summary": "Describes the user-facing modal dialog components that mediate create / read / update / delete and bulk operations on first-class register entities (registers, schemas, objects, agents, applications, organisations, configurations, endpoints, sources, views, webhooks, soft-deleted records, audit-trail entries, files). Every register entity in the OpenRegister UI is mutated through a small family of modal Vue components (`Edit{Entity}.vue`, `Delete{Entity}.vue`, `View{Entity}.vue`, plus per-entity bulk variants) that share a consistent open / load / submit / close / error-handling lifecycle driven by the `navigationStore` dialog state and the corresponding entity store.",
"docsUrl": "openspec/specs/entity-management-modals/spec.md"
},
{
"slug": "event-driven-architecture",
"title": "Event-Driven Architecture",
"summary": "OpenRegister implements a comprehensive event-driven architecture built on Nextcloud's `IEventDispatcher` (OCP\\EventDispatcher\\IEventDispatcher) that enables loose coupling between internal components and external systems. Every mutation across all entity types -- Objects, Registers, Schemas, Sources, Configurations, Views, Agents, Applications, Conversations, and Organisations -- dispatches a typed PHP event that can be consumed by any Nextcloud app, delivered to external systems via webhooks in CloudEvents v1.0 format, or pushed to real-time subscribers via GraphQL SSE. The architecture distinguishes between pre-mutation events (ObjectCreatingEvent, ObjectUpdatingEvent, ObjectDeletingEvent) that implement `StoppableEventInterface` to allow hooks to reject or modify operations, and post-mutation events (ObjectCreatedEvent, ObjectUpdatedEvent, ObjectDeletedEvent) that notify downstream systems after persistence is complete.",
"docsUrl": "openspec/specs/event-driven-architecture/spec.md"
},
{
"slug": "files-sidebar-tabs",
"title": "Filter Sidebar Tabs",
"summary": "Provide a consistent filter-sidebar UX across OpenRegister's main list views (Entities, Webhooks, Dashboard, Deleted). Each filter sidebar is a Vue single-file component that owns a small set of filter controls (search input, register/schema pickers, status pickers, date ranges) and communicates its state either through `update:*` events to a parent list view (Entities, Webhooks) or through the global Pinia register/schema/deleted stores plus the router query string (Dashboard, Deleted).",
"docsUrl": "openspec/specs/files-sidebar-tabs/spec.md"
},
{
"slug": "graphql-api",
"title": "GraphQL API",
Expand Down Expand Up @@ -137,6 +155,12 @@
"summary": "Auto-generate OpenAPI 3.1.0 specifications from register and schema definitions stored in OpenRegister, producing complete API documentation that covers every CRUD endpoint, query parameter, authentication scheme, and response model. The generated spec MUST be downloadable in JSON and YAML formats, serveable via an interactive Swagger UI, and MUST regenerate automatically when schemas change so that documentation never drifts from the live API surface. The generation pipeline MUST also support NL API Design Rules compliance markers for Dutch government API interoperability.",
"docsUrl": "openspec/specs/openapi-generation/spec.md"
},
{
"slug": "platform-administration-modals",
"title": "Platform Administration Modals",
"summary": "Describes the administrator-facing modal dialogs that configure OpenRegister's platform-level infrastructure (SOLR search backend, LLM provider wiring, configuration sets, collection assignments, vectorization, faceting, file management) and that drive long-running operational tasks (cache clear, index warmup, mass validation, index inspection, SOLR setup). Unlike the entity-management modals — which mutate user data via per-entity stores — these modals read and write platform settings via `/api/settings/*` REST endpoints and dispatch background operational jobs.",
"docsUrl": "openspec/specs/platform-administration-modals/spec.md"
},
{
"slug": "production-observability",
"title": "Production Observability",
Expand Down Expand Up @@ -185,12 +209,30 @@
"summary": "Implement dynamic per-record access rules based on field values (row-level security / RLS) and per-field visibility and editability rules based on user roles (field-level security / FLS). Beyond schema-level RBAC that controls access to entire object types, the system MUST support row-level security where access to individual objects depends on the object's own properties (e.g., department, classification level, owner), and field-level security where different users see different fields of the same object. Both security layers MUST be enforced consistently across REST, GraphQL, search, export, and MCP access methods, evaluated at the database query level where possible for performance, and composable with schema-level RBAC and multi-tenancy isolation.",
"docsUrl": "openspec/specs/row-field-level-security/spec.md"
},
{
"slug": "saved-search-views",
"title": "Saved Search Views",
"summary": "Lets OpenRegister users save the configuration of an object search — selected registers and schemas, free-text search terms, facet filters, and enabled facets — as a reusable, named **view** backed by `/api/views`. Views can be marked public or default, favorited per user, and re-applied to the live search from the search sidebar. This capability describes the observed frontend contract of `src/sidebars/search/SearchSideBar.vue` and the `viewsStore` it drives. It was retrofitted under ADR-003 on 2026-05-25 (cluster `fe-sidebars`); requirements capture observed behavior rather than original intent.",
"docsUrl": "openspec/specs/saved-search-views/spec.md"
},
{
"slug": "schema-hooks",
"title": "Schema Hooks",
"summary": "Schema hooks enable per-schema configuration of workflow callbacks that fire on object lifecycle events, allowing external systems to validate, enrich, transform, or reject data before or after persistence. Hooks use CloudEvents 1.0 structured content mode for payloads, support synchronous (request-response) and asynchronous (fire-and-forget) delivery modes, and provide configurable failure behavior (reject, allow, flag, queue) so administrators can balance data integrity against availability. The hook system is engine-agnostic through the `WorkflowEngineInterface` abstraction, currently supporting n8n and Windmill adapters, and integrates deeply with Nextcloud's PSR-14 event dispatcher via `StoppableEventInterface` for pre-mutation rejection.",
"docsUrl": "openspec/specs/schema-hooks/spec.md"
},
{
"slug": "search-index",
"title": "Search Index",
"summary": "OpenRegister provides full-text search, faceted browsing, and bulk re-indexing over its object store via a pluggable search backend (Solr today, Elasticsearch in parallel, with the `SearchBackendInterface` keeping the contract backend-agnostic). The `search-index` capability covers everything from the high-level `IndexService` facade down through the Solr-specific primitives (`SolrCollectionManager`, `SolrDocumentIndexer`, `SolrQueryExecutor`, `SolrFacetProcessor`, `SolrHttpClient`), the schema/document builders (`SchemaHandler`, `DocumentBuilder`, `SchemaMapper`), the bulk-indexing driver (`BulkIndexer`), the configuration plumbing (`ConfigurationHandler`), and the tenant-collection setup orchestrator (`SetupHandler`).",
"docsUrl": "openspec/specs/search-index/spec.md"
},
{
"slug": "tmlo-metadata",
"title": "tmlo-metadata",
"summary": "Foundation capability for TMLO (Toepassingsprofiel Metadatastandaard Lokale Overheden) archival metadata on OpenRegister objects. Owns the cross-cutting contract that the sibling specs (`tmlo-metadata-schema`, `tmlo-auto-populate`, `tmlo-export`, `tmlo-query-api`) compose against:",
"docsUrl": "openspec/specs/tmlo-metadata/spec.md"
},
{
"slug": "urn-resource-addressing",
"title": "URN Resource Addressing",
Expand Down
4 changes: 2 additions & 2 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -2067,7 +2067,7 @@ private function bootBuiltinIntegrationProviders($server): void
]
);
}
}
}
}//end try
}//end foreach
}//end bootBuiltinIntegrationProviders()
}//end class
124 changes: 64 additions & 60 deletions lib/BackgroundJob/BackfillCalendarLinksJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,13 @@
/**
* Constructor.
*
* @param ITimeFactory $time Time factory.
* @param CalendarLinkMapper $linkMapper Link-table mapper.
* @param ITimeFactory $time Time factory.
* @param CalendarLinkMapper $linkMapper Link-table mapper.
* @param CalendarEventService $calendarEventService Legacy X-OR-* CalDAV service.
* @param IUserManager $userManager User manager.
* @param IUserSession $userSession User session.
* @param IAppConfig $appConfig App config (for flag).
* @param LoggerInterface $logger Logger.
* @param IUserManager $userManager User manager.
* @param IUserSession $userSession User session.
* @param IAppConfig $appConfig App config (for flag).
* @param LoggerInterface $logger Logger.
*
* @return void
*/
Expand Down Expand Up @@ -109,63 +109,67 @@
$inserted = 0;
$skipped = 0;

$this->userManager->callForSeenUsers(function ($user) use (&$inserted, &$skipped): void {
try {
$this->userSession->setUser($user);
$events = $this->calendarEventService->getEventsForObject(objectUuid: '');
} catch (Throwable $e) {
// No-op: callForSeenUsers iterates regardless of CalDAV state.
return;
}

foreach ($events as $event) {
$objectUuid = (string) ($event['objectUuid'] ?? '');
$eventUid = (string) ($event['uid'] ?? '');
if ($objectUuid === '' || $eventUid === '') {
continue;
}
$this->userManager->callForSeenUsers(
function ($user) use (&$inserted, &$skipped): void {

Check failure on line 113 in lib/BackgroundJob/BackfillCalendarLinksJob.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Parameter #1 $callback of method OCP\IUserManager::callForSeenUsers() expects Closure(OCP\IUser): (bool|null), Closure(mixed): void given.
try {
$this->userSession->setUser($user);
$events = $this->calendarEventService->getEventsForObject(objectUuid: '');
} catch (Throwable $e) {
// No-op: callForSeenUsers iterates regardless of CalDAV state.
return;
}

try {
$this->linkMapper->findByObjectAndEvent(
objectUuid: $objectUuid,
eventUid: $eventUid
);
$skipped++;
continue;
} catch (DoesNotExistException $e) {
// No row yet — insert it below.
}
foreach ($events as $event) {
$objectUuid = (string) ($event['objectUuid'] ?? '');
$eventUid = (string) ($event['uid'] ?? '');
if ($objectUuid === '' || $eventUid === '') {
continue;
}

try {
$link = new CalendarLink();
$link->setObjectUuid($objectUuid);
$link->setRegisterId((int) ($event['registerId'] ?? 0));
$link->setSchemaId((int) ($event['schemaId'] ?? 0));
$link->setCalendarUri('');
$link->setCalendarId(isset($event['calendarId']) ? (int) $event['calendarId'] : null);
$link->setEventUid($eventUid);
$link->setEventUri((string) ($event['id'] ?? ''));
$link->setSummary(isset($event['summary']) ? (string) $event['summary'] : null);
$link->setLocation(isset($event['location']) ? (string) $event['location'] : null);
if (isset($event['dtstart']) === true && $event['dtstart'] !== null) {
$link->setDtstart(new DateTime((string) $event['dtstart']));
}
if (isset($event['dtend']) === true && $event['dtend'] !== null) {
$link->setDtend(new DateTime((string) $event['dtend']));
}
$link->setLinkedBy('system:backfill');
$link->setLinkedAt(new DateTime());
$link->setTaggedWithXor(true);

$this->linkMapper->insert(entity: $link);
$inserted++;
} catch (Throwable $e) {
$this->logger->warning(
'BackfillCalendarLinksJob: failed to insert link for event '.$eventUid.': '.$e->getMessage()
);
try {
$this->linkMapper->findByObjectAndEvent(
objectUuid: $objectUuid,
eventUid: $eventUid
);
$skipped++;
continue;
} catch (DoesNotExistException $e) {
// No row yet — insert it below.
}

try {
$link = new CalendarLink();
$link->setObjectUuid($objectUuid);
$link->setRegisterId((int) ($event['registerId'] ?? 0));
$link->setSchemaId((int) ($event['schemaId'] ?? 0));
$link->setCalendarUri('');
$link->setCalendarId(isset($event['calendarId']) ? (int) $event['calendarId'] : null);
$link->setEventUid($eventUid);
$link->setEventUri((string) ($event['id'] ?? ''));
$link->setSummary(isset($event['summary']) ? (string) $event['summary'] : null);
$link->setLocation(isset($event['location']) ? (string) $event['location'] : null);
if (isset($event['dtstart']) === true && $event['dtstart'] !== null) {
$link->setDtstart(new DateTime((string) $event['dtstart']));
}

if (isset($event['dtend']) === true && $event['dtend'] !== null) {
$link->setDtend(new DateTime((string) $event['dtend']));
}

$link->setLinkedBy('system:backfill');
$link->setLinkedAt(new DateTime());
$link->setTaggedWithXor(true);

$this->linkMapper->insert(entity: $link);
$inserted++;
} catch (Throwable $e) {
$this->logger->warning(
'BackfillCalendarLinksJob: failed to insert link for event '.$eventUid.': '.$e->getMessage()
);
}//end try
}//end foreach
}
}//end foreach
});
);

$this->logger->info('BackfillCalendarLinksJob finished. Inserted='.$inserted.', skipped='.$skipped);
}//end run()
Expand Down
3 changes: 2 additions & 1 deletion lib/Controller/CalendarEventsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ public function listCalendarEvents(string $calendarUri): JSONResponse
$after = null;
}
}

if ($after === null) {
// Default: now − 1 week.
$after = (new DateTime())->modify('-1 week');
Expand All @@ -389,7 +390,7 @@ public function listCalendarEvents(string $calendarUri): JSONResponse
return new JSONResponse(['results' => $events, 'total' => count($events)]);
} catch (Exception $e) {
return new JSONResponse(['error' => $e->getMessage()], 500);
}
}//end try
}//end listCalendarEvents()

/**
Expand Down
2 changes: 1 addition & 1 deletion lib/Controller/ContactsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ public function create(string $register, string $schema, string $id): JSONRespon

$data = $this->request->getParams();

$hasLinkData = (empty($data['addressbookId']) === false && empty($data['contactUri']) === false);
$hasLinkData = (empty($data['addressbookId']) === false && empty($data['contactUri']) === false);
// Accept `displayName` (Tier-2 dialog field) alongside `fullName`.
$hasCreateData = (empty($data['fullName']) === false || empty($data['displayName']) === false);

Expand Down
1 change: 0 additions & 1 deletion lib/Controller/EmailLinksController.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
*/
class EmailLinksController extends Controller
{

/**
* Constructor.
*
Expand Down
12 changes: 7 additions & 5 deletions lib/Controller/FlowLinksController.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,13 @@ public function index(string $register, string $schema, string $id): JSONRespons

$results = $this->flowLinkService->getLinkedOperations($object->getUuid());

return new JSONResponse([
'results' => $results,
'total' => count($results),
'isAdmin' => $this->flowLinkService->isCurrentUserAdmin(),
]);
return new JSONResponse(
[
'results' => $results,
'total' => count($results),
'isAdmin' => $this->flowLinkService->isCurrentUserAdmin(),
]
);
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Object not found'], 404);
} catch (Exception $e) {
Expand Down
Loading
Loading