mirror of
https://github.com/immich-app/immich.git
synced 2026-03-13 21:30:08 -04:00
WIP
This commit is contained in:
parent
dd03c9c0a9
commit
f57676f76a
@ -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"
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
9
plugins/src/index.d.ts
vendored
9
plugins/src/index.d.ts
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,71 +1,135 @@
|
||||
const { updateAsset, addAssetToAlbum } = Host.getFunctions();
|
||||
const { addAssetToAlbum } = Host.getFunctions();
|
||||
|
||||
function parseInput() {
|
||||
return JSON.parse(Host.inputString());
|
||||
type WorkflowInput<
|
||||
TConfig = Record<string, unknown>,
|
||||
TData = Record<string, unknown>
|
||||
> = {
|
||||
trigger: string;
|
||||
type: string;
|
||||
data: TData;
|
||||
config: TConfig;
|
||||
workflow: {
|
||||
id: string;
|
||||
stepId: string;
|
||||
};
|
||||
};
|
||||
|
||||
type WorkflowOutput = {
|
||||
workflow?: {
|
||||
/** stop the workflow */
|
||||
continue?: boolean;
|
||||
};
|
||||
changes?: Partial<Record<string, unknown>>;
|
||||
/** data to be passed to the next workflow step */
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type AssetFileFilterConfig = {
|
||||
pattern: string;
|
||||
matchType?: 'contains' | 'exact' | 'regex';
|
||||
caseSensitive?: boolean;
|
||||
};
|
||||
|
||||
const wrapper = <TConfig, TData>(
|
||||
fn: (payload: WorkflowInput<TConfig, TData>) => WorkflowOutput | undefined
|
||||
) => {
|
||||
const input = Host.inputString();
|
||||
const event = JSON.parse(input) as WorkflowInput<TConfig, TData>;
|
||||
|
||||
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' } };
|
||||
// });
|
||||
// }
|
||||
|
||||
56
server/src/controllers/plugin.controller.spec.ts
Normal file
56
server/src/controllers/plugin.controller.spec.ts
Normal file
@ -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')]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<PluginResponseDto[]> {
|
||||
return this.service.getAll();
|
||||
searchPlugins(@Query() dto: PluginSearchDto): Promise<PluginResponseDto[]> {
|
||||
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<PluginResponseDto> {
|
||||
return this.service.get(id);
|
||||
|
||||
115
server/src/controllers/workflow.controller.spec.ts
Normal file
115
server/src/controllers/workflow.controller.spec.ts
Normal file
@ -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')]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<WorkflowResponseDto> {
|
||||
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<WorkflowResponseDto[]> {
|
||||
return this.service.getAll(auth);
|
||||
searchWorkflows(@Auth() auth: AuthDto, @Query() dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
|
||||
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<WorkflowResponseDto> {
|
||||
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<void> {
|
||||
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<WorkflowStepResponseDto[]> {
|
||||
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<WorkflowStepResponseDto[]> {
|
||||
return this.service.replaceSteps(auth, id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<PluginTable>;
|
||||
|
||||
export type PluginFilter = Selectable<PluginFilterTable> & {
|
||||
methodName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
supportedContexts: PluginContext[];
|
||||
schema: JSONSchema | null;
|
||||
};
|
||||
|
||||
export type PluginAction = Selectable<PluginActionTable> & {
|
||||
methodName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
supportedContexts: PluginContext[];
|
||||
schema: JSONSchema | null;
|
||||
};
|
||||
|
||||
export type Workflow = Selectable<WorkflowTable> & {
|
||||
triggerType: PluginTriggerType;
|
||||
name: string | null;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowFilter = Selectable<WorkflowFilterTable> & {
|
||||
workflowId: string;
|
||||
pluginFilterId: string;
|
||||
filterConfig: FilterConfig | null;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type WorkflowAction = Selectable<WorkflowActionTable> & {
|
||||
workflowId: string;
|
||||
pluginActionId: string;
|
||||
actionConfig: ActionConfig | null;
|
||||
order: number;
|
||||
};
|
||||
export type PluginMethod = Selectable<PluginMethodTable>;
|
||||
export type Workflow = Selectable<WorkflowTable>;
|
||||
export type WorkflowStep = Selectable<WorkflowStepTable>;
|
||||
|
||||
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',
|
||||
],
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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<string, PluginHostFunction>;
|
||||
};
|
||||
|
||||
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<DB>) {}
|
||||
private pluginMap: Map<string, PluginMapItem> = 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<DB>,
|
||||
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<PluginTable>, initialMethods: Omit<Insertable<PluginMethodTable>, '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<T>({ 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Dirent[]> {
|
||||
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<Buffer>): Promise<Buffer> {
|
||||
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<string> {
|
||||
return fs.readFile(filepath, 'utf8');
|
||||
async readJsonFile<T>(filepath: string): Promise<T> {
|
||||
const file = await fs.readFile(filepath, 'utf8');
|
||||
return JSON.parse(file) as T;
|
||||
}
|
||||
|
||||
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
|
||||
|
||||
@ -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<DB>) {}
|
||||
|
||||
@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<WorkflowTable>) {
|
||||
return this.db.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
update(id: string, workflow: Updateable<WorkflowTable>) {
|
||||
// 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<WorkflowTable>,
|
||||
filters: Insertable<WorkflowFilterTable>[],
|
||||
actions: Insertable<WorkflowActionTable>[],
|
||||
) {
|
||||
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<WorkflowTable>,
|
||||
filters: Insertable<WorkflowFilterTable>[] | undefined,
|
||||
actions: Insertable<WorkflowActionTable>[] | 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// 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<void> {
|
||||
// unsupported
|
||||
}
|
||||
@ -111,7 +111,7 @@ export class AssetExifTable {
|
||||
tags!: string[] | null;
|
||||
|
||||
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
|
||||
updatedAt!: Generated<Date>;
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
29
server/src/schema/tables/plugin-method.table.ts
Normal file
29
server/src/schema/tables/plugin-method.table.ts
Normal file
@ -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<string>;
|
||||
|
||||
@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<WorkflowType[]>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
@ -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<string>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled!: Generated<boolean>;
|
||||
|
||||
@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<Timestamp>;
|
||||
@ -41,55 +42,3 @@ export class PluginTable {
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
@Index({ columns: ['supportedContexts'], using: 'gin' })
|
||||
@Table('plugin_filter')
|
||||
export class PluginFilterTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@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<PluginContext[]>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
@Index({ columns: ['supportedContexts'], using: 'gin' })
|
||||
@Table('plugin_action')
|
||||
export class PluginActionTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@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<PluginContext[]>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
26
server/src/schema/tables/workflow-step.table.ts
Normal file
26
server/src/schema/tables/workflow-step.table.ts
Normal file
@ -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<string>;
|
||||
|
||||
@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;
|
||||
}
|
||||
@ -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<Timestamp>;
|
||||
@ -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<string>;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
workflowId!: Generated<string>;
|
||||
|
||||
@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<string>;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
workflowId!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
pluginActionId!: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
actionConfig!: ActionConfig | null;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
order!: number;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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<AssetTable> & { 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;
|
||||
}
|
||||
}
|
||||
@ -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<T = unknown> {
|
||||
authToken: string;
|
||||
config: T;
|
||||
data: {
|
||||
asset: Asset;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginService extends BaseService {
|
||||
private pluginJwtSecret!: string;
|
||||
private loadedPlugins: Map<string, ExtismPlugin> = 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<PluginResponseDto[]> {
|
||||
const plugins = await this.pluginRepository.getAllPlugins();
|
||||
async search(dto: PluginSearchDto): Promise<PluginResponseDto[]> {
|
||||
const plugins = await this.pluginRepository.search(dto);
|
||||
return plugins.map((plugin) => mapPlugin(plugin));
|
||||
}
|
||||
|
||||
async get(id: string): Promise<PluginResponseDto> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<PluginManifestDto> {
|
||||
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<T extends PluginTriggerType>(
|
||||
triggerType: T,
|
||||
params: { ownerId: string; event: WorkflowData[T] },
|
||||
): Promise<void> {
|
||||
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<T>,
|
||||
}));
|
||||
|
||||
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<JobName.WorkflowRun>): Promise<JobStatus> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
241
server/src/services/workflow-execution.service.ts
Normal file
241
server/src/services/workflow-execution.service.ts
Normal file
@ -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<T extends WorkflowType = any> = {
|
||||
read: (type: T) => Promise<WorkflowEventData<T>>;
|
||||
write: (changes: Partial<WorkflowEventData<T>>) => Promise<void>;
|
||||
};
|
||||
|
||||
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<void> {
|
||||
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<JobName.WorkflowAssetCreate>) {
|
||||
await this.execute(workflowId, (type: WorkflowType) => {
|
||||
switch (type) {
|
||||
case WorkflowType.AssetV1: {
|
||||
return <ExecuteOptions<WorkflowType.AssetV1>>{
|
||||
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<WorkflowResponse>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<WorkflowResponseDto> {
|
||||
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<WorkflowResponseDto[]> {
|
||||
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<WorkflowResponseDto[]> {
|
||||
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id });
|
||||
return workflows.map((workflow) => mapWorkflow(workflow));
|
||||
}
|
||||
|
||||
async get(auth: AuthDto, id: string): Promise<WorkflowResponseDto> {
|
||||
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<WorkflowResponseDto> {
|
||||
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<WorkflowResponseDto> {
|
||||
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<void> {
|
||||
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<WorkflowStepResponseDto[]> {
|
||||
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<WorkflowResponseDto> {
|
||||
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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<T> =
|
||||
@ -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<T extends PluginTriggerType = PluginTriggerType> {
|
||||
export type AssetPersonV1 = AssetV1 & {
|
||||
person: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkflowEventMap = {
|
||||
[WorkflowType.AssetV1]: AssetV1;
|
||||
[WorkflowType.AssetPersonV1]: AssetPersonV1;
|
||||
};
|
||||
|
||||
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
|
||||
|
||||
export type IWorkflowJob<T extends WorkflowType = WorkflowType> = {
|
||||
id: string;
|
||||
trigger: WorkflowTrigger;
|
||||
type: T;
|
||||
event: WorkflowData[T];
|
||||
}
|
||||
};
|
||||
|
||||
export type WorkflowEventPayload<T extends WorkflowType = WorkflowType> = {
|
||||
trigger: WorkflowTrigger;
|
||||
type: T;
|
||||
data: WorkflowEventData<T>;
|
||||
config: WorkflowStepConfig;
|
||||
workflow: {
|
||||
id: string;
|
||||
stepId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
|
||||
workflow?: {
|
||||
/** stop the workflow */
|
||||
continue?: boolean;
|
||||
};
|
||||
changes?: Partial<WorkflowEventData<T>>;
|
||||
/** data to be passed to the next workflow step */
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<UserMetadataKey, Record<string, any
|
||||
[UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string };
|
||||
[UserMetadataKey.Onboarding]: { isOnboarded: boolean };
|
||||
}
|
||||
|
||||
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
|
||||
|
||||
export type JSONSchemaProperty = {
|
||||
type: 'object';
|
||||
description?: string;
|
||||
default?: any;
|
||||
enum?: string[];
|
||||
array?: boolean;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
export interface JSONSchema {
|
||||
type: 'object';
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue };
|
||||
|
||||
export type WorkflowStepConfig = {
|
||||
[key: string]: ConfigValue;
|
||||
};
|
||||
|
||||
@ -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<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean | JSONSchemaProperty;
|
||||
}
|
||||
|
||||
export interface JSONSchema {
|
||||
type: 'object';
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
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;
|
||||
}
|
||||
31
server/src/utils/workflow.ts
Normal file
31
server/src/utils/workflow.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { WorkflowTrigger, WorkflowType } from 'src/enum';
|
||||
|
||||
export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
|
||||
[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, WorkflowType[]> = {
|
||||
[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;
|
||||
};
|
||||
@ -410,7 +410,6 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||
case OcrRepository:
|
||||
case PartnerRepository:
|
||||
case PersonRepository:
|
||||
case PluginRepository:
|
||||
case SearchRepository:
|
||||
case SessionRepository:
|
||||
case SharedLinkRepository:
|
||||
@ -442,6 +441,10 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): 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 = <T>(key: ClassConstructor<T>) => {
|
||||
case OcrRepository:
|
||||
case PartnerRepository:
|
||||
case PersonRepository:
|
||||
case PluginRepository:
|
||||
case SessionRepository:
|
||||
case SyncRepository:
|
||||
case SyncCheckpointRepository:
|
||||
|
||||
@ -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<DB>;
|
||||
let pluginRepo: PluginRepository;
|
||||
|
||||
const wasmBytes = Buffer.from('some-wasm-binary-data');
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(PluginService, {
|
||||
@ -21,7 +22,6 @@ const setup = (db?: Kysely<DB>) => {
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
|
||||
@ -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<DB>) => {
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
188
server/test/medium/specs/workflow/workflow-core-plugin.spec.ts
Normal file
188
server/test/medium/specs/workflow/workflow-core-plugin.spec.ts
Normal file
@ -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<WorkflowExecutionService> {
|
||||
constructor(database: Kysely<DB>) {
|
||||
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 = /^(?<name>[^@#\s]+)(?:@(?<version>[^#\s]*))?#(?<method>[^@#\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 });
|
||||
});
|
||||
});
|
||||
@ -53,7 +53,8 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
||||
createGzip: vitest.fn(),
|
||||
createGunzip: vitest.fn(),
|
||||
readFile: vitest.fn(),
|
||||
readTextFile: vitest.fn(),
|
||||
readJsonFile: vitest.fn() as Mocked<StorageRepository>['readJsonFile'],
|
||||
readdirWithTypes: vitest.fn(),
|
||||
createFile: vitest.fn(),
|
||||
createWriteStream: vitest.fn(),
|
||||
createOrOverwriteFile: vitest.fn(),
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user