This commit is contained in:
Jason Rasmussen 2026-03-05 18:14:29 -05:00
parent dd03c9c0a9
commit f57676f76a
No known key found for this signature in database
GPG Key ID: 2EF24B77EAFA4A41
42 changed files with 2391 additions and 2685 deletions

View File

@ -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"

View File

@ -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"]
}
}
]

View File

@ -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;
}
}

View File

@ -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' } };
// });
// }

View 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')]));
});
});
});

View File

@ -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);

View 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')]));
});
});
});

View File

@ -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);
}
}

View File

@ -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',
],

View File

@ -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 });
}

View File

@ -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[];
}

View File

@ -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,
};
}

View File

@ -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,
};
}
};

View File

@ -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',
}

View File

@ -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,
},
];

View File

@ -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

View File

@ -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

View File

@ -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);
}

View File

@ -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 });
}
}
}

View File

@ -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> {

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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
}

View File

@ -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>;

View 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;
}

View File

@ -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;
}

View 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;
}

View File

@ -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;
}

View File

@ -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,
];

View File

@ -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;
}
}

View File

@ -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));
}
}
}

View 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;
}
}
}

View File

@ -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)),
};
}
}

View File

@ -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;
};

View File

@ -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;
}

View 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;
};

View File

@ -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:

View File

@ -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',
},
],

View File

@ -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();
});
});
});

View 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 });
});
});

View File

@ -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(),

View File

@ -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