From f57676f76a93b7e7725d57d66d9df98a95ecd29a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 5 Mar 2026 18:14:29 -0500 Subject: [PATCH] WIP --- open-api/immich-openapi-specs.json | 751 +++++++++--------- plugins/manifest.json | 74 +- plugins/src/index.d.ts | 9 +- plugins/src/index.ts | 190 +++-- .../src/controllers/plugin.controller.spec.ts | 56 ++ server/src/controllers/plugin.controller.ts | 23 +- .../controllers/workflow.controller.spec.ts | 115 +++ server/src/controllers/workflow.controller.ts | 64 +- server/src/database.ts | 76 +- server/src/decorators.ts | 4 + server/src/dtos/plugin-manifest.dto.ts | 77 +- server/src/dtos/plugin.dto.ts | 87 +- server/src/dtos/workflow.dto.ts | 163 ++-- server/src/enum.ts | 21 +- server/src/plugins.ts | 17 - server/src/queries/plugin.repository.sql | 179 ++--- server/src/queries/workflow.repository.sql | 108 ++- server/src/repositories/logging.repository.ts | 4 + server/src/repositories/plugin.repository.ts | 270 ++++--- server/src/repositories/storage.repository.ts | 37 +- .../src/repositories/workflow.repository.ts | 253 +++--- server/src/schema/index.ts | 18 +- .../1772815612034-UpdateWorkflowTables.ts | 72 ++ server/src/schema/tables/asset-exif.table.ts | 2 +- .../src/schema/tables/plugin-method.table.ts | 29 + server/src/schema/tables/plugin.table.ts | 71 +- .../src/schema/tables/workflow-step.table.ts | 26 + server/src/schema/tables/workflow.table.ts | 51 +- server/src/services/index.ts | 2 + server/src/services/plugin-host.functions.ts | 120 --- server/src/services/plugin.service.ts | 311 +------- .../services/workflow-execution.service.ts | 241 ++++++ server/src/services/workflow.service.ts | 165 ++-- server/src/types.ts | 142 +++- server/src/types/plugin-schema.types.ts | 35 - server/src/utils/workflow.ts | 31 + server/test/medium.factory.ts | 6 +- .../specs/services/plugin.service.spec.ts | 306 ++++--- .../specs/services/workflow.service.spec.ts | 677 +--------------- .../workflow/workflow-core-plugin.spec.ts | 188 +++++ .../repositories/storage.repository.mock.ts | 3 +- server/test/utils.ts | 2 +- 42 files changed, 2391 insertions(+), 2685 deletions(-) create mode 100644 server/src/controllers/plugin.controller.spec.ts create mode 100644 server/src/controllers/workflow.controller.spec.ts delete mode 100644 server/src/plugins.ts create mode 100644 server/src/schema/migrations/1772815612034-UpdateWorkflowTables.ts create mode 100644 server/src/schema/tables/plugin-method.table.ts create mode 100644 server/src/schema/tables/workflow-step.table.ts delete mode 100644 server/src/services/plugin-host.functions.ts create mode 100644 server/src/services/workflow-execution.service.ts delete mode 100644 server/src/types/plugin-schema.types.ts create mode 100644 server/src/utils/workflow.ts create mode 100644 server/test/medium/specs/workflow/workflow-core-plugin.spec.ts diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 38e1fe8e01..709016b619 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8660,8 +8660,60 @@ "/plugins": { "get": { "description": "Retrieve a list of plugins available to the authenticated user.", - "operationId": "getPlugins", - "parameters": [], + "operationId": "searchPlugins", + "parameters": [ + { + "name": "description", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "enabled", + "required": false, + "in": "query", + "description": "Whether the plugin is enabled", + "schema": { + "type": "boolean" + } + }, + { + "name": "id", + "required": false, + "in": "query", + "description": "Plugin ID", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "name", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -8694,65 +8746,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "plugin.read", - "x-immich-state": "Alpha" - } - }, - "/plugins/triggers": { - "get": { - "description": "Retrieve a list of all available plugin triggers.", - "operationId": "getPluginTriggers", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/PluginTriggerResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "summary": "List all plugin triggers", - "tags": [ - "Plugins" - ], - "x-immich-history": [ - { - "version": "v2.3.0", - "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" - } - ], - "x-immich-permission": "plugin.read", - "x-immich-state": "Alpha" + "x-immich-permission": "plugin.read" } }, "/plugins/{id}": { @@ -8799,16 +8797,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "plugin.read", - "x-immich-state": "Alpha" + "x-immich-permission": "plugin.read" } }, "/queues": { @@ -14910,8 +14903,55 @@ "/workflows": { "get": { "description": "Retrieve a list of workflows available to the authenticated user.", - "operationId": "getWorkflows", - "parameters": [], + "operationId": "searchWorkflows", + "parameters": [ + { + "name": "description", + "required": false, + "in": "query", + "description": "Workflow description", + "schema": { + "type": "string" + } + }, + { + "name": "enabled", + "required": false, + "in": "query", + "description": "Workflow enabled", + "schema": { + "type": "boolean" + } + }, + { + "name": "id", + "required": false, + "in": "query", + "description": "Workflow ID", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "name", + "required": false, + "in": "query", + "description": "Workflow name", + "schema": { + "type": "string" + } + }, + { + "name": "trigger", + "required": false, + "in": "query", + "description": "Workflow trigger type", + "schema": { + "$ref": "#/components/schemas/PluginTriggerType" + } + } + ], "responses": { "200": { "content": { @@ -14944,16 +14984,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.read", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.read" }, "post": { "description": "Create a new workflow, the workflow can also be created with empty filters and actions.", @@ -14998,16 +15033,54 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.create", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.create" + } + }, + "/workflows/triggers": { + "get": { + "description": "Retrieve a list of all available workflow triggers.", + "operationId": "getWorkflowTriggers", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkflowTriggerResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all workflow triggers", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ] } }, "/workflows/{id}": { @@ -15047,16 +15120,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.delete", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.delete" }, "get": { "description": "Retrieve information about a specific workflow by its ID.", @@ -15101,16 +15169,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.read", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.read" }, "put": { "description": "Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.", @@ -15165,16 +15228,130 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.update", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.update" + } + }, + "/workflows/{id}/steps": { + "get": { + "description": "Retrieve the steps of a specific workflow by its ID.", + "operationId": "getWorkflowSteps", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkflowStepResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve workflow steps", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ], + "x-immich-permission": "workflow.read" + }, + "put": { + "description": "Update the steps of a specific workflow by its ID. This endpoint can be used to update the step configuration, order, etc.", + "operationId": "replaceWorkflowSteps", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkflowStepResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update a workflow steps", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ], + "x-immich-permission": "workflow.update" } } }, @@ -18207,7 +18384,7 @@ "VersionCheck", "OcrQueueAll", "Ocr", - "WorkflowRun" + "WorkflowAssetCreate" ], "type": "string" }, @@ -20174,18 +20351,29 @@ ], "type": "object" }, - "PluginActionResponseDto": { + "PluginMethodResponseDto": { "properties": { + "PluginTypes": { + "description": "Supported types", + "items": { + "enum": [ + "AssetV1", + "AssetPersonV1" + ], + "type": "string" + }, + "type": "array" + }, "description": { - "description": "Action description", + "description": "Description", "type": "string" }, "id": { - "description": "Action ID", + "description": "ID", "type": "string" }, - "methodName": { - "description": "Method name", + "name": { + "description": "Name", "type": "string" }, "pluginId": { @@ -20193,97 +20381,28 @@ "type": "string" }, "schema": { - "description": "Action schema", + "description": "Schema", "nullable": true, "type": "object" }, - "supportedContexts": { - "description": "Supported contexts", - "items": { - "$ref": "#/components/schemas/PluginContextType" - }, - "type": "array" - }, "title": { - "description": "Action title", + "description": "Title", "type": "string" } }, "required": [ + "PluginTypes", "description", "id", - "methodName", + "name", "pluginId", "schema", - "supportedContexts", - "title" - ], - "type": "object" - }, - "PluginContextType": { - "description": "Context type", - "enum": [ - "asset", - "album", - "person" - ], - "type": "string" - }, - "PluginFilterResponseDto": { - "properties": { - "description": { - "description": "Filter description", - "type": "string" - }, - "id": { - "description": "Filter ID", - "type": "string" - }, - "methodName": { - "description": "Method name", - "type": "string" - }, - "pluginId": { - "description": "Plugin ID", - "type": "string" - }, - "schema": { - "description": "Filter schema", - "nullable": true, - "type": "object" - }, - "supportedContexts": { - "description": "Supported contexts", - "items": { - "$ref": "#/components/schemas/PluginContextType" - }, - "type": "array" - }, - "title": { - "description": "Filter title", - "type": "string" - } - }, - "required": [ - "description", - "id", - "methodName", - "pluginId", - "schema", - "supportedContexts", "title" ], "type": "object" }, "PluginResponseDto": { "properties": { - "actions": { - "description": "Plugin actions", - "items": { - "$ref": "#/components/schemas/PluginActionResponseDto" - }, - "type": "array" - }, "author": { "description": "Plugin author", "type": "string" @@ -20296,17 +20415,17 @@ "description": "Plugin description", "type": "string" }, - "filters": { - "description": "Plugin filters", - "items": { - "$ref": "#/components/schemas/PluginFilterResponseDto" - }, - "type": "array" - }, "id": { "description": "Plugin ID", "type": "string" }, + "methods": { + "description": "Plugin methods", + "items": { + "$ref": "#/components/schemas/PluginMethodResponseDto" + }, + "type": "array" + }, "name": { "description": "Plugin name", "type": "string" @@ -20325,12 +20444,11 @@ } }, "required": [ - "actions", "author", "createdAt", "description", - "filters", "id", + "methods", "name", "title", "updatedAt", @@ -20338,33 +20456,8 @@ ], "type": "object" }, - "PluginTriggerResponseDto": { - "properties": { - "contextType": { - "allOf": [ - { - "$ref": "#/components/schemas/PluginContextType" - } - ], - "description": "Context type" - }, - "type": { - "allOf": [ - { - "$ref": "#/components/schemas/PluginTriggerType" - } - ], - "description": "Trigger type" - } - }, - "required": [ - "contextType", - "type" - ], - "type": "object" - }, "PluginTriggerType": { - "description": "Trigger type", + "description": "Workflow trigger type", "enum": [ "AssetCreate", "PersonRecognized" @@ -26008,65 +26101,8 @@ ], "type": "string" }, - "WorkflowActionItemDto": { - "properties": { - "actionConfig": { - "description": "Action configuration", - "type": "object" - }, - "pluginActionId": { - "description": "Plugin action ID", - "format": "uuid", - "type": "string" - } - }, - "required": [ - "pluginActionId" - ], - "type": "object" - }, - "WorkflowActionResponseDto": { - "properties": { - "actionConfig": { - "description": "Action configuration", - "nullable": true, - "type": "object" - }, - "id": { - "description": "Action ID", - "type": "string" - }, - "order": { - "description": "Action order", - "type": "number" - }, - "pluginActionId": { - "description": "Plugin action ID", - "type": "string" - }, - "workflowId": { - "description": "Workflow ID", - "type": "string" - } - }, - "required": [ - "actionConfig", - "id", - "order", - "pluginActionId", - "workflowId" - ], - "type": "object" - }, "WorkflowCreateDto": { "properties": { - "actions": { - "description": "Workflow actions", - "items": { - "$ref": "#/components/schemas/WorkflowActionItemDto" - }, - "type": "array" - }, "description": { "description": "Workflow description", "type": "string" @@ -26075,18 +26111,11 @@ "description": "Workflow enabled", "type": "boolean" }, - "filters": { - "description": "Workflow filters", - "items": { - "$ref": "#/components/schemas/WorkflowFilterItemDto" - }, - "type": "array" - }, "name": { "description": "Workflow name", "type": "string" }, - "triggerType": { + "trigger": { "allOf": [ { "$ref": "#/components/schemas/PluginTriggerType" @@ -26096,91 +26125,26 @@ } }, "required": [ - "actions", - "filters", "name", - "triggerType" - ], - "type": "object" - }, - "WorkflowFilterItemDto": { - "properties": { - "filterConfig": { - "description": "Filter configuration", - "type": "object" - }, - "pluginFilterId": { - "description": "Plugin filter ID", - "format": "uuid", - "type": "string" - } - }, - "required": [ - "pluginFilterId" - ], - "type": "object" - }, - "WorkflowFilterResponseDto": { - "properties": { - "filterConfig": { - "description": "Filter configuration", - "nullable": true, - "type": "object" - }, - "id": { - "description": "Filter ID", - "type": "string" - }, - "order": { - "description": "Filter order", - "type": "number" - }, - "pluginFilterId": { - "description": "Plugin filter ID", - "type": "string" - }, - "workflowId": { - "description": "Workflow ID", - "type": "string" - } - }, - "required": [ - "filterConfig", - "id", - "order", - "pluginFilterId", - "workflowId" + "trigger" ], "type": "object" }, "WorkflowResponseDto": { "properties": { - "actions": { - "description": "Workflow actions", - "items": { - "$ref": "#/components/schemas/WorkflowActionResponseDto" - }, - "type": "array" - }, "createdAt": { "description": "Creation date", "type": "string" }, "description": { "description": "Workflow description", + "nullable": true, "type": "string" }, "enabled": { "description": "Workflow enabled", "type": "boolean" }, - "filters": { - "description": "Workflow filters", - "items": { - "$ref": "#/components/schemas/WorkflowFilterResponseDto" - }, - "type": "array" - }, "id": { "description": "Workflow ID", "type": "string" @@ -26194,7 +26158,14 @@ "description": "Owner user ID", "type": "string" }, - "triggerType": { + "steps": { + "description": "Workflow steps", + "items": { + "$ref": "#/components/schemas/WorkflowStepResponseDto" + }, + "type": "array" + }, + "trigger": { "allOf": [ { "$ref": "#/components/schemas/PluginTriggerType" @@ -26204,27 +26175,84 @@ } }, "required": [ - "actions", "createdAt", "description", "enabled", - "filters", "id", "name", "ownerId", - "triggerType" + "steps", + "trigger" ], "type": "object" }, - "WorkflowUpdateDto": { + "WorkflowStepResponseDto": { "properties": { - "actions": { - "description": "Workflow actions", + "config": { + "description": "Method configuration", + "nullable": true, + "type": "object" + }, + "id": { + "description": "Step ID", + "type": "string" + }, + "order": { + "description": "Step order", + "type": "number" + }, + "pluginMethodId": { + "description": "Plugin method ID", + "type": "string" + }, + "workflowId": { + "description": "Workflow ID", + "type": "string" + } + }, + "required": [ + "config", + "id", + "order", + "pluginMethodId", + "workflowId" + ], + "type": "object" + }, + "WorkflowTriggerResponseDto": { + "properties": { + "trigger": { + "allOf": [ + { + "$ref": "#/components/schemas/PluginTriggerType" + } + ], + "description": "Trigger type" + }, + "types": { + "description": "Workflow types", "items": { - "$ref": "#/components/schemas/WorkflowActionItemDto" + "$ref": "#/components/schemas/WorkflowType" }, "type": "array" - }, + } + }, + "required": [ + "trigger", + "types" + ], + "type": "object" + }, + "WorkflowType": { + "description": "Workflow types", + "enum": [ + "AssetV1", + "AssetPersonV1" + ], + "type": "string" + }, + "WorkflowUpdateDto": { + "properties": { "description": { "description": "Workflow description", "type": "string" @@ -26233,18 +26261,11 @@ "description": "Workflow enabled", "type": "boolean" }, - "filters": { - "description": "Workflow filters", - "items": { - "$ref": "#/components/schemas/WorkflowFilterItemDto" - }, - "type": "array" - }, "name": { "description": "Workflow name", "type": "string" }, - "triggerType": { + "trigger": { "allOf": [ { "$ref": "#/components/schemas/PluginTriggerType" diff --git a/plugins/manifest.json b/plugins/manifest.json index 4d2de275ca..6a3c2f145b 100644 --- a/plugins/manifest.json +++ b/plugins/manifest.json @@ -4,17 +4,13 @@ "title": "Immich Core", "description": "Core workflow capabilities for Immich", "author": "Immich Team", - "wasm": { - "path": "dist/plugin.wasm" - }, - "filters": [ + "wasmPath": "dist/plugin.wasm", + "methods": [ { - "methodName": "filterFileName", + "name": "filterFileName", "title": "Filter by filename", "description": "Filter assets by filename pattern using text matching or regular expressions", - "supportedContexts": [ - "asset" - ], + "types": ["AssetV1"], "schema": { "type": "object", "properties": { @@ -26,11 +22,7 @@ "matchType": { "type": "string", "title": "Match type", - "enum": [ - "contains", - "regex", - "exact" - ], + "enum": ["contains", "regex", "exact"], "default": "contains", "description": "Type of pattern matching to perform" }, @@ -40,18 +32,14 @@ "description": "Whether matching should be case-sensitive" } }, - "required": [ - "pattern" - ] + "required": ["pattern"] } }, { - "methodName": "filterFileType", + "name": "filterFileType", "title": "Filter by file type", "description": "Filter assets by file type", - "supportedContexts": [ - "asset" - ], + "types": ["AssetV1"], "schema": { "type": "object", "properties": { @@ -60,26 +48,19 @@ "title": "File types", "items": { "type": "string", - "enum": [ - "image", - "video" - ] + "enum": ["image", "video"] }, "description": "Allowed file types" } }, - "required": [ - "fileTypes" - ] + "required": ["fileTypes"] } }, { - "methodName": "filterPerson", + "name": "filterPerson", "title": "Filter by person", "description": "Filter by detected person", - "supportedContexts": [ - "person" - ], + "types": ["AssetV1"], "schema": { "type": "object", "properties": { @@ -98,29 +79,21 @@ "description": "Match any name (true) or require all names (false)" } }, - "required": [ - "personIds" - ] + "required": ["personIds"] } - } - ], - "actions": [ + }, { - "methodName": "actionArchive", + "name": "assetArchive", "title": "Archive", "description": "Move the asset to archive", - "supportedContexts": [ - "asset" - ], + "types": ["AssetV1"], "schema": {} }, { - "methodName": "actionFavorite", + "name": "assetFavorite", "title": "Favorite", "description": "Mark the asset as favorite or unfavorite", - "supportedContexts": [ - "asset" - ], + "types": ["AssetV1"], "schema": { "type": "object", "properties": { @@ -133,13 +106,10 @@ } }, { - "methodName": "actionAddToAlbum", + "name": "assetAddToAlbum", "title": "Add to Album", "description": "Add the item to a specified album", - "supportedContexts": [ - "asset", - "person" - ], + "types": ["AssetV1"], "schema": { "type": "object", "properties": { @@ -150,9 +120,7 @@ "subType": "album-picker" } }, - "required": [ - "albumId" - ] + "required": ["albumId"] } } ] diff --git a/plugins/src/index.d.ts b/plugins/src/index.d.ts index 7f805aafe6..1facf4a6d8 100644 --- a/plugins/src/index.d.ts +++ b/plugins/src/index.d.ts @@ -1,12 +1,13 @@ declare module 'main' { - export function filterFileName(): I32; - export function actionAddToAlbum(): I32; - export function actionArchive(): I32; + export function assetFileFilter(): I32; + export function assetArchive(): I32; + export function assetFavorite(): I32; + export function assetLock(): I32; + export function assetTrash(): I32; } declare module 'extism:host' { interface user { - updateAsset(ptr: PTR): I32; addAssetToAlbum(ptr: PTR): I32; } } diff --git a/plugins/src/index.ts b/plugins/src/index.ts index 9566c02cd8..13d597f6eb 100644 --- a/plugins/src/index.ts +++ b/plugins/src/index.ts @@ -1,71 +1,135 @@ -const { updateAsset, addAssetToAlbum } = Host.getFunctions(); +const { addAssetToAlbum } = Host.getFunctions(); -function parseInput() { - return JSON.parse(Host.inputString()); +type WorkflowInput< + TConfig = Record, + TData = Record +> = { + trigger: string; + type: string; + data: TData; + config: TConfig; + workflow: { + id: string; + stepId: string; + }; +}; + +type WorkflowOutput = { + workflow?: { + /** stop the workflow */ + continue?: boolean; + }; + changes?: Partial>; + /** data to be passed to the next workflow step */ + data?: Record; +}; + +type AssetFileFilterConfig = { + pattern: string; + matchType?: 'contains' | 'exact' | 'regex'; + caseSensitive?: boolean; +}; + +const wrapper = ( + fn: (payload: WorkflowInput) => WorkflowOutput | undefined +) => { + const input = Host.inputString(); + const event = JSON.parse(input) as WorkflowInput; + + console.log(`Event trigger: ${event.trigger}`); + console.log(`Event type: ${event.type}`); + console.log(`Event data: ${JSON.stringify(event.data)}`); + console.log(`Event config: ${JSON.stringify(event.config)}`); + + const response = fn(event) ?? {}; + + console.log(`Output workflow: ${JSON.stringify(response.workflow)}`); + console.log(`Output changes: ${JSON.stringify(response.changes)}`); + console.log(`Output data: ${JSON.stringify(response.data)}`); + + const output = JSON.stringify(response); + Host.outputString(output); +}; + +export function assetFileFilter() { + return wrapper(({ data, config }) => { + const { + pattern, + matchType = 'contains', + caseSensitive = false, + } = config as AssetFileFilterConfig; + + const { asset } = data as { + asset: { originalFileName: string; fileName: string }; + }; + + const fileName = asset.originalFileName || asset.fileName || ''; + const searchName = caseSensitive ? fileName : fileName.toLowerCase(); + const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); + + if (matchType === 'exact') { + return { workflow: { continue: searchName === searchPattern } }; + } + + if (matchType === 'regex') { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(searchPattern, flags); + return { workflow: { continue: regex.test(fileName) } }; + } + + return { workflow: { continue: searchName.includes(searchPattern) } }; + }); } -function returnOutput(output: any) { - Host.outputString(JSON.stringify(output)); - return 0; -} - -export function filterFileName() { - const input = parseInput(); - const { data, config } = input; - const { pattern, matchType = 'contains', caseSensitive = false } = config; - - const fileName = data.asset.originalFileName || data.asset.fileName || ''; - const searchName = caseSensitive ? fileName : fileName.toLowerCase(); - const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); - - let passed = false; - - if (matchType === 'exact') { - passed = searchName === searchPattern; - } else if (matchType === 'regex') { - const flags = caseSensitive ? '' : 'i'; - const regex = new RegExp(searchPattern, flags); - passed = regex.test(fileName); - } else { - // contains - passed = searchName.includes(searchPattern); - } - - return returnOutput({ passed }); -} - -export function actionAddToAlbum() { - const input = parseInput(); - const { authToken, config, data } = input; - const { albumId } = config; - - const ptr = Memory.fromString( - JSON.stringify({ - authToken, - assetId: data.asset.id, - albumId: albumId, - }) +export const assetArchive = () => { + wrapper<{ inverse?: boolean }, { asset: { visibility: string } }>( + ({ config, data }) => { + const target = config.inverse ? 'timeline' : 'archive'; + if (target !== data.asset.visibility) { + return { + changes: { + asset: { visibility: target }, + }, + }; + } + } ); +}; - addAssetToAlbum(ptr.offset); - ptr.free(); - - return returnOutput({ success: true }); -} - -export function actionArchive() { - const input = parseInput(); - const { authToken, data } = input; - const ptr = Memory.fromString( - JSON.stringify({ - authToken, - id: data.asset.id, - visibility: 'archive', - }) +export const assetFavorite = () => { + wrapper<{ inverse?: boolean }, { asset: { isFavorite: boolean } }>( + ({ config, data }) => { + const target = config.inverse ? false : true; + if (target !== data.asset.isFavorite) { + return { + changes: { + asset: { isFavorite: target }, + }, + }; + } + } ); +}; - updateAsset(ptr.offset); - ptr.free(); - - return returnOutput({ success: true }); +export function assetLock() { + return wrapper(() => ({ changes: { asset: { visibility: 'locked' } } })); } + +export function assetTrash() { + return wrapper(() => ({ + changes: { + asset: { deletedAt: new Date().toISOString(), status: 'trashed' }, + }, + })); +} + +// export function actionAddToAlbum() { +// return wrapper(() => { +// const albumId = '123'; +// const assetId = '123'; +// const ptr = Memory.fromString(JSON.stringify({ assetId, albumId })); +// addAssetToAlbum(ptr.offset); +// ptr.free(); +// return { data: { pleaseNot: 'happening to me' } }; +// }); +// } diff --git a/server/src/controllers/plugin.controller.spec.ts b/server/src/controllers/plugin.controller.spec.ts new file mode 100644 index 0000000000..f9a9af5897 --- /dev/null +++ b/server/src/controllers/plugin.controller.spec.ts @@ -0,0 +1,56 @@ +import { PluginController } from 'src/controllers/plugin.controller'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginService } from 'src/services/plugin.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(PluginController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(PluginService); + + beforeAll(async () => { + ctx = await controllerSetup(PluginController, [ + { provide: PluginService, useValue: service }, + { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /plugins', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/plugins'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/plugins`) + .query({ id: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); + + describe('GET /plugins/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/plugins/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/plugins/invalid`) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); +}); diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts index 52c833e93d..7b2920488f 100644 --- a/server/src/controllers/plugin.controller.ts +++ b/server/src/controllers/plugin.controller.ts @@ -1,7 +1,7 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; -import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto'; +import { PluginResponseDto, PluginSearchDto } from 'src/dtos/plugin.dto'; import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { PluginService } from 'src/services/plugin.service'; @@ -12,26 +12,15 @@ import { UUIDParamDto } from 'src/validation'; export class PluginController { constructor(private service: PluginService) {} - @Get('triggers') - @Authenticated({ permission: Permission.PluginRead }) - @Endpoint({ - summary: 'List all plugin triggers', - description: 'Retrieve a list of all available plugin triggers.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), - }) - getPluginTriggers(): PluginTriggerResponseDto[] { - return this.service.getTriggers(); - } - @Get() @Authenticated({ permission: Permission.PluginRead }) @Endpoint({ summary: 'List all plugins', description: 'Retrieve a list of plugins available to the authenticated user.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) - getPlugins(): Promise { - return this.service.getAll(); + searchPlugins(@Query() dto: PluginSearchDto): Promise { + return this.service.search(dto); } @Get(':id') @@ -39,7 +28,7 @@ export class PluginController { @Endpoint({ summary: 'Retrieve a plugin', description: 'Retrieve information about a specific plugin by its ID.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) getPlugin(@Param() { id }: UUIDParamDto): Promise { return this.service.get(id); diff --git a/server/src/controllers/workflow.controller.spec.ts b/server/src/controllers/workflow.controller.spec.ts new file mode 100644 index 0000000000..a38753f2e6 --- /dev/null +++ b/server/src/controllers/workflow.controller.spec.ts @@ -0,0 +1,115 @@ +import { WorkflowController } from 'src/controllers/workflow.controller'; +import { WorkflowTrigger } from 'src/enum'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { WorkflowService } from 'src/services/workflow.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(WorkflowController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(WorkflowService); + + beforeAll(async () => { + ctx = await controllerSetup(WorkflowController, [ + { provide: WorkflowService, useValue: service }, + { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /workflows', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/workflows').send({}); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require a valid trigger`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post(`/workflows`) + .send({ trigger: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest( + expect.arrayContaining([expect.stringContaining('trigger must be one of the following values')]), + ), + ); + }); + + it(`should require a valid enabled value`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post(`/workflows`) + .send({ enabled: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest(expect.arrayContaining([expect.stringContaining('enabled must be a boolean')])), + ); + }); + + it(`should require a name`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post(`/workflows`) + .send({ trigger: WorkflowTrigger.AssetCreate }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest(expect.arrayContaining([expect.stringContaining('name must be a string')])), + ); + }); + }); + + describe('GET /workflows', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/workflows'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/workflows`) + .query({ id: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); + + describe('GET /workflows/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/workflows/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/workflows/invalid`) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); + + describe('PUT /workflows/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/workflows/${factory.uuid()}`).send({}); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/workflows/invalid`) + .set('Authorization', `Bearer token`) + .send({}); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); +}); diff --git a/server/src/controllers/workflow.controller.ts b/server/src/controllers/workflow.controller.ts index e07b6443f4..5bc939245b 100644 --- a/server/src/controllers/workflow.controller.ts +++ b/server/src/controllers/workflow.controller.ts @@ -1,8 +1,16 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto'; +import { + WorkflowCreateDto, + WorkflowResponseDto, + WorkflowSearchDto, + WorkflowStepDto, + WorkflowStepResponseDto, + WorkflowTriggerResponseDto, + WorkflowUpdateDto, +} from 'src/dtos/workflow.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { WorkflowService } from 'src/services/workflow.service'; @@ -18,7 +26,7 @@ export class WorkflowController { @Endpoint({ summary: 'Create a workflow', description: 'Create a new workflow, the workflow can also be created with empty filters and actions.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise { return this.service.create(auth, dto); @@ -29,10 +37,21 @@ export class WorkflowController { @Endpoint({ summary: 'List all workflows', description: 'Retrieve a list of workflows available to the authenticated user.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) - getWorkflows(@Auth() auth: AuthDto): Promise { - return this.service.getAll(auth); + searchWorkflows(@Auth() auth: AuthDto, @Query() dto: WorkflowSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Get('triggers') + @Authenticated({ permission: false }) + @Endpoint({ + summary: 'List all workflow triggers', + description: 'Retrieve a list of all available workflow triggers.', + history: HistoryBuilder.v3(), + }) + getWorkflowTriggers(): WorkflowTriggerResponseDto[] { + return this.service.getTriggers(); } @Get(':id') @@ -40,7 +59,7 @@ export class WorkflowController { @Endpoint({ summary: 'Retrieve a workflow', description: 'Retrieve information about a specific workflow by its ID.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); @@ -52,7 +71,7 @@ export class WorkflowController { summary: 'Update a workflow', description: 'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) updateWorkflow( @Auth() auth: AuthDto, @@ -68,9 +87,36 @@ export class WorkflowController { @Endpoint({ summary: 'Delete a workflow', description: 'Delete a workflow by its ID.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } + + @Get(':id/steps') + @Authenticated({ permission: Permission.WorkflowRead }) + @Endpoint({ + summary: 'Retrieve workflow steps', + description: 'Retrieve the steps of a specific workflow by its ID.', + history: HistoryBuilder.v3(), + }) + getWorkflowSteps(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getSteps(auth, id); + } + + @Put(':id/steps') + @Authenticated({ permission: Permission.WorkflowUpdate }) + @Endpoint({ + summary: 'Update a workflow steps', + description: + 'Update the steps of a specific workflow by its ID. This endpoint can be used to update the step configuration, order, etc.', + history: HistoryBuilder.v3(), + }) + replaceWorkflowSteps( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: WorkflowStepDto[], + ): Promise { + return this.service.replaceSteps(auth, id, dto); + } } diff --git a/server/src/database.ts b/server/src/database.ts index ec614df9e0..8dceb1dc69 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -7,8 +7,6 @@ import { AssetVisibility, MemoryType, Permission, - PluginContext, - PluginTriggerType, SharedLinkType, SourceType, UserAvatarColor, @@ -16,10 +14,11 @@ import { } from 'src/enum'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; -import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; -import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; +import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; +import { PluginTable } from 'src/schema/tables/plugin.table'; +import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; import { UserMetadataItem } from 'src/types'; -import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types'; export type AuthUser = { id: string; @@ -277,43 +276,9 @@ export type AssetFace = { }; export type Plugin = Selectable; - -export type PluginFilter = Selectable & { - methodName: string; - title: string; - description: string; - supportedContexts: PluginContext[]; - schema: JSONSchema | null; -}; - -export type PluginAction = Selectable & { - methodName: string; - title: string; - description: string; - supportedContexts: PluginContext[]; - schema: JSONSchema | null; -}; - -export type Workflow = Selectable & { - triggerType: PluginTriggerType; - name: string | null; - description: string; - enabled: boolean; -}; - -export type WorkflowFilter = Selectable & { - workflowId: string; - pluginFilterId: string; - filterConfig: FilterConfig | null; - order: number; -}; - -export type WorkflowAction = Selectable & { - workflowId: string; - pluginActionId: string; - actionConfig: ActionConfig | null; - order: number; -}; +export type PluginMethod = Selectable; +export type Workflow = Selectable; +export type WorkflowStep = Selectable; const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; const userWithPrefixColumns = [ @@ -345,6 +310,32 @@ export const columns = { 'asset.width', 'asset.height', ], + workflowAssetV1: [ + 'asset.id', + 'asset.ownerId', + 'asset.stackId', + 'asset.livePhotoVideoId', + 'asset.libraryId', + 'asset.duplicateId', + 'asset.createdAt', + 'asset.updatedAt', + 'asset.deletedAt', + 'asset.fileCreatedAt', + 'asset.fileModifiedAt', + 'asset.localDateTime', + 'asset.type', + 'asset.status', + 'asset.visibility', + 'asset.duration', + 'asset.checksum', + 'asset.originalPath', + 'asset.originalFileName', + 'asset.isOffline', + 'asset.isFavorite', + 'asset.isExternal', + 'asset.isEdited', + 'asset.isFavorite', + ], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'], assetFilesForThumbnail: [ 'asset_file.id', @@ -483,7 +474,6 @@ export const columns = { 'plugin.description as description', 'plugin.author as author', 'plugin.version as version', - 'plugin.wasmPath as wasmPath', 'plugin.createdAt as createdAt', 'plugin.updatedAt as updatedAt', ], diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 695adb4a36..84be9920d4 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -210,6 +210,10 @@ export class HistoryBuilder { private hasDeprecated = false; private items: HistoryEntry[] = []; + static v3() { + return new HistoryBuilder().added('v3.0.0'); + } + added(version: string, description?: string) { return this.push({ version, state: 'Added', description }); } diff --git a/server/src/dtos/plugin-manifest.dto.ts b/server/src/dtos/plugin-manifest.dto.ts index d5d1c52997..a3edd01bc3 100644 --- a/server/src/dtos/plugin-manifest.dto.ts +++ b/server/src/dtos/plugin-manifest.dto.ts @@ -3,7 +3,6 @@ import { Type } from 'class-transformer'; import { ArrayMinSize, IsArray, - IsEnum, IsNotEmpty, IsObject, IsOptional, @@ -12,66 +11,31 @@ import { Matches, ValidateNested, } from 'class-validator'; -import { PluginContext } from 'src/enum'; -import { JSONSchema } from 'src/types/plugin-schema.types'; +import { WorkflowType } from 'src/enum'; +import { JSONSchema } from 'src/types'; import { ValidateEnum } from 'src/validation'; -class PluginManifestWasmDto { - @ApiProperty({ description: 'WASM file path' }) +class PluginManifestMethodDto { + @ApiProperty({ description: 'Method name' }) @IsString() @IsNotEmpty() - path!: string; -} + name!: string; -class PluginManifestFilterDto { - @ApiProperty({ description: 'Filter method name' }) - @IsString() - @IsNotEmpty() - methodName!: string; - - @ApiProperty({ description: 'Filter title' }) + @ApiProperty({ description: 'Method title' }) @IsString() @IsNotEmpty() title!: string; - @ApiProperty({ description: 'Filter description' }) - @IsString() - @IsNotEmpty() - description!: string; - - @ApiProperty({ description: 'Supported contexts', enum: PluginContext, isArray: true }) - @IsArray() - @ArrayMinSize(1) - @IsEnum(PluginContext, { each: true }) - supportedContexts!: PluginContext[]; - - @ApiPropertyOptional({ description: 'Filter schema' }) - @IsObject() - @IsOptional() - schema?: JSONSchema; -} - -class PluginManifestActionDto { - @ApiProperty({ description: 'Action method name' }) - @IsString() - @IsNotEmpty() - methodName!: string; - - @ApiProperty({ description: 'Action title' }) - @IsString() - @IsNotEmpty() - title!: string; - - @ApiProperty({ description: 'Action description' }) + @ApiProperty({ description: 'Method description' }) @IsString() @IsNotEmpty() description!: string; @ArrayMinSize(1) - @ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true, description: 'Supported contexts' }) - supportedContexts!: PluginContext[]; + @ValidateEnum({ enum: WorkflowType, name: 'WorkflowType', each: true, description: 'Workflow type' }) + types!: WorkflowType[]; - @ApiPropertyOptional({ description: 'Action schema' }) + @ApiPropertyOptional({ description: 'Method schema' }) @IsObject() @IsOptional() schema?: JSONSchema; @@ -102,27 +66,20 @@ export class PluginManifestDto { @IsNotEmpty() description!: string; + @ApiProperty({ description: 'WASM file path' }) + @IsString() + @IsNotEmpty() + wasmPath!: string; + @ApiProperty({ description: 'Plugin author' }) @IsString() @IsNotEmpty() author!: string; - @ApiProperty({ description: 'WASM configuration' }) - @ValidateNested() - @Type(() => PluginManifestWasmDto) - wasm!: PluginManifestWasmDto; - @ApiPropertyOptional({ description: 'Plugin filters' }) @IsArray() @ValidateNested({ each: true }) - @Type(() => PluginManifestFilterDto) + @Type(() => PluginManifestMethodDto) @IsOptional() - filters?: PluginManifestFilterDto[]; - - @ApiPropertyOptional({ description: 'Plugin actions' }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => PluginManifestActionDto) - @IsOptional() - actions?: PluginManifestActionDto[]; + methods!: PluginManifestMethodDto[]; } diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts index de1f1b28d4..fe703c9301 100644 --- a/server/src/dtos/plugin.dto.ts +++ b/server/src/dtos/plugin.dto.ts @@ -1,15 +1,28 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; -import { PluginAction, PluginFilter } from 'src/database'; -import { PluginContext as PluginContextType, PluginTriggerType } from 'src/enum'; -import type { JSONSchema } from 'src/types/plugin-schema.types'; -import { ValidateEnum } from 'src/validation'; +import { PluginMethod } from 'src/database'; +import { WorkflowType } from 'src/enum'; +import { JSONSchema } from 'src/types'; +import { ValidateBoolean, ValidateString, ValidateUUID } from 'src/validation'; -export class PluginTriggerResponseDto { - @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Trigger type' }) - type!: PluginTriggerType; - @ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', description: 'Context type' }) - contextType!: PluginContextType; +export class PluginSearchDto { + @ValidateUUID({ optional: true, description: 'Plugin ID' }) + id?: string; + + @ValidateBoolean({ optional: true, description: 'Whether the plugin is enabled' }) + enabled?: boolean; + + @ValidateString({ optional: true }) + name?: string; + + @ValidateString({ optional: true }) + version?: string; + + @ValidateString({ optional: true }) + title?: string; + + @ValidateString({ optional: true }) + description?: string; } export class PluginResponseDto { @@ -29,45 +42,30 @@ export class PluginResponseDto { createdAt!: string; @ApiProperty({ description: 'Last update date' }) updatedAt!: string; - @ApiProperty({ description: 'Plugin filters' }) - filters!: PluginFilterResponseDto[]; - @ApiProperty({ description: 'Plugin actions' }) - actions!: PluginActionResponseDto[]; + @ApiProperty({ description: 'Plugin methods' }) + methods!: PluginMethodResponseDto[]; } -export class PluginFilterResponseDto { - @ApiProperty({ description: 'Filter ID' }) +export class PluginMethodResponseDto { + @ApiProperty({ description: 'ID' }) id!: string; @ApiProperty({ description: 'Plugin ID' }) pluginId!: string; - @ApiProperty({ description: 'Method name' }) - methodName!: string; - @ApiProperty({ description: 'Filter title' }) + @ApiProperty({ description: 'Name' }) + name!: string; + @ApiProperty({ description: 'Title' }) title!: string; - @ApiProperty({ description: 'Filter description' }) + @ApiProperty({ description: 'Description' }) description!: string; - - @ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', each: true, description: 'Supported contexts' }) - supportedContexts!: PluginContextType[]; - @ApiProperty({ description: 'Filter schema' }) - schema!: JSONSchema | null; -} - -export class PluginActionResponseDto { - @ApiProperty({ description: 'Action ID' }) - id!: string; - @ApiProperty({ description: 'Plugin ID' }) - pluginId!: string; - @ApiProperty({ description: 'Method name' }) - methodName!: string; - @ApiProperty({ description: 'Action title' }) - title!: string; - @ApiProperty({ description: 'Action description' }) - description!: string; - - @ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', each: true, description: 'Supported contexts' }) - supportedContexts!: PluginContextType[]; - @ApiProperty({ description: 'Action schema' }) + @ValidateString({ + // TODO need enum validation for non-enum type + // enum: WorkflowType, + name: 'PluginTypes', + // each: true, + description: 'Supported types', + }) + types!: WorkflowType[]; + @ApiProperty({ description: 'Schema' }) schema!: JSONSchema | null; } @@ -85,11 +83,9 @@ export type MapPlugin = { description: string; author: string; version: string; - wasmPath: string; createdAt: Date; updatedAt: Date; - filters: PluginFilter[]; - actions: PluginAction[]; + methods: PluginMethod[]; }; export function mapPlugin(plugin: MapPlugin): PluginResponseDto { @@ -102,7 +98,6 @@ export function mapPlugin(plugin: MapPlugin): PluginResponseDto { version: plugin.version, createdAt: plugin.createdAt.toISOString(), updatedAt: plugin.updatedAt.toISOString(), - filters: plugin.filters, - actions: plugin.actions, + methods: plugin.methods, }; } diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts index c4e5ac9c4c..583b4ee490 100644 --- a/server/src/dtos/workflow.dto.ts +++ b/server/src/dtos/workflow.dto.ts @@ -1,36 +1,43 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator'; -import { WorkflowAction, WorkflowFilter } from 'src/database'; -import { PluginTriggerType } from 'src/enum'; -import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; -import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation'; +import { Workflow, WorkflowStep } from 'src/database'; +import { WorkflowTrigger, WorkflowType } from 'src/enum'; +import type { WorkflowStepConfig } from 'src/types'; +import { Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; -export class WorkflowFilterItemDto { - @ApiProperty({ description: 'Plugin filter ID' }) - @IsUUID() - pluginFilterId!: string; - - @ApiPropertyOptional({ description: 'Filter configuration' }) - @IsObject() - @Optional() - filterConfig?: FilterConfig; +export class WorkflowTriggerResponseDto { + @ValidateEnum({ enum: WorkflowTrigger, name: 'PluginTriggerType', description: 'Trigger type' }) + trigger!: WorkflowTrigger; + @ValidateEnum({ enum: WorkflowType, name: 'WorkflowType', description: 'Workflow types', each: true }) + types!: WorkflowType[]; } -export class WorkflowActionItemDto { - @ApiProperty({ description: 'Plugin action ID' }) - @IsUUID() - pluginActionId!: string; +export class WorkflowSearchDto { + @ValidateUUID({ optional: true, description: 'Workflow ID' }) + id?: string; - @ApiPropertyOptional({ description: 'Action configuration' }) - @IsObject() - @Optional() - actionConfig?: ActionConfig; + @ValidateEnum({ + optional: true, + enum: WorkflowTrigger, + name: 'PluginTriggerType', + description: 'Workflow trigger type', + }) + trigger?: WorkflowTrigger; + + @ValidateString({ optional: true, description: 'Workflow name' }) + name?: string; + + @ValidateString({ optional: true, description: 'Workflow description' }) + description?: string; + + @ValidateBoolean({ optional: true, description: 'Workflow enabled' }) + enabled?: boolean; } export class WorkflowCreateDto { - @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Workflow trigger type' }) - triggerType!: PluginTriggerType; + @ValidateEnum({ enum: WorkflowTrigger, name: 'PluginTriggerType', description: 'Workflow trigger type' }) + trigger!: WorkflowTrigger; @ApiProperty({ description: 'Workflow name' }) @IsString() @@ -44,26 +51,16 @@ export class WorkflowCreateDto { @ValidateBoolean({ optional: true, description: 'Workflow enabled' }) enabled?: boolean; - - @ApiProperty({ description: 'Workflow filters' }) - @ValidateNested({ each: true }) - @Type(() => WorkflowFilterItemDto) - filters!: WorkflowFilterItemDto[]; - - @ApiProperty({ description: 'Workflow actions' }) - @ValidateNested({ each: true }) - @Type(() => WorkflowActionItemDto) - actions!: WorkflowActionItemDto[]; } export class WorkflowUpdateDto { @ValidateEnum({ - enum: PluginTriggerType, + enum: WorkflowTrigger, name: 'PluginTriggerType', optional: true, description: 'Workflow trigger type', }) - triggerType?: PluginTriggerType; + trigger?: WorkflowTrigger; @ApiPropertyOptional({ description: 'Workflow name' }) @IsString() @@ -78,18 +75,26 @@ export class WorkflowUpdateDto { @ValidateBoolean({ optional: true, description: 'Workflow enabled' }) enabled?: boolean; +} - @ApiPropertyOptional({ description: 'Workflow filters' }) - @ValidateNested({ each: true }) - @Type(() => WorkflowFilterItemDto) - @Optional() - filters?: WorkflowFilterItemDto[]; +export class WorkflowStepDto { + @ApiProperty({ description: 'Plugin method ID' }) + @IsUUID() + pluginMethodId!: string; - @ApiPropertyOptional({ description: 'Workflow actions' }) - @ValidateNested({ each: true }) - @Type(() => WorkflowActionItemDto) + @ApiPropertyOptional({ description: 'Method configuration' }) + @IsObject() @Optional() - actions?: WorkflowActionItemDto[]; + config?: WorkflowStepConfig; + + @ValidateBoolean({ optional: true, description: 'Workflow step enabled' }) + enabled?: boolean; +} + +export class WorkflowStepsCreateDto { + @ValidateNested({ each: true }) + @Type(() => WorkflowStepDto) + steps!: WorkflowStepDto[]; } export class WorkflowResponseDto { @@ -97,64 +102,52 @@ export class WorkflowResponseDto { id!: string; @ApiProperty({ description: 'Owner user ID' }) ownerId!: string; - @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Workflow trigger type' }) - triggerType!: PluginTriggerType; + @ValidateEnum({ enum: WorkflowTrigger, name: 'PluginTriggerType', description: 'Workflow trigger type' }) + trigger!: WorkflowTrigger; @ApiProperty({ description: 'Workflow name' }) name!: string | null; @ApiProperty({ description: 'Workflow description' }) - description!: string; + description!: string | null; @ApiProperty({ description: 'Creation date' }) createdAt!: string; @ApiProperty({ description: 'Workflow enabled' }) enabled!: boolean; - @ApiProperty({ description: 'Workflow filters' }) - filters!: WorkflowFilterResponseDto[]; - @ApiProperty({ description: 'Workflow actions' }) - actions!: WorkflowActionResponseDto[]; + @ApiProperty({ description: 'Workflow steps' }) + steps!: WorkflowStepResponseDto[]; } -export class WorkflowFilterResponseDto { - @ApiProperty({ description: 'Filter ID' }) +export class WorkflowStepResponseDto { + @ApiProperty({ description: 'Step ID' }) id!: string; @ApiProperty({ description: 'Workflow ID' }) workflowId!: string; - @ApiProperty({ description: 'Plugin filter ID' }) - pluginFilterId!: string; - @ApiProperty({ description: 'Filter configuration' }) - filterConfig!: FilterConfig | null; - @ApiProperty({ description: 'Filter order', type: 'number' }) + @ApiProperty({ description: 'Plugin method ID' }) + pluginMethodId!: string; + @ApiProperty({ description: 'Method configuration' }) + config!: WorkflowStepConfig | null; + @ApiProperty({ description: 'Step order', type: 'number' }) order!: number; } -export class WorkflowActionResponseDto { - @ApiProperty({ description: 'Action ID' }) - id!: string; - @ApiProperty({ description: 'Workflow ID' }) - workflowId!: string; - @ApiProperty({ description: 'Plugin action ID' }) - pluginActionId!: string; - @ApiProperty({ description: 'Action configuration' }) - actionConfig!: ActionConfig | null; - @ApiProperty({ description: 'Action order', type: 'number' }) - order!: number; -} - -export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto { +export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): WorkflowResponseDto => { return { - id: filter.id, - workflowId: filter.workflowId, - pluginFilterId: filter.pluginFilterId, - filterConfig: filter.filterConfig, - order: filter.order, + id: workflow.id, + ownerId: workflow.ownerId, + trigger: workflow.trigger, + name: workflow.name, + description: workflow.description, + createdAt: workflow.createdAt.toISOString(), + enabled: workflow.enabled, + steps: workflow.steps.map((step) => mapWorkflowStep(step)), }; -} +}; -export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto { +export const mapWorkflowStep = (step: WorkflowStep): WorkflowStepResponseDto => { return { - id: action.id, - workflowId: action.workflowId, - pluginActionId: action.pluginActionId, - actionConfig: action.actionConfig, - order: action.order, + id: step.id, + workflowId: step.workflowId, + pluginMethodId: step.pluginMethodId, + config: step.config, + order: step.order, }; -} +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index 2aa9bd2aa6..c2873108c1 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -544,8 +544,11 @@ export enum BootstrapEventPriority { StorageService = -195, // Other services may need to queue jobs on bootstrap. JobService = -190, - // Initialise config after other bootstrap services, stop other services from using config on bootstrap + // Initialize config after other bootstrap services, stop other services from using config on bootstrap SystemConfig = 100, + PluginSync = 190, + // Load plugins into memory after sync + PluginLoad = 200, } export enum QueueName { @@ -655,7 +658,7 @@ export enum JobName { Ocr = 'Ocr', // Workflow - WorkflowRun = 'WorkflowRun', + WorkflowAssetCreate = 'WorkflowAssetCreate', } export enum QueueCommand { @@ -694,6 +697,7 @@ export enum DatabaseLock { CLIPDimSize = 512, Library = 1337, NightlyJobs = 600, + PluginImport = 666, MediaLocation = 700, GetSystemConfig = 69, BackupDatabase = 42, @@ -882,13 +886,12 @@ export enum ApiTag { Workflows = 'Workflows', } -export enum PluginContext { - Asset = 'asset', - Album = 'album', - Person = 'person', -} - -export enum PluginTriggerType { +export enum WorkflowTrigger { AssetCreate = 'AssetCreate', PersonRecognized = 'PersonRecognized', } + +export enum WorkflowType { + AssetV1 = 'AssetV1', + AssetPersonV1 = 'AssetPersonV1', +} diff --git a/server/src/plugins.ts b/server/src/plugins.ts deleted file mode 100644 index 77f35e79f6..0000000000 --- a/server/src/plugins.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { PluginContext, PluginTriggerType } from 'src/enum'; - -export type PluginTrigger = { - type: PluginTriggerType; - contextType: PluginContext; -}; - -export const pluginTriggers: PluginTrigger[] = [ - { - type: PluginTriggerType.AssetCreate, - contextType: PluginContext.Asset, - }, - { - type: PluginTriggerType.PersonRecognized, - contextType: PluginContext.Person, - }, -]; diff --git a/server/src/queries/plugin.repository.sql b/server/src/queries/plugin.repository.sql index 82c203dafd..652acd462d 100644 --- a/server/src/queries/plugin.repository.sql +++ b/server/src/queries/plugin.repository.sql @@ -1,48 +1,17 @@ -- NOTE: This file is auto generated by ./sql-generator --- PluginRepository.getPlugin +-- PluginRepository.getForLoad select - "plugin"."id" as "id", - "plugin"."name" as "name", - "plugin"."title" as "title", - "plugin"."description" as "description", - "plugin"."author" as "author", - "plugin"."version" as "version", - "plugin"."wasmPath" as "wasmPath", - "plugin"."createdAt" as "createdAt", - "plugin"."updatedAt" as "updatedAt", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_filter" - where - "plugin_filter"."pluginId" = "plugin"."id" - ) as agg - ) as "filters", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_action" - where - "plugin_action"."pluginId" = "plugin"."id" - ) as agg - ) as "actions" + "id", + "name", + "version", + "wasmBytes" from "plugin" where - "plugin"."id" = $1 + "enabled" = $1 --- PluginRepository.getPluginByName +-- PluginRepository.search select "plugin"."id" as "id", "plugin"."name" as "name", @@ -50,7 +19,6 @@ select "plugin"."description" as "description", "plugin"."author" as "author", "plugin"."version" as "version", - "plugin"."wasmPath" as "wasmPath", "plugin"."createdAt" as "createdAt", "plugin"."updatedAt" as "updatedAt", ( @@ -61,99 +29,84 @@ select select * from - "plugin_filter" + "plugin_method" where - "plugin_filter"."pluginId" = "plugin"."id" + "plugin_method"."pluginId" = "plugin"."id" ) as agg - ) as "filters", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_action" - where - "plugin_action"."pluginId" = "plugin"."id" - ) as agg - ) as "actions" -from - "plugin" -where - "plugin"."name" = $1 - --- PluginRepository.getAllPlugins -select - "plugin"."id" as "id", - "plugin"."name" as "name", - "plugin"."title" as "title", - "plugin"."description" as "description", - "plugin"."author" as "author", - "plugin"."version" as "version", - "plugin"."wasmPath" as "wasmPath", - "plugin"."createdAt" as "createdAt", - "plugin"."updatedAt" as "updatedAt", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_filter" - where - "plugin_filter"."pluginId" = "plugin"."id" - ) as agg - ) as "filters", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_action" - where - "plugin_action"."pluginId" = "plugin"."id" - ) as agg - ) as "actions" + ) as "methods" from "plugin" order by "plugin"."name" --- PluginRepository.getFilter +-- PluginRepository.getByName select - * + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_method" + where + "plugin_method"."pluginId" = "plugin"."id" + ) as agg + ) as "methods" from - "plugin_filter" + "plugin" where - "id" = $1 + "plugin"."name" = $1 --- PluginRepository.getFiltersByPlugin +-- PluginRepository.get +select + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_method" + where + "plugin_method"."pluginId" = "plugin"."id" + ) as agg + ) as "methods" +from + "plugin" +where + "plugin"."id" = $1 + +-- PluginRepository.getMethods select * from - "plugin_filter" + "plugin_method" where "pluginId" = $1 --- PluginRepository.getAction +-- PluginRepository.getMethod select * from - "plugin_action" + "plugin_method" where "id" = $1 - --- PluginRepository.getActionsByPlugin -select - * -from - "plugin_action" -where - "pluginId" = $1 diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql index 27dc21dffe..ef5368e3c7 100644 --- a/server/src/queries/workflow.repository.sql +++ b/server/src/queries/workflow.repository.sql @@ -1,70 +1,94 @@ -- NOTE: This file is auto generated by ./sql-generator --- WorkflowRepository.getWorkflow +-- WorkflowRepository.search select - * + *, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "workflow_step" + where + "workflow_step"."workflowId" = "workflow"."id" + ) as agg + ) as "steps" +from + "workflow" +order by + "createdAt" desc + +-- WorkflowRepository.get +select + *, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "workflow_step" + where + "workflow_step"."workflowId" = "workflow"."id" + ) as agg + ) as "steps" from "workflow" where "id" = $1 -order by - "createdAt" desc --- WorkflowRepository.getWorkflowsByOwner +-- WorkflowRepository.getForWorkflowRun select - * + "workflow"."id", + "workflow"."name", + "workflow"."trigger", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "workflow_step"."id", + "workflow_step"."config", + "plugin_method"."pluginId" as "pluginId", + "plugin_method"."name" as "methodName", + "plugin_method"."types" as "types" + from + "workflow_step" + inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId" + where + "workflow_step"."workflowId" = "workflow"."id" + and "workflow_step"."enabled" = $1 + ) as agg + ) as "steps" from "workflow" where - "ownerId" = $1 -order by - "createdAt" desc - --- WorkflowRepository.getWorkflowsByTrigger -select - * -from - "workflow" -where - "triggerType" = $1 - and "enabled" = $2 - --- WorkflowRepository.getWorkflowByOwnerAndTrigger -select - * -from - "workflow" -where - "ownerId" = $1 - and "triggerType" = $2 + "id" = $2 and "enabled" = $3 --- WorkflowRepository.deleteWorkflow +-- WorkflowRepository.delete delete from "workflow" where "id" = $1 --- WorkflowRepository.getFilters +-- WorkflowRepository.getSteps select * from - "workflow_filter" + "workflow_step" where "workflowId" = $1 order by "order" asc --- WorkflowRepository.deleteFiltersByWorkflow -delete from "workflow_filter" +-- WorkflowRepository.deleteStep +delete from "workflow_step" where "workflowId" = $1 - --- WorkflowRepository.getActions -select - * -from - "workflow_action" -where - "workflowId" = $1 -order by - "order" asc + and "id" = $2 diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 39867b14d0..c1df648f09 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -119,6 +119,10 @@ export class LoggingRepository { logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; } + getLogLevel(): LogLevel { + return logLevels[0] || LogLevel.Fatal; + } + verbose(message: string, ...details: LogDetails) { this.handleMessage(LogLevel.Verbose, message, details); } diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts index 6217237947..9f24a96e6c 100644 --- a/server/src/repositories/plugin.repository.ts +++ b/server/src/repositories/plugin.repository.ts @@ -1,176 +1,196 @@ +import { CallContext, Plugin as ExtismPlugin, newPlugin } from '@extism/extism'; import { Injectable } from '@nestjs/common'; -import { Kysely } from 'kysely'; +import { Insertable, Kysely } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; -import { readdir } from 'node:fs/promises'; import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { PluginSearchDto } from 'src/dtos/plugin.dto'; +import { LogLevel } from 'src/enum'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { DB } from 'src/schema'; +import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; +import { PluginTable } from 'src/schema/tables/plugin.table'; + +type PluginMethod = { pluginId: string; methodName: string }; +type PluginLoad = { id: string; name: string; version: string; wasmBytes: Buffer }; +type PluginMapItem = { plugin: ExtismPlugin; name: string; version: string }; +export type PluginHostFunction = (callContext: CallContext, input: bigint) => any; // TODO probably needs to be bigint return as well +export type PluginLoadOptions = { + functions: Record; +}; + +const levels = { + [LogLevel.Verbose]: 'trace', + [LogLevel.Debug]: 'debug', + [LogLevel.Log]: 'info', + [LogLevel.Warn]: 'warn', + [LogLevel.Error]: 'error', + [LogLevel.Fatal]: 'error', +} as const; + +const asExtismLogLevel = (logLevel: LogLevel) => levels[logLevel] || 'info'; @Injectable() export class PluginRepository { - constructor(@InjectKysely() private db: Kysely) {} + private pluginMap: Map = new Map(); - /** - * Loads a plugin from a validated manifest file in a transaction. - * This ensures all plugin, filter, and action operations are atomic. - * @param manifest The validated plugin manifest - * @param basePath The base directory path where the plugin is located - */ - async loadPlugin(manifest: PluginManifestDto, basePath: string) { - return this.db.transaction().execute(async (tx) => { - // Upsert the plugin - const plugin = await tx - .insertInto('plugin') - .values({ - name: manifest.name, - title: manifest.title, - description: manifest.description, - author: manifest.author, - version: manifest.version, - wasmPath: `${basePath}/${manifest.wasm.path}`, - }) - .onConflict((oc) => - oc.column('name').doUpdateSet({ - title: manifest.title, - description: manifest.description, - author: manifest.author, - version: manifest.version, - wasmPath: `${basePath}/${manifest.wasm.path}`, - }), - ) - .returningAll() - .executeTakeFirstOrThrow(); - - const filters = manifest.filters - ? await tx - .insertInto('plugin_filter') - .values( - manifest.filters.map((filter) => ({ - pluginId: plugin.id, - methodName: filter.methodName, - title: filter.title, - description: filter.description, - supportedContexts: filter.supportedContexts, - schema: filter.schema, - })), - ) - .onConflict((oc) => - oc.column('methodName').doUpdateSet((eb) => ({ - pluginId: eb.ref('excluded.pluginId'), - title: eb.ref('excluded.title'), - description: eb.ref('excluded.description'), - supportedContexts: eb.ref('excluded.supportedContexts'), - schema: eb.ref('excluded.schema'), - })), - ) - .returningAll() - .execute() - : []; - - const actions = manifest.actions - ? await tx - .insertInto('plugin_action') - .values( - manifest.actions.map((action) => ({ - pluginId: plugin.id, - methodName: action.methodName, - title: action.title, - description: action.description, - supportedContexts: action.supportedContexts, - schema: action.schema, - })), - ) - .onConflict((oc) => - oc.column('methodName').doUpdateSet((eb) => ({ - pluginId: eb.ref('excluded.pluginId'), - title: eb.ref('excluded.title'), - description: eb.ref('excluded.description'), - supportedContexts: eb.ref('excluded.supportedContexts'), - schema: eb.ref('excluded.schema'), - })), - ) - .returningAll() - .execute() - : []; - - return { plugin, filters, actions }; - }); + constructor( + @InjectKysely() private db: Kysely, + private logger: LoggingRepository, + ) { + this.logger.setContext(PluginRepository.name); } - async readDirectory(path: string) { - return readdir(path, { withFileTypes: true }); + @GenerateSql() + getForLoad() { + return this.db + .selectFrom('plugin') + .where('enabled', '=', true) + .select(['id', 'name', 'version', 'wasmBytes']) + .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getPlugin(id: string) { + @GenerateSql() + search(dto: PluginSearchDto = {}) { return this.db .selectFrom('plugin') .select((eb) => [ ...columns.plugin, jsonArrayFrom( - eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), - ).as('filters'), - jsonArrayFrom( - eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), - ).as('actions'), + eb.selectFrom('plugin_method').selectAll().whereRef('plugin_method.pluginId', '=', 'plugin.id'), + ).as('methods'), ]) - .where('plugin.id', '=', id) - .executeTakeFirst(); + .$if(!!dto.id, (qb) => qb.where('plugin.id', '=', dto.id!)) + .$if(!!dto.name, (qb) => qb.where('plugin.name', '=', dto.name!)) + .$if(!!dto.title, (qb) => qb.where('plugin.name', '=', dto.title!)) + .$if(!!dto.description, (qb) => qb.where('plugin.name', '=', dto.description!)) + .$if(!!dto.version, (qb) => qb.where('plugin.version', '=', dto.version!)) + .orderBy('plugin.name') + .execute(); } @GenerateSql({ params: [DummyValue.STRING] }) - getPluginByName(name: string) { + getByName(name: string) { return this.db .selectFrom('plugin') .select((eb) => [ ...columns.plugin, jsonArrayFrom( - eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), - ).as('filters'), - jsonArrayFrom( - eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), - ).as('actions'), + eb.selectFrom('plugin_method').selectAll().whereRef('plugin_method.pluginId', '=', 'plugin.id'), + ).as('methods'), ]) .where('plugin.name', '=', name) .executeTakeFirst(); } - @GenerateSql() - getAllPlugins() { + @GenerateSql({ params: [DummyValue.UUID] }) + get(id: string) { return this.db .selectFrom('plugin') .select((eb) => [ ...columns.plugin, jsonArrayFrom( - eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), - ).as('filters'), - jsonArrayFrom( - eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), - ).as('actions'), + eb.selectFrom('plugin_method').selectAll().whereRef('plugin_method.pluginId', '=', 'plugin.id'), + ).as('methods'), ]) - .orderBy('plugin.name') - .execute(); + .where('plugin.id', '=', id) + .executeTakeFirst(); + } + + async create(dto: Insertable, initialMethods: Omit, 'pluginId'>[]) { + return this.db.transaction().execute(async (tx) => { + // Upsert the plugin + const plugin = await tx + .insertInto('plugin') + .values(dto) + .onConflict((oc) => + oc.columns(['name', 'version']).doUpdateSet((eb) => ({ + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + author: eb.ref('excluded.author'), + version: eb.ref('excluded.version'), + wasmBytes: eb.ref('excluded.wasmBytes'), + })), + ) + .returning(['id', 'name']) + .executeTakeFirstOrThrow(); + + // TODO: handle methods that were removed in a new version + const methods = + initialMethods.length > 0 + ? await tx + .insertInto('plugin_method') + .values(initialMethods.map((method) => ({ ...method, pluginId: plugin.id }))) + .onConflict((oc) => + oc.columns(['pluginId', 'name']).doUpdateSet((eb) => ({ + pluginId: eb.ref('excluded.pluginId'), + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + types: eb.ref('excluded.types'), + schema: eb.ref('excluded.schema'), + })), + ) + .returningAll() + .execute() + : []; + + return { ...plugin, methods }; + }); } @GenerateSql({ params: [DummyValue.UUID] }) - getFilter(id: string) { - return this.db.selectFrom('plugin_filter').selectAll().where('id', '=', id).executeTakeFirst(); + getMethods(pluginId: string) { + return this.db.selectFrom('plugin_method').selectAll().where('pluginId', '=', pluginId).execute(); } @GenerateSql({ params: [DummyValue.UUID] }) - getFiltersByPlugin(pluginId: string) { - return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute(); + getMethod(id: string) { + return this.db.selectFrom('plugin_method').selectAll().where('id', '=', id).executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getAction(id: string) { - return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst(); + async load({ id, name, version, wasmBytes }: PluginLoad, { functions }: PluginLoadOptions) { + const data = new Uint8Array(wasmBytes.buffer, wasmBytes.byteOffset, wasmBytes.byteLength); + const logger = LoggingRepository.create(`Plugin:${name}@${version}`); + const plugin = await newPlugin( + { wasm: [{ data }] }, + { + useWasi: true, + runInWorker: true, + functions: { + 'extism:host/user': functions, + }, + logLevel: asExtismLogLevel(logger.getLogLevel()), + logger: { + trace: (message) => logger.verbose(message), + info: (message) => logger.log(message), + debug: (message) => logger.debug(message), + warn: (message) => logger.warn(message), + error: (message) => logger.error(message), + } as Console, + }, + ); + this.pluginMap.set(id, { plugin, name, version }); } - @GenerateSql({ params: [DummyValue.UUID] }) - getActionsByPlugin(pluginId: string) { - return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute(); + async callMethod({ pluginId, methodName }: PluginMethod, input: unknown) { + const item = this.pluginMap.get(pluginId); + if (!item) { + throw new Error(`No loaded plugin found for ${pluginId}`); + } + + const { plugin, name, version } = item; + const methodLabel = `${name}@${version}#${methodName}`; + + try { + const result = await plugin.call(methodName, JSON.stringify(input)); + if (result) { + return result.json() as T; + } + + return result as T; + } catch (error: Error | any) { + throw new Error(`Plugin method call failed: ${methodLabel}`, { cause: error }); + } } } diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 5a1a936e77..0c06492036 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -2,7 +2,15 @@ import { Injectable } from '@nestjs/common'; import archiver from 'archiver'; import chokidar, { ChokidarOptions } from 'chokidar'; import { escapePath, glob, globStream } from 'fast-glob'; -import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs'; +import { + constants, + createReadStream, + createWriteStream, + Dirent, + existsSync, + mkdirSync, + ReadOptionsWithBuffer, +} from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { PassThrough, Readable, Writable } from 'node:stream'; @@ -50,6 +58,10 @@ export class StorageRepository { return fs.readdir(folder); } + readdirWithTypes(folder: string): Promise { + return fs.readdir(folder, { withFileTypes: true }); + } + copyFile(source: string, target: string) { return fs.copyFile(source, target); } @@ -117,17 +129,24 @@ export class StorageRepository { } async readFile(filepath: string, options?: ReadOptionsWithBuffer): Promise { - const file = await fs.open(filepath); - try { - const { buffer } = await file.read(options); - return buffer as Buffer; - } finally { - await file.close(); + // read a slice + if (options) { + const file = await fs.open(filepath); + try { + const { buffer } = await file.read(options); + return buffer as Buffer; + } finally { + await file.close(); + } } + + // read everything + return fs.readFile(filepath); } - async readTextFile(filepath: string): Promise { - return fs.readFile(filepath, 'utf8'); + async readJsonFile(filepath: string): Promise { + const file = await fs.readFile(filepath, 'utf8'); + return JSON.parse(file) as T; } async checkFileExists(filepath: string, mode = constants.F_OK): Promise { diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index deaf2aa2fc..5c193929e5 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -1,131 +1,101 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { PluginTriggerType } from 'src/enum'; +import { WorkflowSearchDto, WorkflowStepDto } from 'src/dtos/workflow.dto'; import { DB } from 'src/schema'; -import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; @Injectable() export class WorkflowRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [DummyValue.UUID] }) - getWorkflow(id: string) { + private queryBuilder() { return this.db .selectFrom('workflow') .selectAll() - .where('id', '=', id) + .select((eb) => [ + jsonArrayFrom( + eb.selectFrom('workflow_step').selectAll().whereRef('workflow_step.workflowId', '=', 'workflow.id'), + ).as('steps'), + ]); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + search(dto: WorkflowSearchDto & { ownerId?: string }) { + return this.queryBuilder() + .$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!)) + .$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!)) + .$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!)) .orderBy('createdAt', 'desc') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + get(id: string) { + return this.queryBuilder().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getForWorkflowRun(id: string) { + return this.db + .selectFrom('workflow') + .select(['workflow.id', 'workflow.name', 'workflow.trigger']) + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom('workflow_step') + .innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId') + .whereRef('workflow_step.workflowId', '=', 'workflow.id') + .where('workflow_step.enabled', '=', true) + .select([ + 'workflow_step.id', + 'workflow_step.config', + 'plugin_method.pluginId as pluginId', + 'plugin_method.name as methodName', + 'plugin_method.types as types', + ]), + ).as('steps'), + ]) + .where('id', '=', id) + .where('enabled', '=', true) .executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getWorkflowsByOwner(ownerId: string) { + create(workflow: Insertable) { + return this.db.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow(); + } + + update(id: string, workflow: Updateable) { + // handle empty update + if (Object.values(workflow).filter((prop) => prop !== undefined).length === 0) { + return this.queryBuilder().where('id', '=', id).executeTakeFirstOrThrow(); + } + return this.db - .selectFrom('workflow') - .selectAll() - .where('ownerId', '=', ownerId) - .orderBy('createdAt', 'desc') - .execute(); - } - - @GenerateSql({ params: [PluginTriggerType.AssetCreate] }) - getWorkflowsByTrigger(type: PluginTriggerType) { - return this.db - .selectFrom('workflow') - .selectAll() - .where('triggerType', '=', type) - .where('enabled', '=', true) - .execute(); - } - - @GenerateSql({ params: [DummyValue.UUID, PluginTriggerType.AssetCreate] }) - getWorkflowByOwnerAndTrigger(ownerId: string, type: PluginTriggerType) { - return this.db - .selectFrom('workflow') - .selectAll() - .where('ownerId', '=', ownerId) - .where('triggerType', '=', type) - .where('enabled', '=', true) - .execute(); - } - - async createWorkflow( - workflow: Insertable, - filters: Insertable[], - actions: Insertable[], - ) { - return await this.db.transaction().execute(async (tx) => { - const createdWorkflow = await tx.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow(); - - if (filters.length > 0) { - const newFilters = filters.map((filter) => ({ - ...filter, - workflowId: createdWorkflow.id, - })); - - await tx.insertInto('workflow_filter').values(newFilters).execute(); - } - - if (actions.length > 0) { - const newActions = actions.map((action) => ({ - ...action, - workflowId: createdWorkflow.id, - })); - await tx.insertInto('workflow_action').values(newActions).execute(); - } - - return createdWorkflow; - }); - } - - async updateWorkflow( - id: string, - workflow: Updateable, - filters: Insertable[] | undefined, - actions: Insertable[] | undefined, - ) { - return await this.db.transaction().execute(async (trx) => { - if (Object.keys(workflow).length > 0) { - await trx.updateTable('workflow').set(workflow).where('id', '=', id).execute(); - } - - if (filters !== undefined) { - await trx.deleteFrom('workflow_filter').where('workflowId', '=', id).execute(); - if (filters.length > 0) { - const filtersWithWorkflowId = filters.map((filter) => ({ - ...filter, - workflowId: id, - })); - await trx.insertInto('workflow_filter').values(filtersWithWorkflowId).execute(); - } - } - - if (actions !== undefined) { - await trx.deleteFrom('workflow_action').where('workflowId', '=', id).execute(); - if (actions.length > 0) { - const actionsWithWorkflowId = actions.map((action) => ({ - ...action, - workflowId: id, - })); - await trx.insertInto('workflow_action').values(actionsWithWorkflowId).execute(); - } - } - - return await trx.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirstOrThrow(); - }); + .updateTable('workflow') + .set(workflow) + .where('id', '=', id) + .returningAll() + .returning((eb) => [ + jsonArrayFrom( + eb.selectFrom('workflow_step').selectAll().whereRef('workflow_step.workflowId', '=', 'workflow.id'), + ).as('steps'), + ]) + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) - async deleteWorkflow(id: string) { + async delete(id: string) { await this.db.deleteFrom('workflow').where('id', '=', id).execute(); } @GenerateSql({ params: [DummyValue.UUID] }) - getFilters(workflowId: string) { + getSteps(workflowId: string) { return this.db - .selectFrom('workflow_filter') + .selectFrom('workflow_step') .selectAll() .where('workflowId', '=', workflowId) .orderBy('order', 'asc') @@ -133,17 +103,78 @@ export class WorkflowRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - async deleteFiltersByWorkflow(workflowId: string) { - await this.db.deleteFrom('workflow_filter').where('workflowId', '=', workflowId).execute(); + async deleteStep(workflowId: string, stepId: string) { + await this.db.deleteFrom('workflow_step').where('workflowId', '=', workflowId).where('id', '=', stepId).execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getActions(workflowId: string) { + replaceSteps(id: string, steps: WorkflowStepDto[]) { + return this.db.transaction().execute(async (trx) => { + await trx.deleteFrom('workflow_step').where('workflowId', '=', id).execute(); + if (steps.length === 0) { + return []; + } + + return trx + .insertInto('workflow_step') + .values( + steps.map((step, i) => ({ + workflowId: id, + enabled: step.enabled ?? true, + pluginMethodId: step.pluginMethodId, + config: step.config, + order: i, + })), + ) + .returningAll() + .execute(); + }); + } + + getForAssetV1(assetId: string) { return this.db - .selectFrom('workflow_action') - .selectAll() - .where('workflowId', '=', workflowId) - .orderBy('order', 'asc') - .execute(); + .selectFrom('asset') + .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') + .select((eb) => [ + ...columns.workflowAssetV1, + jsonObjectFrom( + eb + .selectFrom('asset_exif') + .select([ + 'asset_exif.make', + 'asset_exif.model', + 'asset_exif.orientation', + 'asset_exif.dateTimeOriginal', + 'asset_exif.modifyDate', + 'asset_exif.exifImageWidth', + 'asset_exif.exifImageHeight', + 'asset_exif.fileSizeInByte', + 'asset_exif.lensModel', + 'asset_exif.fNumber', + 'asset_exif.focalLength', + 'asset_exif.iso', + 'asset_exif.latitude', + 'asset_exif.longitude', + 'asset_exif.city', + 'asset_exif.state', + 'asset_exif.country', + 'asset_exif.description', + 'asset_exif.fps', + 'asset_exif.exposureTime', + 'asset_exif.livePhotoCID', + 'asset_exif.timeZone', + 'asset_exif.projectionType', + 'asset_exif.profileDescription', + 'asset_exif.colorspace', + 'asset_exif.bitsPerSample', + 'asset_exif.autoStackId', + 'asset_exif.rating', + 'asset_exif.tags', + 'asset_exif.updatedAt', + ]) + .whereRef('asset_exif.assetId', '=', 'asset.id'), + ).as('exifInfo'), + ]) + .where('id', '=', assetId) + .executeTakeFirstOrThrow(); } } diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 2426c2aab7..68d56ef48c 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -56,7 +56,8 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; import { PersonTable } from 'src/schema/tables/person.table'; -import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; +import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; +import { PluginTable } from 'src/schema/tables/plugin.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; @@ -73,7 +74,8 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; -import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; +import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @Database({ name: 'immich' }) @@ -132,11 +134,9 @@ export class ImmichDatabase { UserTable, VersionHistoryTable, PluginTable, - PluginFilterTable, - PluginActionTable, + PluginMethodTable, WorkflowTable, - WorkflowFilterTable, - WorkflowActionTable, + WorkflowStepTable, ]; functions = [ @@ -249,10 +249,8 @@ export interface DB { version_history: VersionHistoryTable; plugin: PluginTable; - plugin_filter: PluginFilterTable; - plugin_action: PluginActionTable; + plugin_method: PluginMethodTable; workflow: WorkflowTable; - workflow_filter: WorkflowFilterTable; - workflow_action: WorkflowActionTable; + workflow_step: WorkflowStepTable; } diff --git a/server/src/schema/migrations/1772815612034-UpdateWorkflowTables.ts b/server/src/schema/migrations/1772815612034-UpdateWorkflowTables.ts new file mode 100644 index 0000000000..ec5f62ade7 --- /dev/null +++ b/server/src/schema/migrations/1772815612034-UpdateWorkflowTables.ts @@ -0,0 +1,72 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // take #2... + await sql`DROP TABLE "workflow_action";`.execute(db); + await sql`DROP TABLE "workflow_filter";`.execute(db); + await sql`DROP TABLE "workflow";`.execute(db); + await sql`DROP TABLE "plugin_action";`.execute(db); + await sql`DROP TABLE "plugin_filter";`.execute(db); + await sql`DROP TABLE "plugin";`.execute(db); + + await sql`CREATE TABLE "plugin" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "enabled" boolean NOT NULL DEFAULT true, + "name" character varying NOT NULL, + "version" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "author" character varying NOT NULL, + "wasmBytes" bytea NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "plugin_name_version_uq" UNIQUE ("name", "version"), + CONSTRAINT "plugin_name_uq" UNIQUE ("name"), + CONSTRAINT "plugin_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db); + await sql`CREATE TABLE "plugin_method" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "name" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "types" character varying[] NOT NULL, + "schema" jsonb, + CONSTRAINT "plugin_method_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_method_pluginId_name_uq" UNIQUE ("pluginId", "name"), + CONSTRAINT "plugin_method_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_method_pluginId_idx" ON "plugin_method" ("pluginId");`.execute(db); + await sql`CREATE TABLE "workflow" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ownerId" uuid NOT NULL, + "trigger" character varying NOT NULL, + "name" character varying, + "description" character varying, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "enabled" boolean NOT NULL DEFAULT true, + CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db); + await sql`CREATE TABLE "workflow_step" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "enabled" boolean NOT NULL DEFAULT true, + "workflowId" uuid NOT NULL, + "pluginMethodId" uuid NOT NULL, + "config" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_step_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_step_pluginMethodId_fkey" FOREIGN KEY ("pluginMethodId") REFERENCES "plugin_method" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_step_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_step_workflowId_idx" ON "workflow_step" ("workflowId");`.execute(db); + await sql`CREATE INDEX "workflow_step_pluginMethodId_idx" ON "workflow_step" ("pluginMethodId");`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db); +} + +export async function down(): Promise { + // unsupported +} diff --git a/server/src/schema/tables/asset-exif.table.ts b/server/src/schema/tables/asset-exif.table.ts index ae47ecfb10..d725189e6d 100644 --- a/server/src/schema/tables/asset-exif.table.ts +++ b/server/src/schema/tables/asset-exif.table.ts @@ -111,7 +111,7 @@ export class AssetExifTable { tags!: string[] | null; @UpdateDateColumn({ default: () => 'clock_timestamp()' }) - updatedAt!: Generated; + updatedAt!: Generated; @UpdateIdColumn({ index: true }) updateId!: Generated; diff --git a/server/src/schema/tables/plugin-method.table.ts b/server/src/schema/tables/plugin-method.table.ts new file mode 100644 index 0000000000..c406c9cbf7 --- /dev/null +++ b/server/src/schema/tables/plugin-method.table.ts @@ -0,0 +1,29 @@ +import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table, Unique } from '@immich/sql-tools'; +import { WorkflowType } from 'src/enum'; +import { PluginTable } from 'src/schema/tables/plugin.table'; +import { JSONSchema } from 'src/types'; + +@Unique({ columns: ['pluginId', 'name'] }) +@Table('plugin_method') +export class PluginMethodTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + pluginId!: string; + + @Column() + name!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column({ type: 'character varying', array: true }) + types!: Generated; + + @Column({ type: 'jsonb', nullable: true }) + schema!: JSONSchema | null; +} diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts index 5f82807f23..99763a4d43 100644 --- a/server/src/schema/tables/plugin.table.ts +++ b/server/src/schema/tables/plugin.table.ts @@ -1,25 +1,29 @@ import { Column, CreateDateColumn, - ForeignKeyColumn, Generated, - Index, PrimaryGeneratedColumn, Table, Timestamp, + Unique, UpdateDateColumn, } from '@immich/sql-tools'; -import { PluginContext } from 'src/enum'; -import type { JSONSchema } from 'src/types/plugin-schema.types'; +@Unique({ columns: ['name', 'version'] }) @Table('plugin') export class PluginTable { @PrimaryGeneratedColumn('uuid') id!: Generated; + @Column({ type: 'boolean', default: true }) + enabled!: Generated; + @Column({ index: true, unique: true }) name!: string; + @Column() + version!: string; + @Column() title!: string; @@ -29,11 +33,8 @@ export class PluginTable { @Column() author!: string; - @Column() - version!: string; - - @Column() - wasmPath!: string; + @Column({ type: 'bytea' }) + wasmBytes!: Buffer; @CreateDateColumn() createdAt!: Generated; @@ -41,55 +42,3 @@ export class PluginTable { @UpdateDateColumn() updatedAt!: Generated; } - -@Index({ columns: ['supportedContexts'], using: 'gin' }) -@Table('plugin_filter') -export class PluginFilterTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - @Column({ index: true }) - pluginId!: string; - - @Column({ index: true, unique: true }) - methodName!: string; - - @Column() - title!: string; - - @Column() - description!: string; - - @Column({ type: 'character varying', array: true }) - supportedContexts!: Generated; - - @Column({ type: 'jsonb', nullable: true }) - schema!: JSONSchema | null; -} - -@Index({ columns: ['supportedContexts'], using: 'gin' }) -@Table('plugin_action') -export class PluginActionTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - @Column({ index: true }) - pluginId!: string; - - @Column({ index: true, unique: true }) - methodName!: string; - - @Column() - title!: string; - - @Column() - description!: string; - - @Column({ type: 'character varying', array: true }) - supportedContexts!: Generated; - - @Column({ type: 'jsonb', nullable: true }) - schema!: JSONSchema | null; -} diff --git a/server/src/schema/tables/workflow-step.table.ts b/server/src/schema/tables/workflow-step.table.ts new file mode 100644 index 0000000000..4d9422c399 --- /dev/null +++ b/server/src/schema/tables/workflow-step.table.ts @@ -0,0 +1,26 @@ +import { Column, ForeignKeyColumn, PrimaryGeneratedColumn, Table } from '@immich/sql-tools'; +import { Generated } from 'kysely'; +import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; +import { WorkflowStepConfig } from 'src/types'; + +@Table('workflow_step') +export class WorkflowStepTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: string; + + @ForeignKeyColumn(() => PluginMethodTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + pluginMethodId!: string; + + @Column({ type: 'jsonb', nullable: true }) + config!: WorkflowStepConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts index 163518e039..012548219c 100644 --- a/server/src/schema/tables/workflow.table.ts +++ b/server/src/schema/tables/workflow.table.ts @@ -3,15 +3,12 @@ import { CreateDateColumn, ForeignKeyColumn, Generated, - Index, PrimaryGeneratedColumn, Table, Timestamp, } from '@immich/sql-tools'; -import { PluginTriggerType } from 'src/enum'; -import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; +import { WorkflowTrigger } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; -import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; @Table('workflow') export class WorkflowTable { @@ -22,13 +19,13 @@ export class WorkflowTable { ownerId!: string; @Column() - triggerType!: PluginTriggerType; + trigger!: WorkflowTrigger; @Column({ nullable: true }) name!: string | null; - @Column() - description!: string; + @Column({ nullable: true }) + description!: string | null; @CreateDateColumn() createdAt!: Generated; @@ -36,43 +33,3 @@ export class WorkflowTable { @Column({ type: 'boolean', default: true }) enabled!: boolean; } - -@Index({ columns: ['workflowId', 'order'] }) -@Index({ columns: ['pluginFilterId'] }) -@Table('workflow_filter') -export class WorkflowFilterTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - workflowId!: Generated; - - @ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - pluginFilterId!: string; - - @Column({ type: 'jsonb', nullable: true }) - filterConfig!: FilterConfig | null; - - @Column({ type: 'integer' }) - order!: number; -} - -@Index({ columns: ['workflowId', 'order'] }) -@Index({ columns: ['pluginActionId'] }) -@Table('workflow_action') -export class WorkflowActionTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - workflowId!: Generated; - - @ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - pluginActionId!: string; - - @Column({ type: 'jsonb', nullable: true }) - actionConfig!: ActionConfig | null; - - @Column({ type: 'integer' }) - order!: number; -} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index ba54474b71..233dcd3ba1 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -45,6 +45,7 @@ import { UserAdminService } from 'src/services/user-admin.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; import { ViewService } from 'src/services/view.service'; +import { WorkflowExecutionService } from 'src/services/workflow-execution.service'; import { WorkflowService } from 'src/services/workflow.service'; export const services = [ @@ -95,5 +96,6 @@ export const services = [ UserService, VersionService, ViewService, + WorkflowExecutionService, WorkflowService, ]; diff --git a/server/src/services/plugin-host.functions.ts b/server/src/services/plugin-host.functions.ts deleted file mode 100644 index 50b1052b54..0000000000 --- a/server/src/services/plugin-host.functions.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { CurrentPlugin } from '@extism/extism'; -import { UnauthorizedException } from '@nestjs/common'; -import { Updateable } from 'kysely'; -import { Permission } from 'src/enum'; -import { AccessRepository } from 'src/repositories/access.repository'; -import { AlbumRepository } from 'src/repositories/album.repository'; -import { AssetRepository } from 'src/repositories/asset.repository'; -import { CryptoRepository } from 'src/repositories/crypto.repository'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { requireAccess } from 'src/utils/access'; - -/** - * Plugin host functions that are exposed to WASM plugins via Extism. - * These functions allow plugins to interact with the Immich system. - */ -export class PluginHostFunctions { - constructor( - private assetRepository: AssetRepository, - private albumRepository: AlbumRepository, - private accessRepository: AccessRepository, - private cryptoRepository: CryptoRepository, - private logger: LoggingRepository, - private pluginJwtSecret: string, - ) {} - - /** - * Creates Extism host function bindings for the plugin. - * These are the functions that WASM plugins can call. - */ - getHostFunctions() { - return { - 'extism:host/user': { - updateAsset: (cp: CurrentPlugin, offs: bigint) => this.handleUpdateAsset(cp, offs), - addAssetToAlbum: (cp: CurrentPlugin, offs: bigint) => this.handleAddAssetToAlbum(cp, offs), - }, - }; - } - - /** - * Host function wrapper for updateAsset. - * Reads the input from the plugin, parses it, and calls the actual update function. - */ - private async handleUpdateAsset(cp: CurrentPlugin, offs: bigint) { - const input = JSON.parse(cp.read(offs)!.text()); - await this.updateAsset(input); - } - - /** - * Host function wrapper for addAssetToAlbum. - * Reads the input from the plugin, parses it, and calls the actual add function. - */ - private async handleAddAssetToAlbum(cp: CurrentPlugin, offs: bigint) { - const input = JSON.parse(cp.read(offs)!.text()); - await this.addAssetToAlbum(input); - } - - /** - * Validates the JWT token and returns the auth context. - */ - private validateToken(authToken: string): { userId: string } { - try { - const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.pluginJwtSecret); - if (!auth.userId) { - throw new UnauthorizedException('Invalid token: missing userId'); - } - return auth; - } catch (error) { - this.logger.error('Token validation failed:', error); - throw new UnauthorizedException('Invalid token'); - } - } - - /** - * Updates an asset with the given properties. - */ - async updateAsset(input: { authToken: string } & Updateable & { id: string }) { - const { authToken, id, ...assetData } = input; - - // Validate token - const auth = this.validateToken(authToken); - - // Check access to the asset - await requireAccess(this.accessRepository, { - auth: { user: { id: auth.userId } } as any, - permission: Permission.AssetUpdate, - ids: [id], - }); - - this.logger.log(`Updating asset ${id} -- ${JSON.stringify(assetData)}`); - await this.assetRepository.update({ id, ...assetData }); - } - - /** - * Adds an asset to an album. - */ - async addAssetToAlbum(input: { authToken: string; assetId: string; albumId: string }) { - const { authToken, assetId, albumId } = input; - - // Validate token - const auth = this.validateToken(authToken); - - // Check access to both the asset and the album - await requireAccess(this.accessRepository, { - auth: { user: { id: auth.userId } } as any, - permission: Permission.AssetRead, - ids: [assetId], - }); - - await requireAccess(this.accessRepository, { - auth: { user: { id: auth.userId } } as any, - permission: Permission.AlbumUpdate, - ids: [albumId], - }); - - this.logger.log(`Adding asset ${assetId} to album ${albumId}`); - await this.albumRepository.addAssetIds(albumId, [assetId]); - return 0; - } -} diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts index d78b8940d3..6c1e0409d7 100644 --- a/server/src/services/plugin.service.ts +++ b/server/src/services/plugin.service.ts @@ -1,322 +1,19 @@ -import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism'; import { BadRequestException, Injectable } from '@nestjs/common'; -import { plainToInstance } from 'class-transformer'; -import { validateOrReject } from 'class-validator'; -import { join } from 'node:path'; -import { Asset, WorkflowAction, WorkflowFilter } from 'src/database'; -import { OnEvent, OnJob } from 'src/decorators'; -import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; -import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto'; -import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum'; -import { pluginTriggers } from 'src/plugins'; -import { ArgOf } from 'src/repositories/event.repository'; +import { mapPlugin, PluginResponseDto, PluginSearchDto } from 'src/dtos/plugin.dto'; import { BaseService } from 'src/services/base.service'; -import { PluginHostFunctions } from 'src/services/plugin-host.functions'; -import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types'; - -interface WorkflowContext { - authToken: string; - asset: Asset; -} - -interface PluginInput { - authToken: string; - config: T; - data: { - asset: Asset; - }; -} @Injectable() export class PluginService extends BaseService { - private pluginJwtSecret!: string; - private loadedPlugins: Map = new Map(); - private hostFunctions!: PluginHostFunctions; - - @OnEvent({ name: 'AppBootstrap' }) - async onBootstrap() { - this.pluginJwtSecret = this.cryptoRepository.randomBytesAsText(32); - - await this.loadPluginsFromManifests(); - - this.hostFunctions = new PluginHostFunctions( - this.assetRepository, - this.albumRepository, - this.accessRepository, - this.cryptoRepository, - this.logger, - this.pluginJwtSecret, - ); - - await this.loadPlugins(); - } - - getTriggers(): PluginTriggerResponseDto[] { - return pluginTriggers; - } - - // - // CRUD operations for plugins - // - async getAll(): Promise { - const plugins = await this.pluginRepository.getAllPlugins(); + async search(dto: PluginSearchDto): Promise { + const plugins = await this.pluginRepository.search(dto); return plugins.map((plugin) => mapPlugin(plugin)); } async get(id: string): Promise { - const plugin = await this.pluginRepository.getPlugin(id); + const plugin = await this.pluginRepository.get(id); if (!plugin) { throw new BadRequestException('Plugin not found'); } return mapPlugin(plugin); } - - /////////////////////////////////////////// - // Plugin Loader - ////////////////////////////////////////// - async loadPluginsFromManifests(): Promise { - // Load core plugin - const { resourcePaths, plugins } = this.configRepository.getEnv(); - const coreManifestPath = `${resourcePaths.corePlugin}/manifest.json`; - - const coreManifest = await this.readAndValidateManifest(coreManifestPath); - await this.loadPluginToDatabase(coreManifest, resourcePaths.corePlugin); - - this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`); - - // Load external plugins - if (plugins.external.allow && plugins.external.installFolder) { - await this.loadExternalPlugins(plugins.external.installFolder); - } - } - - private async loadExternalPlugins(installFolder: string): Promise { - try { - const entries = await this.pluginRepository.readDirectory(installFolder); - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const pluginFolder = join(installFolder, entry.name); - const manifestPath = join(pluginFolder, 'manifest.json'); - try { - const manifest = await this.readAndValidateManifest(manifestPath); - await this.loadPluginToDatabase(manifest, pluginFolder); - - this.logger.log(`Successfully processed external plugin: ${manifest.name} (version ${manifest.version})`); - } catch (error) { - this.logger.warn(`Failed to load external plugin from ${manifestPath}:`, error); - } - } - } catch (error) { - this.logger.error(`Failed to scan external plugins folder ${installFolder}:`, error); - } - } - - private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise { - const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name); - if (currentPlugin != null && currentPlugin.version === manifest.version) { - this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`); - return; - } - - const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath); - - this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`); - - for (const filter of filters) { - this.logger.log(`Upserted plugin filter: ${filter.methodName} (ID: ${filter.id})`); - } - - for (const action of actions) { - this.logger.log(`Upserted plugin action: ${action.methodName} (ID: ${action.id})`); - } - } - - private async readAndValidateManifest(manifestPath: string): Promise { - const content = await this.storageRepository.readTextFile(manifestPath); - const manifestData = JSON.parse(content); - const manifest = plainToInstance(PluginManifestDto, manifestData); - - await validateOrReject(manifest, { - whitelist: true, - forbidNonWhitelisted: true, - }); - - return manifest; - } - - /////////////////////////////////////////// - // Plugin Execution - /////////////////////////////////////////// - private async loadPlugins() { - const plugins = await this.pluginRepository.getAllPlugins(); - for (const plugin of plugins) { - try { - this.logger.debug(`Loading plugin: ${plugin.name} from ${plugin.wasmPath}`); - - const extismPlugin = await newPlugin(plugin.wasmPath, { - useWasi: true, - functions: this.hostFunctions.getHostFunctions(), - }); - - this.loadedPlugins.set(plugin.id, extismPlugin); - this.logger.log(`Successfully loaded plugin: ${plugin.name}`); - } catch (error) { - this.logger.error(`Failed to load plugin ${plugin.name}:`, error); - } - } - } - - @OnEvent({ name: 'AssetCreate' }) - async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) { - await this.handleTrigger(PluginTriggerType.AssetCreate, { - ownerId: asset.ownerId, - event: { userId: asset.ownerId, asset }, - }); - } - - private async handleTrigger( - triggerType: T, - params: { ownerId: string; event: WorkflowData[T] }, - ): Promise { - const workflows = await this.workflowRepository.getWorkflowByOwnerAndTrigger(params.ownerId, triggerType); - if (workflows.length === 0) { - return; - } - - const jobs: JobItem[] = workflows.map((workflow) => ({ - name: JobName.WorkflowRun, - data: { - id: workflow.id, - type: triggerType, - event: params.event, - } as IWorkflowJob, - })); - - await this.jobRepository.queueAll(jobs); - this.logger.debug(`Queued ${jobs.length} workflow execution jobs for trigger ${triggerType}`); - } - - @OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow }) - async handleWorkflowRun({ id: workflowId, type, event }: JobOf): Promise { - try { - const workflow = await this.workflowRepository.getWorkflow(workflowId); - if (!workflow) { - this.logger.error(`Workflow ${workflowId} not found`); - return JobStatus.Failed; - } - - const workflowFilters = await this.workflowRepository.getFilters(workflowId); - const workflowActions = await this.workflowRepository.getActions(workflowId); - - switch (type) { - case PluginTriggerType.AssetCreate: { - const data = event as WorkflowData[PluginTriggerType.AssetCreate]; - const asset = data.asset; - - const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret); - - const context = { - authToken, - asset, - }; - - const filtersPassed = await this.executeFilters(workflowFilters, context); - if (!filtersPassed) { - return JobStatus.Skipped; - } - - await this.executeActions(workflowActions, context); - this.logger.debug(`Workflow ${workflowId} executed successfully`); - return JobStatus.Success; - } - - case PluginTriggerType.PersonRecognized: { - this.logger.error('unimplemented'); - return JobStatus.Skipped; - } - - default: { - this.logger.error(`Unknown workflow trigger type: ${type}`); - return JobStatus.Failed; - } - } - } catch (error) { - this.logger.error(`Error executing workflow ${workflowId}:`, error); - return JobStatus.Failed; - } - } - - private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise { - for (const workflowFilter of workflowFilters) { - const filter = await this.pluginRepository.getFilter(workflowFilter.pluginFilterId); - if (!filter) { - this.logger.error(`Filter ${workflowFilter.pluginFilterId} not found`); - return false; - } - - const pluginInstance = this.loadedPlugins.get(filter.pluginId); - if (!pluginInstance) { - this.logger.error(`Plugin ${filter.pluginId} not loaded`); - return false; - } - - const filterInput: PluginInput = { - authToken: context.authToken, - config: workflowFilter.filterConfig, - data: { - asset: context.asset, - }, - }; - - this.logger.debug(`Calling filter ${filter.methodName} with input: ${JSON.stringify(filterInput)}`); - - const filterResult = await pluginInstance.call( - filter.methodName, - new TextEncoder().encode(JSON.stringify(filterInput)), - ); - - if (!filterResult) { - this.logger.error(`Filter ${filter.methodName} returned null`); - return false; - } - - const result = JSON.parse(filterResult.text()); - if (result.passed === false) { - this.logger.debug(`Filter ${filter.methodName} returned false, stopping workflow execution`); - return false; - } - } - - return true; - } - - private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise { - for (const workflowAction of workflowActions) { - const action = await this.pluginRepository.getAction(workflowAction.pluginActionId); - if (!action) { - throw new Error(`Action ${workflowAction.pluginActionId} not found`); - } - - const pluginInstance = this.loadedPlugins.get(action.pluginId); - if (!pluginInstance) { - throw new Error(`Plugin ${action.pluginId} not loaded`); - } - - const actionInput: PluginInput = { - authToken: context.authToken, - config: workflowAction.actionConfig, - data: { - asset: context.asset, - }, - }; - - this.logger.debug(`Calling action ${action.methodName} with input: ${JSON.stringify(actionInput)}`); - - await pluginInstance.call(action.methodName, JSON.stringify(actionInput)); - } - } } diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts new file mode 100644 index 0000000000..381f2d206d --- /dev/null +++ b/server/src/services/workflow-execution.service.ts @@ -0,0 +1,241 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import _ from 'lodash'; +import { join } from 'node:path'; +import { OnEvent, OnJob } from 'src/decorators'; +import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { + BootstrapEventPriority, + DatabaseLock, + ImmichWorker, + JobName, + JobStatus, + QueueName, + WorkflowTrigger, + WorkflowType, +} from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +import { BaseService } from 'src/services/base.service'; +import { JobOf, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from 'src/types'; + +type ExecuteOptions = { + read: (type: T) => Promise>; + write: (changes: Partial>) => Promise; +}; + +export class WorkflowExecutionService extends BaseService { + private jwtSecret!: string; + + @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginSync, workers: [ImmichWorker.Microservices] }) + async onPluginSync() { + await this.databaseRepository.withLock(DatabaseLock.PluginImport, async () => { + // TODO avoid importing plugins in each worker + // Can this use system metadata similar to geocoding? + + const { resourcePaths, plugins } = this.configRepository.getEnv(); + await this.importFolder(resourcePaths.corePlugin, { force: true }); + + if (plugins.external.allow && plugins.external.installFolder) { + await this.importFolders(plugins.external.installFolder); + } + }); + } + + @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginLoad, workers: [ImmichWorker.Microservices] }) + async onPluginLoad() { + this.jwtSecret = this.cryptoRepository.randomBytesAsText(32); + + const plugins = await this.pluginRepository.getForLoad(); + for (const plugin of plugins) { + try { + await this.pluginRepository.load(plugin, { + functions: { + addAssetToAlbum: () => { + return 0; + }, + }, + }); + + this.logger.log(`Successfully loaded plugin: ${plugin.name}`); + } catch (error) { + this.logger.error(`Failed to load plugin ${plugin.name}:`, error); + } + } + } + + private async importFolders(installFolder: string): Promise { + try { + const entries = await this.storageRepository.readdirWithTypes(installFolder); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + await this.importFolder(join(installFolder, entry.name)); + } + } catch (error) { + this.logger.error(`Failed to import plugins folder ${installFolder}:`, error); + } + } + + private async importFolder(folder: string, options?: { force?: boolean }) { + try { + const manifestPath = join(folder, 'manifest.json'); + const dto = await this.storageRepository.readJsonFile(manifestPath); + const manifest = plainToInstance(PluginManifestDto, dto); + const errors = await validate(manifest, { whitelist: true, forbidNonWhitelisted: true }); + if (errors.length > 0) { + this.logger.warn(`Invalid plugin manifest at ${manifestPath}:\n${errors.map((e) => e.toString()).join('\n')}`); + return; + } + + const existing = await this.pluginRepository.getByName(manifest.name); + if (existing && existing.version === manifest.version && options?.force !== true) { + return; + } + + const wasmPath = `${folder}/${manifest.wasmPath}`; + const wasmBytes = await this.storageRepository.readFile(wasmPath); + + const plugin = await this.pluginRepository.create( + { + enabled: true, + name: manifest.name, + title: manifest.title, + description: manifest.description, + author: manifest.author, + version: manifest.version, + wasmBytes, + }, + manifest.methods.map((method) => ({ + name: method.name, + title: method.title, + description: method.description, + types: method.types, + schema: method.schema, + })), + ); + + if (existing) { + this.logger.log( + `Upgraded plugin ${manifest.name} (${plugin.methods.length} methods) from ${existing.version} to ${manifest.version} `, + ); + } else { + this.logger.log( + `Imported plugin ${manifest.name}@${manifest.version} (${plugin.methods.length} methods) from ${folder}`, + ); + } + + return manifest; + } catch { + this.logger.warn(`Failed to import plugin from ${folder}:`); + } + } + + /** + * Validates the JWT token and returns the auth context. + */ + private validateToken(authToken: string): { userId: string } { + try { + const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.jwtSecret); + if (!auth.userId) { + throw new UnauthorizedException('Invalid token: missing userId'); + } + return auth; + } catch (error) { + this.logger.error('Token validation failed:', error); + throw new UnauthorizedException('Invalid token'); + } + } + + @OnEvent({ name: 'AssetCreate' }) + async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) { + const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate }; + const items = await this.workflowRepository.search(dto); + await this.jobRepository.queueAll( + items.map((workflow) => ({ + name: JobName.WorkflowAssetCreate, + data: { workflowId: workflow.id, assetId: asset.id }, + })), + ); + } + + @OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow }) + async handleWorkflowAssetCreate({ workflowId, assetId }: JobOf) { + await this.execute(workflowId, (type: WorkflowType) => { + switch (type) { + case WorkflowType.AssetV1: { + return >{ + read: async () => { + const asset = await this.workflowRepository.getForAssetV1(assetId); + return { asset }; + }, + write: async (changes) => { + if (changes.asset) { + await this.assetRepository.update({ + id: assetId, + ..._.omitBy( + { + isFavorite: changes.asset?.isFavorite, + visibility: changes.asset?.visibility, + }, + _.isUndefined, + ), + }); + } + }, + }; + } + } + }); + } + + private async execute(workflowId: string, getHandler: (type: WorkflowType) => ExecuteOptions | undefined) { + const workflow = await this.workflowRepository.getForWorkflowRun(workflowId); + if (!workflow) { + return; + } + + // TODO infer from steps + const type = 'AssetV1' as WorkflowType; + const handler = getHandler(type); + if (!handler) { + this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`); + return; + } + + try { + const { read, write } = handler; + let data = await read(type); + for (const step of workflow.steps) { + const payload: WorkflowEventPayload = { + trigger: workflow.trigger, + type, + config: step.config ?? {}, + workflow: { + id: workflowId, + stepId: step.id, + }, + data, + }; + + const result = await this.pluginRepository.callMethod(step, payload); + if (result?.changes) { + await write(result.changes); + data = await read(type); + } + + const shouldContinue = result?.workflow?.continue ?? true; + if (!shouldContinue) { + break; + } + } + + this.logger.debug(`Workflow ${workflowId} executed successfully`); + } catch (error) { + this.logger.error(`Error executing workflow ${workflowId}:`, error); + return JobStatus.Failed; + } + } +} diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts index 1a65182b1f..7149bfac06 100644 --- a/server/src/services/workflow.service.ts +++ b/server/src/services/workflow.service.ts @@ -1,159 +1,94 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { Workflow } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; import { - mapWorkflowAction, - mapWorkflowFilter, + mapWorkflow, + mapWorkflowStep, WorkflowCreateDto, WorkflowResponseDto, + WorkflowSearchDto, + WorkflowStepDto, + WorkflowStepResponseDto, + WorkflowTriggerResponseDto, WorkflowUpdateDto, } from 'src/dtos/workflow.dto'; -import { Permission, PluginContext, PluginTriggerType } from 'src/enum'; -import { pluginTriggers } from 'src/plugins'; - +import { Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; +import { getWorkflowTriggers, isMethodCompatible } from 'src/utils/workflow'; @Injectable() export class WorkflowService extends BaseService { - async create(auth: AuthDto, dto: WorkflowCreateDto): Promise { - const context = this.getContextForTrigger(dto.triggerType); - - const filterInserts = await this.validateAndMapFilters(dto.filters, context); - const actionInserts = await this.validateAndMapActions(dto.actions, context); - - const workflow = await this.workflowRepository.createWorkflow( - { - ownerId: auth.user.id, - triggerType: dto.triggerType, - name: dto.name, - description: dto.description || '', - enabled: dto.enabled ?? true, - }, - filterInserts, - actionInserts, - ); - - return this.mapWorkflow(workflow); + getTriggers(): WorkflowTriggerResponseDto[] { + return getWorkflowTriggers(); } - async getAll(auth: AuthDto): Promise { - const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id); - - return Promise.all(workflows.map((workflow) => this.mapWorkflow(workflow))); + async search(auth: AuthDto, dto: WorkflowSearchDto): Promise { + const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id }); + return workflows.map((workflow) => mapWorkflow(workflow)); } async get(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] }); const workflow = await this.findOrFail(id); - return this.mapWorkflow(workflow); + return mapWorkflow(workflow); + } + + async create(auth: AuthDto, dto: WorkflowCreateDto): Promise { + const workflow = await this.workflowRepository.create({ + ownerId: auth.user.id, + trigger: dto.trigger, + name: dto.name, + description: dto.description, + enabled: dto.enabled ?? true, + }); + + return mapWorkflow({ ...workflow, steps: [] }); } async update(auth: AuthDto, id: string, dto: WorkflowUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [id] }); - - if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) { - throw new BadRequestException('No fields to update'); - } - - const workflow = await this.findOrFail(id); - const context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType); - - const { filters, actions, ...workflowUpdate } = dto; - const filterInserts = filters && (await this.validateAndMapFilters(filters, context)); - const actionInserts = actions && (await this.validateAndMapActions(actions, context)); - - const updatedWorkflow = await this.workflowRepository.updateWorkflow( - id, - workflowUpdate, - filterInserts, - actionInserts, - ); - - return this.mapWorkflow(updatedWorkflow); + const workflow = await this.workflowRepository.update(id, dto); + return mapWorkflow(workflow); } async delete(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] }); - await this.workflowRepository.deleteWorkflow(id); + await this.workflowRepository.delete(id); } - private async validateAndMapFilters( - filters: Array<{ pluginFilterId: string; filterConfig?: any }>, - requiredContext: PluginContext, - ) { - for (const dto of filters) { - const filter = await this.pluginRepository.getFilter(dto.pluginFilterId); - if (!filter) { - throw new BadRequestException(`Invalid filter ID: ${dto.pluginFilterId}`); + async getSteps(auth: AuthDto, workflowId: string) { + await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [workflowId] }); + const steps = await this.workflowRepository.getSteps(workflowId); + return steps.map((step) => mapWorkflowStep(step)); + } + + async replaceSteps(auth: AuthDto, workflowId: string, dtos: WorkflowStepDto[]): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [workflowId] }); + + const workflow = await this.findOrFail(workflowId); + + // validate all steps have a common type that is compatible with the workflow trigger + for (const dto of dtos) { + const pluginMethod = await this.pluginRepository.getMethod(dto.pluginMethodId); + if (!pluginMethod) { + throw new BadRequestException(`Invalid method ID: ${dto.pluginMethodId}`); } - if (!filter.supportedContexts.includes(requiredContext)) { + if (!isMethodCompatible(pluginMethod, workflow.trigger)) { throw new BadRequestException( - `Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`, + `Method "${pluginMethod.title}" is incompatible with workflow trigger: "${workflow.trigger}"`, ); } } - return filters.map((dto, index) => ({ - pluginFilterId: dto.pluginFilterId, - filterConfig: dto.filterConfig || null, - order: index, - })); - } - - private async validateAndMapActions( - actions: Array<{ pluginActionId: string; actionConfig?: any }>, - requiredContext: PluginContext, - ) { - for (const dto of actions) { - const action = await this.pluginRepository.getAction(dto.pluginActionId); - if (!action) { - throw new BadRequestException(`Invalid action ID: ${dto.pluginActionId}`); - } - if (!action.supportedContexts.includes(requiredContext)) { - throw new BadRequestException( - `Action "${action.title}" does not support ${requiredContext} context. Supported contexts: ${action.supportedContexts.join(', ')}`, - ); - } - } - - return actions.map((dto, index) => ({ - pluginActionId: dto.pluginActionId, - actionConfig: dto.actionConfig || null, - order: index, - })); - } - - private getContextForTrigger(type: PluginTriggerType) { - const trigger = pluginTriggers.find((t) => t.type === type); - if (!trigger) { - throw new BadRequestException(`Invalid trigger type: ${type}`); - } - return trigger.contextType; + const steps = await this.workflowRepository.replaceSteps(workflowId, dtos); + return steps.map((step) => mapWorkflowStep(step)); } private async findOrFail(id: string) { - const workflow = await this.workflowRepository.getWorkflow(id); + const workflow = await this.workflowRepository.get(id); if (!workflow) { throw new BadRequestException('Workflow not found'); } return workflow; } - - private async mapWorkflow(workflow: Workflow): Promise { - const filters = await this.workflowRepository.getFilters(workflow.id); - const actions = await this.workflowRepository.getActions(workflow.id); - - return { - id: workflow.id, - ownerId: workflow.ownerId, - triggerType: workflow.triggerType, - name: workflow.name, - description: workflow.description, - createdAt: workflow.createdAt.toISOString(), - enabled: workflow.enabled, - filters: filters.map((f) => mapWorkflowFilter(f)), - actions: actions.map((a) => mapWorkflowAction(a)), - }; - } } diff --git a/server/src/types.ts b/server/src/types.ts index 8cf128f497..7ccde288f8 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,18 +1,19 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; -import { Asset, AssetFile } from 'src/database'; +import { AssetFile } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { AssetOrder, + AssetStatus, AssetType, + AssetVisibility, ExifOrientation, ImageFormat, JobName, MemoryType, - PluginTriggerType, QueueName, StorageFolder, SyncEntityType, @@ -20,6 +21,8 @@ import { TranscodeTarget, UserMetadataKey, VideoCodec, + WorkflowTrigger, + WorkflowType, } from 'src/enum'; export type DeepPartial = @@ -259,22 +262,105 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { recipientId: string; } -export interface WorkflowData { - [PluginTriggerType.AssetCreate]: { - userId: string; - asset: Asset; +export type AssetV1 = { + asset: { + id: string; + ownerId: string; + type: AssetType; + originalPath: string; + fileCreatedAt: Date; + fileModifiedAt: Date; + isFavorite: boolean; + checksum: Buffer; // sha1 checksum + livePhotoVideoId: string | null; + updatedAt: Date; + createdAt: Date; + originalFileName: string; + isOffline: boolean; + libraryId: string | null; + isExternal: boolean; + deletedAt: Date | null; + localDateTime: Date; + stackId: string | null; + duplicateId: string | null; + status: AssetStatus; + visibility: AssetVisibility; + isEdited: boolean; + exifInfo: { + make: string | null; + model: string | null; + exifImageWidth: number | null; + exifImageHeight: number | null; + fileSizeInByte: number | null; + orientation: string | null; + dateTimeOriginal: Date | null; + modifyDate: Date | null; + lensModel: string | null; + fNumber: number | null; + focalLength: number | null; + iso: number | null; + latitude: number | null; + longitude: number | null; + city: string | null; + state: string | null; + country: string | null; + description: string | null; + fps: number | null; + exposureTime: string | null; + livePhotoCID: string | null; + timeZone: string | null; + projectionType: string | null; + profileDescription: string | null; + colorspace: string | null; + bitsPerSample: number | null; + autoStackId: string | null; + rating: number | null; + tags: string[] | null; + updatedAt: Date | null; + } | null; }; - [PluginTriggerType.PersonRecognized]: { - personId: string; - assetId: string; - }; -} +}; -export interface IWorkflowJob { +export type AssetPersonV1 = AssetV1 & { + person: { + id: string; + name: string; + }; +}; + +export type WorkflowEventMap = { + [WorkflowType.AssetV1]: AssetV1; + [WorkflowType.AssetPersonV1]: AssetPersonV1; +}; + +export type WorkflowEventData = WorkflowEventMap[T]; + +export type IWorkflowJob = { id: string; + trigger: WorkflowTrigger; type: T; - event: WorkflowData[T]; -} +}; + +export type WorkflowEventPayload = { + trigger: WorkflowTrigger; + type: T; + data: WorkflowEventData; + config: WorkflowStepConfig; + workflow: { + id: string; + stepId: string; + }; +}; + +export type WorkflowResponse = { + workflow?: { + /** stop the workflow */ + continue?: boolean; + }; + changes?: Partial>; + /** data to be passed to the next workflow step */ + data?: Record; +}; export interface JobCounts { active: number; @@ -385,7 +471,7 @@ export type JobItem = | { name: JobName.Ocr; data: IEntityJob } // Workflow - | { name: JobName.WorkflowRun; data: IWorkflowJob } + | { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } } // Editor | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }; @@ -548,3 +634,29 @@ export interface UserMetadata extends Record; + required?: string[]; +}; + +export interface JSONSchema { + type: 'object'; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + description?: string; +} + +export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }; + +export type WorkflowStepConfig = { + [key: string]: ConfigValue; +}; diff --git a/server/src/types/plugin-schema.types.ts b/server/src/types/plugin-schema.types.ts deleted file mode 100644 index 793bb3c1ff..0000000000 --- a/server/src/types/plugin-schema.types.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * JSON Schema types for plugin configuration schemas - * Based on JSON Schema Draft 7 - */ - -export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'; - -export interface JSONSchemaProperty { - type?: JSONSchemaType | JSONSchemaType[]; - description?: string; - default?: any; - enum?: any[]; - items?: JSONSchemaProperty; - properties?: Record; - required?: string[]; - additionalProperties?: boolean | JSONSchemaProperty; -} - -export interface JSONSchema { - type: 'object'; - properties?: Record; - required?: string[]; - additionalProperties?: boolean; - description?: string; -} - -export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }; - -export interface FilterConfig { - [key: string]: ConfigValue; -} - -export interface ActionConfig { - [key: string]: ConfigValue; -} diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts new file mode 100644 index 0000000000..c9cf6db844 --- /dev/null +++ b/server/src/utils/workflow.ts @@ -0,0 +1,31 @@ +import { WorkflowTrigger, WorkflowType } from 'src/enum'; + +export const triggerMap: Record = { + [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], + [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetV1], +}; + +export const getWorkflowTriggers = () => + Object.entries(triggerMap).map(([trigger, types]) => ({ trigger: trigger as WorkflowTrigger, types })); + +/** some types extend other types and have implied compatibility */ +const inferredMap: Record = { + [WorkflowType.AssetV1]: [], + [WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1], +}; + +const withImpliedItems = (type: WorkflowType): WorkflowType[] => [type, ...inferredMap[type]]; + +export const isMethodCompatible = (pluginMethod: { types: WorkflowType[] }, trigger: WorkflowTrigger) => { + const validTypes = triggerMap[trigger]; + const pluginCompatibility = pluginMethod.types.map((type) => withImpliedItems(type)); + for (const requested of validTypes) { + for (const pluginCompatibilityGroup of pluginCompatibility) { + if (pluginCompatibilityGroup.includes(requested)) { + return true; + } + } + } + + return false; +}; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 53bf78b5b8..c1d5f9a6a0 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -410,7 +410,6 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case OcrRepository: case PartnerRepository: case PersonRepository: - case PluginRepository: case SearchRepository: case SessionRepository: case SharedLinkRepository: @@ -442,6 +441,10 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { return new key(LoggingRepository.create()); } + case PluginRepository: { + return new key(db, LoggingRepository.create()); + } + case StorageRepository: { return new key(LoggingRepository.create()); } @@ -473,7 +476,6 @@ const newMockRepository = (key: ClassConstructor) => { case OcrRepository: case PartnerRepository: case PersonRepository: - case PluginRepository: case SessionRepository: case SyncRepository: case SyncCheckpointRepository: diff --git a/server/test/medium/specs/services/plugin.service.spec.ts b/server/test/medium/specs/services/plugin.service.spec.ts index b70e8e8d54..e651285ae4 100644 --- a/server/test/medium/specs/services/plugin.service.spec.ts +++ b/server/test/medium/specs/services/plugin.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { PluginContext } from 'src/enum'; +import { WorkflowType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; @@ -9,7 +9,8 @@ import { newMediumService } from 'test/medium.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; -let pluginRepo: PluginRepository; + +const wasmBytes = Buffer.from('some-wasm-binary-data'); const setup = (db?: Kysely) => { return newMediumService(PluginService, { @@ -21,7 +22,6 @@ const setup = (db?: Kysely) => { beforeAll(async () => { defaultDatabase = await getKyselyDB(); - pluginRepo = new PluginRepository(defaultDatabase); }); afterEach(async () => { @@ -32,214 +32,202 @@ describe(PluginService.name, () => { describe('getAll', () => { it('should return empty array when no plugins exist', async () => { const { sut } = setup(); - - const plugins = await sut.getAll(); - - expect(plugins).toEqual([]); + await expect(sut.search({})).resolves.toEqual([]); }); - it('should return plugin without filters and actions', async () => { - const { sut } = setup(); + it('should return plugin without methods', async () => { + const { ctx, sut } = setup(); - const result = await pluginRepo.loadPlugin( + const result = await ctx.get(PluginRepository).create( { + enabled: true, name: 'test-plugin', title: 'Test Plugin', description: 'A test plugin', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/test.wasm' }, + wasmBytes, }, - '/test/base/path', + [], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(1); expect(plugins[0]).toMatchObject({ - id: result.plugin.id, + id: result.id, name: 'test-plugin', description: 'A test plugin', author: 'Test Author', version: '1.0.0', - filters: [], - actions: [], + methods: [], }); }); - it('should return plugin with filters and actions', async () => { - const { sut } = setup(); + it('should return plugin with multiple methods', async () => { + const { ctx, sut } = setup(); - const result = await pluginRepo.loadPlugin( + const result = await ctx.get(PluginRepository).create( { + enabled: true, name: 'full-plugin', title: 'Full Plugin', - description: 'A plugin with filters and actions', + description: 'A plugin with multiple methods', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/full.wasm' }, - filters: [ - { - methodName: 'test-filter', - title: 'Test Filter', - description: 'A test filter', - supportedContexts: [PluginContext.Asset], - schema: { type: 'object', properties: {} }, - }, - ], - actions: [ - { - methodName: 'test-action', - title: 'Test Action', - description: 'A test action', - supportedContexts: [PluginContext.Asset], - schema: { type: 'object', properties: {} }, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + types: [WorkflowType.AssetV1], + schema: { type: 'object', properties: {} }, + }, + { + name: 'test-action', + title: 'Test Action', + description: 'A test action', + types: [WorkflowType.AssetV1], + schema: { type: 'object', properties: {} }, + }, + ], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(1); expect(plugins[0]).toMatchObject({ - id: result.plugin.id, + id: result.id, name: 'full-plugin', - filters: [ + methods: [ { - id: result.filters[0].id, - pluginId: result.plugin.id, - methodName: 'test-filter', + id: result.methods[0].id, + pluginId: result.id, + name: 'test-filter', title: 'Test Filter', description: 'A test filter', - supportedContexts: [PluginContext.Asset], + types: [WorkflowType.AssetV1], schema: { type: 'object', properties: {} }, }, - ], - actions: [ { - id: result.actions[0].id, - pluginId: result.plugin.id, - methodName: 'test-action', + id: result.methods[1].id, + pluginId: result.id, + name: 'test-action', title: 'Test Action', description: 'A test action', - supportedContexts: [PluginContext.Asset], + types: [WorkflowType.AssetV1], schema: { type: 'object', properties: {} }, }, ], }); }); - it('should return multiple plugins with their respective filters and actions', async () => { - const { sut } = setup(); + it('should return multiple plugins with their respective methods', async () => { + const { ctx, sut } = setup(); - await pluginRepo.loadPlugin( + await ctx.get(PluginRepository).create( { + enabled: true, name: 'plugin-1', title: 'Plugin 1', description: 'First plugin', author: 'Author 1', version: '1.0.0', - wasm: { path: '/path/to/plugin1.wasm' }, - filters: [ - { - methodName: 'filter-1', - title: 'Filter 1', - description: 'Filter for plugin 1', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'filter-1', + title: 'Filter 1', + description: 'Filter for plugin 1', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + ], ); - await pluginRepo.loadPlugin( + await ctx.get(PluginRepository).create( { + enabled: true, name: 'plugin-2', title: 'Plugin 2', description: 'Second plugin', author: 'Author 2', version: '2.0.0', - wasm: { path: '/path/to/plugin2.wasm' }, - actions: [ - { - methodName: 'action-2', - title: 'Action 2', - description: 'Action for plugin 2', - supportedContexts: [PluginContext.Album], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'action-2', + title: 'Action 2', + description: 'Action for plugin 2', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + ], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(2); expect(plugins[0].name).toBe('plugin-1'); - expect(plugins[0].filters).toHaveLength(1); - expect(plugins[0].actions).toHaveLength(0); + expect(plugins[0].methods).toHaveLength(1); expect(plugins[1].name).toBe('plugin-2'); - expect(plugins[1].filters).toHaveLength(0); - expect(plugins[1].actions).toHaveLength(1); + expect(plugins[1].methods).toHaveLength(1); }); - it('should handle plugin with multiple filters and actions', async () => { - const { sut } = setup(); + it('should handle plugin with multiple methods', async () => { + const { ctx, sut } = setup(); - await pluginRepo.loadPlugin( + await ctx.get(PluginRepository).create( { + enabled: true, name: 'multi-plugin', title: 'Multi Plugin', - description: 'Plugin with multiple items', + description: 'Plugin with multiple methods', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/multi.wasm' }, - filters: [ - { - methodName: 'filter-a', - title: 'Filter A', - description: 'First filter', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - { - methodName: 'filter-b', - title: 'Filter B', - description: 'Second filter', - supportedContexts: [PluginContext.Album], - schema: undefined, - }, - ], - actions: [ - { - methodName: 'action-x', - title: 'Action X', - description: 'First action', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - { - methodName: 'action-y', - title: 'Action Y', - description: 'Second action', - supportedContexts: [PluginContext.Person], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'filter-a', + title: 'Filter A', + description: 'First filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'filter-b', + title: 'Filter B', + description: 'Second filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'action-x', + title: 'Action X', + description: 'First action', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'action-y', + title: 'Action Y', + description: 'Second action', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + ], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(1); - expect(plugins[0].filters).toHaveLength(2); - expect(plugins[0].actions).toHaveLength(2); + expect(plugins[0].methods).toHaveLength(4); }); }); @@ -250,55 +238,51 @@ describe(PluginService.name, () => { await expect(sut.get('00000000-0000-0000-0000-000000000000')).rejects.toThrow('Plugin not found'); }); - it('should return single plugin with filters and actions', async () => { - const { sut } = setup(); + it('should return single plugin with methods', async () => { + const { ctx, sut } = setup(); - const result = await pluginRepo.loadPlugin( + const result = await ctx.get(PluginRepository).create( { + enabled: true, name: 'single-plugin', title: 'Single Plugin', description: 'A single plugin', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/single.wasm' }, - filters: [ - { - methodName: 'single-filter', - title: 'Single Filter', - description: 'A single filter', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], - actions: [ - { - methodName: 'single-action', - title: 'Single Action', - description: 'A single action', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', - ); - - const pluginResult = await sut.get(result.plugin.id); - - expect(pluginResult).toMatchObject({ - id: result.plugin.id, - name: 'single-plugin', - filters: [ + [ { - id: result.filters[0].id, - methodName: 'single-filter', + name: 'single-filter', title: 'Single Filter', + description: 'A single filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'single-action', + title: 'Single Action', + description: 'A single action', + types: [WorkflowType.AssetV1], + schema: undefined, }, ], - actions: [ + ); + + const pluginResult = await sut.get(result.id); + + expect(pluginResult).toMatchObject({ + id: result.id, + name: 'single-plugin', + methods: [ { - id: result.actions[0].id, - methodName: 'single-action', + id: result.methods[0].id, + name: 'single-filter', + title: 'Single Filter', + }, + { + id: result.methods[1].id, + name: 'single-action', title: 'Single Action', }, ], diff --git a/server/test/medium/specs/services/workflow.service.spec.ts b/server/test/medium/specs/services/workflow.service.spec.ts index 229737c531..8d79557bdd 100644 --- a/server/test/medium/specs/services/workflow.service.spec.ts +++ b/server/test/medium/specs/services/workflow.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { PluginContext, PluginTriggerType } from 'src/enum'; +import { WorkflowTrigger, WorkflowType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; @@ -20,53 +20,48 @@ const setup = (db?: Kysely) => { }); }; +const wasmBytes = Buffer.from('random-wasm-bytes'); + beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); describe(WorkflowService.name, () => { let testPluginId: string; - let testFilterId: string; - let testActionId: string; beforeAll(async () => { - // Create a test plugin with filters and actions once for all tests - const pluginRepo = new PluginRepository(defaultDatabase); - const result = await pluginRepo.loadPlugin( + const { ctx } = setup(); + // Create a test plugin with methods and actions once for all tests + const pluginRepo = ctx.get(PluginRepository); + const result = await pluginRepo.create( { + enabled: true, name: 'test-core-plugin', title: 'Test Core Plugin', description: 'A test core plugin for workflow tests', author: 'Test Author', version: '1.0.0', - wasm: { - path: '/test/path.wasm', - }, - filters: [ - { - methodName: 'test-filter', - title: 'Test Filter', - description: 'A test filter', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], - actions: [ - { - methodName: 'test-action', - title: 'Test Action', - description: 'A test action', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], + wasmBytes, }, - '/plugins/test-core-plugin', + [ + { + name: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'test-action', + title: 'Test Action', + description: 'A test action', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + ], ); - testPluginId = result.plugin.id; - testFilterId = result.filters[0].id; - testActionId = result.actions[0].id; + testPluginId = result.id; }); afterAll(async () => { @@ -74,229 +69,48 @@ describe(WorkflowService.name, () => { }); describe('create', () => { - it('should create a workflow without filters or actions', async () => { + it('should create a workflow without methods or actions', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const auth = factory.auth({ user }); const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'test-workflow', description: 'A test workflow', enabled: true, - filters: [], - actions: [], }); expect(workflow).toMatchObject({ id: expect.any(String), ownerId: user.id, - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'test-workflow', description: 'A test workflow', enabled: true, - filters: [], - actions: [], }); }); - it('should create a workflow with filters and actions', async () => { + it('should create a workflow with methods and actions', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const auth = factory.auth({ user }); const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'test-workflow-with-relations', - description: 'A test workflow with filters and actions', + description: 'A test workflow with methods and actions', enabled: true, - filters: [ - { - pluginFilterId: testFilterId, - filterConfig: { key: 'value' }, - }, - ], - actions: [ - { - pluginActionId: testActionId, - actionConfig: { action: 'test' }, - }, - ], }); expect(workflow).toMatchObject({ id: expect.any(String), ownerId: user.id, - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'test-workflow-with-relations', enabled: true, }); - - expect(workflow.filters).toHaveLength(1); - expect(workflow.filters[0]).toMatchObject({ - id: expect.any(String), - workflowId: workflow.id, - pluginFilterId: testFilterId, - filterConfig: { key: 'value' }, - order: 0, - }); - - expect(workflow.actions).toHaveLength(1); - expect(workflow.actions[0]).toMatchObject({ - id: expect.any(String), - workflowId: workflow.id, - pluginActionId: testActionId, - actionConfig: { action: 'test' }, - order: 0, - }); - }); - - it('should throw error when creating workflow with invalid filter', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-workflow', - description: 'A workflow with invalid filter', - enabled: true, - filters: [{ pluginFilterId: factory.uuid(), filterConfig: { key: 'value' } }], - actions: [], - }), - ).rejects.toThrow('Invalid filter ID'); - }); - - it('should throw error when creating workflow with invalid action', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-workflow', - description: 'A workflow with invalid action', - enabled: true, - filters: [], - actions: [{ pluginActionId: factory.uuid(), actionConfig: { action: 'test' } }], - }), - ).rejects.toThrow('Invalid action ID'); - }); - - it('should throw error when filter does not support trigger context', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - // Create a plugin with a filter that only supports Album context - const pluginRepo = new PluginRepository(defaultDatabase); - const result = await pluginRepo.loadPlugin( - { - name: 'album-only-plugin', - title: 'Album Only Plugin', - description: 'Plugin with album-only filter', - author: 'Test Author', - version: '1.0.0', - wasm: { path: '/test/album-plugin.wasm' }, - filters: [ - { - methodName: 'album-filter', - title: 'Album Filter', - description: 'A filter that only works with albums', - supportedContexts: [PluginContext.Album], - schema: undefined, - }, - ], - }, - '/plugins/test-core-plugin', - ); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-context-workflow', - description: 'A workflow with context mismatch', - enabled: true, - filters: [{ pluginFilterId: result.filters[0].id }], - actions: [], - }), - ).rejects.toThrow('does not support asset context'); - }); - - it('should throw error when action does not support trigger context', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - // Create a plugin with an action that only supports Person context - const pluginRepo = new PluginRepository(defaultDatabase); - const result = await pluginRepo.loadPlugin( - { - name: 'person-only-plugin', - title: 'Person Only Plugin', - description: 'Plugin with person-only action', - author: 'Test Author', - version: '1.0.0', - wasm: { path: '/test/person-plugin.wasm' }, - actions: [ - { - methodName: 'person-action', - title: 'Person Action', - description: 'An action that only works with persons', - supportedContexts: [PluginContext.Person], - schema: undefined, - }, - ], - }, - '/plugins/test-core-plugin', - ); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-context-workflow', - description: 'A workflow with context mismatch', - enabled: true, - filters: [], - actions: [{ pluginActionId: result.actions[0].id }], - }), - ).rejects.toThrow('does not support asset context'); - }); - - it('should create workflow with multiple filters and actions in correct order', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'multi-step-workflow', - description: 'A workflow with multiple filters and actions', - enabled: true, - filters: [ - { pluginFilterId: testFilterId, filterConfig: { step: 1 } }, - { pluginFilterId: testFilterId, filterConfig: { step: 2 } }, - ], - actions: [ - { pluginActionId: testActionId, actionConfig: { step: 1 } }, - { pluginActionId: testActionId, actionConfig: { step: 2 } }, - { pluginActionId: testActionId, actionConfig: { step: 3 } }, - ], - }); - - expect(workflow.filters).toHaveLength(2); - expect(workflow.filters[0].order).toBe(0); - expect(workflow.filters[0].filterConfig).toEqual({ step: 1 }); - expect(workflow.filters[1].order).toBe(1); - expect(workflow.filters[1].filterConfig).toEqual({ step: 2 }); - - expect(workflow.actions).toHaveLength(3); - expect(workflow.actions[0].order).toBe(0); - expect(workflow.actions[1].order).toBe(1); - expect(workflow.actions[2].order).toBe(2); }); }); @@ -307,24 +121,20 @@ describe(WorkflowService.name, () => { const auth = factory.auth({ user }); const workflow1 = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'workflow-1', description: 'First workflow', enabled: true, - filters: [], - actions: [], }); const workflow2 = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'workflow-2', description: 'Second workflow', enabled: false, - filters: [], - actions: [], }); - const workflows = await sut.getAll(auth); + const workflows = await sut.search(auth, {}); expect(workflows).toHaveLength(2); expect(workflows).toEqual( @@ -340,7 +150,7 @@ describe(WorkflowService.name, () => { const { user } = await ctx.newUser(); const auth = factory.auth({ user }); - const workflows = await sut.getAll(auth); + const workflows = await sut.search(auth, {}); expect(workflows).toEqual([]); }); @@ -353,424 +163,15 @@ describe(WorkflowService.name, () => { const auth2 = factory.auth({ user: user2 }); await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'user1-workflow', description: 'User 1 workflow', enabled: true, - filters: [], - actions: [], }); - const user2Workflows = await sut.getAll(auth2); + const user2Workflows = await sut.search(auth2, {}); expect(user2Workflows).toEqual([]); }); }); - - describe('get', () => { - it('should return a specific workflow by id', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'A test workflow', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { key: 'value' } }], - actions: [{ pluginActionId: testActionId, actionConfig: { action: 'test' } }], - }); - - const workflow = await sut.get(auth, created.id); - - expect(workflow).toMatchObject({ - id: created.id, - name: 'test-workflow', - description: 'A test workflow', - enabled: true, - }); - expect(workflow.filters).toHaveLength(1); - expect(workflow.actions).toHaveLength(1); - }); - - it('should throw error when workflow does not exist', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect(sut.get(auth, '66da82df-e424-4bf4-b6f3-5d8e71620dae')).rejects.toThrow(); - }); - - it('should throw error when user does not have access to workflow', async () => { - const { sut, ctx } = setup(); - const { user: user1 } = await ctx.newUser(); - const { user: user2 } = await ctx.newUser(); - const auth1 = factory.auth({ user: user1 }); - const auth2 = factory.auth({ user: user2 }); - - const workflow = await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, - name: 'private-workflow', - description: 'Private workflow', - enabled: true, - filters: [], - actions: [], - }); - - await expect(sut.get(auth2, workflow.id)).rejects.toThrow(); - }); - }); - - describe('update', () => { - it('should update workflow basic fields', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'original-workflow', - description: 'Original description', - enabled: true, - filters: [], - actions: [], - }); - - const updated = await sut.update(auth, created.id, { - name: 'updated-workflow', - description: 'Updated description', - enabled: false, - }); - - expect(updated).toMatchObject({ - id: created.id, - name: 'updated-workflow', - description: 'Updated description', - enabled: false, - }); - }); - - it('should update workflow filters', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { old: 'config' } }], - actions: [], - }); - - const updated = await sut.update(auth, created.id, { - filters: [ - { pluginFilterId: testFilterId, filterConfig: { new: 'config' } }, - { pluginFilterId: testFilterId, filterConfig: { second: 'filter' } }, - ], - }); - - expect(updated.filters).toHaveLength(2); - expect(updated.filters[0].filterConfig).toEqual({ new: 'config' }); - expect(updated.filters[1].filterConfig).toEqual({ second: 'filter' }); - }); - - it('should update workflow actions', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [{ pluginActionId: testActionId, actionConfig: { old: 'config' } }], - }); - - const updated = await sut.update(auth, created.id, { - actions: [ - { pluginActionId: testActionId, actionConfig: { new: 'config' } }, - { pluginActionId: testActionId, actionConfig: { second: 'action' } }, - ], - }); - - expect(updated.actions).toHaveLength(2); - expect(updated.actions[0].actionConfig).toEqual({ new: 'config' }); - expect(updated.actions[1].actionConfig).toEqual({ second: 'action' }); - }); - - it('should clear filters when updated with empty array', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { key: 'value' } }], - actions: [], - }); - - const updated = await sut.update(auth, created.id, { - filters: [], - }); - - expect(updated.filters).toHaveLength(0); - }); - - it('should throw error when no fields to update', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await expect(sut.update(auth, created.id, {})).rejects.toThrow('No fields to update'); - }); - - it('should throw error when updating non-existent workflow', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect(sut.update(auth, factory.uuid(), { name: 'updated-name' })).rejects.toThrow(); - }); - - it('should throw error when user does not have access to update workflow', async () => { - const { sut, ctx } = setup(); - const { user: user1 } = await ctx.newUser(); - const { user: user2 } = await ctx.newUser(); - const auth1 = factory.auth({ user: user1 }); - const auth2 = factory.auth({ user: user2 }); - - const workflow = await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, - name: 'private-workflow', - description: 'Private', - enabled: true, - filters: [], - actions: [], - }); - - await expect( - sut.update(auth2, workflow.id, { - name: 'hacked-workflow', - }), - ).rejects.toThrow(); - }); - - it('should throw error when updating with invalid filter', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await expect( - sut.update(auth, created.id, { - filters: [{ pluginFilterId: factory.uuid(), filterConfig: {} }], - }), - ).rejects.toThrow(); - }); - - it('should throw error when updating with invalid action', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await expect( - sut.update(auth, created.id, { actions: [{ pluginActionId: factory.uuid(), actionConfig: {} }] }), - ).rejects.toThrow(); - }); - - it('should update trigger type', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.PersonRecognized, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await sut.update(auth, created.id, { - triggerType: PluginTriggerType.AssetCreate, - }); - - const fetched = await sut.get(auth, created.id); - expect(fetched.triggerType).toBe(PluginTriggerType.AssetCreate); - }); - - it('should add filters', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await sut.update(auth, created.id, { - filters: [ - { pluginFilterId: testFilterId, filterConfig: { first: true } }, - { pluginFilterId: testFilterId, filterConfig: { second: true } }, - ], - }); - - const fetched = await sut.get(auth, created.id); - expect(fetched.filters).toHaveLength(2); - expect(fetched.filters[0].filterConfig).toEqual({ first: true }); - expect(fetched.filters[1].filterConfig).toEqual({ second: true }); - }); - - it('should replace existing filters', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { original: true } }], - actions: [], - }); - - await sut.update(auth, created.id, { - filters: [{ pluginFilterId: testFilterId, filterConfig: { replaced: true } }], - }); - - const fetched = await sut.get(auth, created.id); - expect(fetched.filters).toHaveLength(1); - expect(fetched.filters[0].filterConfig).toEqual({ replaced: true }); - }); - - it('should remove existing filters', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { toRemove: true } }], - actions: [], - }); - - await sut.update(auth, created.id, { - filters: [], - }); - - const fetched = await sut.get(auth, created.id); - expect(fetched.filters).toHaveLength(0); - }); - }); - - describe('delete', () => { - it('should delete a workflow', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await sut.delete(auth, workflow.id); - - await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); - }); - - it('should delete workflow with filters and actions', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: {} }], - actions: [{ pluginActionId: testActionId, actionConfig: {} }], - }); - - await sut.delete(auth, workflow.id); - - await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); - }); - - it('should throw error when deleting non-existent workflow', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect(sut.delete(auth, factory.uuid())).rejects.toThrow(); - }); - - it('should throw error when user does not have access to delete workflow', async () => { - const { sut, ctx } = setup(); - const { user: user1 } = await ctx.newUser(); - const { user: user2 } = await ctx.newUser(); - const auth1 = factory.auth({ user: user1 }); - const auth2 = factory.auth({ user: user2 }); - - const workflow = await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, - name: 'private-workflow', - description: 'Private', - enabled: true, - filters: [], - actions: [], - }); - - await expect(sut.delete(auth2, workflow.id)).rejects.toThrow(); - }); - }); }); diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts new file mode 100644 index 0000000000..fab415bc51 --- /dev/null +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -0,0 +1,188 @@ +import { Kysely } from 'kysely'; +import { AssetVisibility, WorkflowTrigger } from 'src/enum'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; +import { DB } from 'src/schema'; +import { WorkflowExecutionService } from 'src/services/workflow-execution.service'; +import { WorkflowStepConfig } from 'src/types'; +import { MediumTestContext } from 'test/medium.factory'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { getKyselyDB } from 'test/utils'; + +let initialized = false; + +class WorkflowTestContext extends MediumTestContext { + constructor(database: Kysely) { + super(WorkflowExecutionService, { + database, + real: [ + AssetRepository, + CryptoRepository, + DatabaseRepository, + LoggingRepository, + StorageRepository, + PluginRepository, + WorkflowRepository, + ], + mock: [ConfigRepository], + }); + } + + async init() { + if (initialized) { + return; + } + + const mockData = mockEnvData({}); + mockData.resourcePaths.corePlugin = '../plugins'; + mockData.plugins.external.allow = false; + this.getMock(ConfigRepository).getEnv.mockReturnValue(mockData); + + await this.sut.onPluginSync(); + await this.sut.onPluginLoad(); + + initialized = true; + } +} + +type WorkflowTemplate = { + ownerId: string; + trigger: WorkflowTrigger; + steps: WorkflowTemplateStep[]; +}; + +type WorkflowTemplateStep = { + action: string; + config?: WorkflowStepConfig; +}; + +// TODO move this into the service and add support in the API +const createWorkflow = async (template: WorkflowTemplate) => { + const workflowRepo = ctx.get(WorkflowRepository); + const pluginRepo = ctx.get(PluginRepository); + + const workflow = await workflowRepo.create({ + enabled: true, + name: 'Test workflow', + description: 'A workflow to test the core plugin', + ownerId: template.ownerId, + trigger: template.trigger, + }); + + const plugins = await pluginRepo.search({ enabled: true }); + const pluginMethods = plugins.flatMap((plugin) => plugin.methods.map((method) => ({ ...method, plugin }))); + + const REF_REGEX = /^(?[^@#\s]+)(?:@(?[^#\s]*))?#(?[^@#\s]+)$/; + + const resolveMethod = (ref: string) => { + const matches = REF_REGEX.exec(ref); + const pluginName = matches?.groups?.name; + const version = matches?.groups?.version; + const methodName = matches?.groups?.method; + + const method = pluginMethods.find( + (method) => + // same method name + methodName === method.name && + // same plugin name + pluginName === method.plugin.name && + // optional plugin version + (!version || version === method.plugin.version), + ); + if (!method) { + throw new Error(`Plugin method not found: ${pluginName}@${version}#${methodName}`); + } + + return method; + }; + + const steps = await workflowRepo.replaceSteps( + workflow.id, + template.steps.map((step) => ({ + pluginMethodId: resolveMethod(step.action).id, + config: step.config, + })), + ); + + return { ...workflow, steps }; +}; + +let ctx: WorkflowTestContext; + +beforeAll(async () => { + const db = await getKyselyDB(); + ctx = new WorkflowTestContext(db); + await ctx.init(); +}); + +describe('core plugin', () => { + it('should archive an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ action: 'immich-core#assetArchive' }], + }); + + await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Archive, + }); + }); + + it('should unarchive an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Archive }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ action: 'immich-core#assetArchive', config: { inverse: true } }], + }); + + await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Timeline, + }); + }); + + it('should favorite an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ action: 'immich-core#assetFavorite' }], + }); + + await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); + }); + + it('should unfavorite an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ action: 'immich-core#assetFavorite', config: { inverse: true } }], + }); + + await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false }); + }); +}); diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 85c72b6c10..334d7d0d53 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -53,7 +53,8 @@ export const newStorageRepositoryMock = (): Mocked['readJsonFile'], + readdirWithTypes: vitest.fn(), createFile: vitest.fn(), createWriteStream: vitest.fn(), createOrOverwriteFile: vitest.fn(), diff --git a/server/test/utils.ts b/server/test/utils.ts index b3e47b2b7e..988b5d23c0 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -322,7 +322,7 @@ export const getMocks = () => { oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: automock(PersonRepository, { strict: false }), - plugin: automock(PluginRepository, { strict: true }), + plugin: automock(PluginRepository, { strict: true, args: [databaseMock, loggerMock] }), process: automock(ProcessRepository), search: automock(SearchRepository, { strict: false }), // eslint-disable-next-line no-sparse-arrays