diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-04-27 00:02:20 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-27 00:02:20 +0100 |
| commit | 136f126296af65f50da598d084d1485c0e40437a (patch) | |
| tree | 2725c7932ebbcb9b48b5af98eb9b72329a400260 /packages | |
| parent | ca47be7fe7be128f459c37614a04902a873fe289 (diff) | |
| download | karakeep-136f126296af65f50da598d084d1485c0e40437a.tar.zst | |
feat: Implement generic rule engine (#1318)
* Add schema for the new rule engine
* Add rule engine backend logic
* Implement the worker logic and event firing
* Implement the UI changesfor the rule engine
* Ensure that when a referenced list or tag are deleted, the corresponding event/action is
* Dont show smart lists in rule engine events
* Add privacy validations for attached tag and list ids
* Move the rules logic into a models
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/db/drizzle/0045_add_rule_engine.sql | 33 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/0045_snapshot.json | 1951 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/_journal.json | 9 | ||||
| -rw-r--r-- | packages/db/schema.ts | 118 | ||||
| -rw-r--r-- | packages/shared-react/hooks/rules.ts | 40 | ||||
| -rw-r--r-- | packages/shared/queues.ts | 28 | ||||
| -rw-r--r-- | packages/shared/types/rules.ts | 333 | ||||
| -rw-r--r-- | packages/shared/types/search.ts | 6 | ||||
| -rw-r--r-- | packages/trpc/lib/__tests__/ruleEngine.test.ts | 664 | ||||
| -rw-r--r-- | packages/trpc/lib/ruleEngine.ts | 231 | ||||
| -rw-r--r-- | packages/trpc/models/lists.ts | 19 | ||||
| -rw-r--r-- | packages/trpc/models/rules.ts | 233 | ||||
| -rw-r--r-- | packages/trpc/package.json | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/_app.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 27 | ||||
| -rw-r--r-- | packages/trpc/routers/lists.ts | 3 | ||||
| -rw-r--r-- | packages/trpc/routers/rules.test.ts | 379 | ||||
| -rw-r--r-- | packages/trpc/routers/rules.ts | 120 | ||||
| -rw-r--r-- | packages/trpc/routers/tags.ts | 2 |
19 files changed, 4191 insertions, 9 deletions
diff --git a/packages/db/drizzle/0045_add_rule_engine.sql b/packages/db/drizzle/0045_add_rule_engine.sql new file mode 100644 index 00000000..d6d301dd --- /dev/null +++ b/packages/db/drizzle/0045_add_rule_engine.sql @@ -0,0 +1,33 @@ +CREATE TABLE `ruleEngineActions` ( + `id` text PRIMARY KEY NOT NULL, + `userId` text NOT NULL, + `ruleId` text NOT NULL, + `action` text NOT NULL, + `listId` text, + `tagId` text, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`ruleId`) REFERENCES `ruleEngineRules`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`,`tagId`) REFERENCES `bookmarkTags`(`userId`,`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`,`listId`) REFERENCES `bookmarkLists`(`userId`,`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `ruleEngineActions_userId_idx` ON `ruleEngineActions` (`userId`);--> statement-breakpoint +CREATE INDEX `ruleEngineActions_ruleId_idx` ON `ruleEngineActions` (`ruleId`);--> statement-breakpoint +CREATE TABLE `ruleEngineRules` ( + `id` text PRIMARY KEY NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `name` text NOT NULL, + `description` text, + `event` text NOT NULL, + `condition` text NOT NULL, + `userId` text NOT NULL, + `listId` text, + `tagId` text, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`,`tagId`) REFERENCES `bookmarkTags`(`userId`,`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`,`listId`) REFERENCES `bookmarkLists`(`userId`,`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `ruleEngine_userId_idx` ON `ruleEngineRules` (`userId`);--> statement-breakpoint +CREATE UNIQUE INDEX `bookmarkLists_userId_id_idx` ON `bookmarkLists` (`userId`,`id`);--> statement-breakpoint +CREATE UNIQUE INDEX `bookmarkTags_userId_id_idx` ON `bookmarkTags` (`userId`,`id`);
\ No newline at end of file diff --git a/packages/db/drizzle/meta/0045_snapshot.json b/packages/db/drizzle/meta/0045_snapshot.json new file mode 100644 index 00000000..293af6a6 --- /dev/null +++ b/packages/db/drizzle/meta/0045_snapshot.json @@ -0,0 +1,1951 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dce61b2b-d896-425a-a9cd-6a79f596d7b2", + "prevId": "1592d5ec-1cbd-45f5-9061-fbaece6eb91e", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublished": { + "name": "datePublished", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateModified": { + "name": "dateModified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "crawlStatusCode": { + "name": "crawlStatusCode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 200 + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkLists_userId_id_idx": { + "name": "bookmarkLists_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + }, + "bookmarkTags_userId_id_idx": { + "name": "bookmarkTags_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_archived_idx": { + "name": "bookmarks_archived_idx", + "columns": [ + "archived" + ], + "isUnique": false + }, + "bookmarks_favourited_idx": { + "name": "bookmarks_favourited_idx", + "columns": [ + "favourited" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appliesTo": { + "name": "appliesTo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startOffset": { + "name": "startOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endOffset": { + "name": "endOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'yellow'" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "highlights_bookmarkId_idx": { + "name": "highlights_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "highlights_userId_idx": { + "name": "highlights_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "highlights_bookmarkId_bookmarks_id_fk": { + "name": "highlights_bookmarkId_bookmarks_id_fk", + "tableFrom": "highlights", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_userId_user_id_fk": { + "name": "highlights_userId_user_id_fk", + "tableFrom": "highlights", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeedImports": { + "name": "rssFeedImports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rssFeedId": { + "name": "rssFeedId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rssFeedImports_feedIdIdx_idx": { + "name": "rssFeedImports_feedIdIdx_idx", + "columns": [ + "rssFeedId" + ], + "isUnique": false + }, + "rssFeedImports_entryIdIdx_idx": { + "name": "rssFeedImports_entryIdIdx_idx", + "columns": [ + "entryId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_entryId_unique": { + "name": "rssFeedImports_rssFeedId_entryId_unique", + "columns": [ + "rssFeedId", + "entryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rssFeedImports_rssFeedId_rssFeeds_id_fk": { + "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "rssFeeds", + "columnsFrom": [ + "rssFeedId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rssFeedImports_bookmarkId_bookmarks_id_fk": { + "name": "rssFeedImports_bookmarkId_bookmarks_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeeds": { + "name": "rssFeeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchedStatus": { + "name": "lastFetchedStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rssFeeds_userId_idx": { + "name": "rssFeeds_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rssFeeds_userId_user_id_fk": { + "name": "rssFeeds_userId_user_id_fk", + "tableFrom": "rssFeeds", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineActions": { + "name": "ruleEngineActions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ruleId": { + "name": "ruleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngineActions_userId_idx": { + "name": "ruleEngineActions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "ruleEngineActions_ruleId_idx": { + "name": "ruleEngineActions_ruleId_idx", + "columns": [ + "ruleId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineActions_userId_user_id_fk": { + "name": "ruleEngineActions_userId_user_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_ruleId_ruleEngineRules_id_fk": { + "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "ruleEngineRules", + "columnsFrom": [ + "ruleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_tagId_fk": { + "name": "ruleEngineActions_userId_tagId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_listId_fk": { + "name": "ruleEngineActions_userId_listId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineRules": { + "name": "ruleEngineRules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngine_userId_idx": { + "name": "ruleEngine_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineRules_userId_user_id_fk": { + "name": "ruleEngineRules_userId_user_id_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_tagId_fk": { + "name": "ruleEngineRules_userId_tagId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_listId_fk": { + "name": "ruleEngineRules_userId_listId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "tagId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhooks_userId_idx": { + "name": "webhooks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "webhooks_userId_user_id_fk": { + "name": "webhooks_userId_user_id_fk", + "tableFrom": "webhooks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index d768270d..8281b126 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -316,6 +316,13 @@ "when": 1744744684677, "tag": "0044_add_password_salt", "breakpoints": true + }, + { + "idx": 45, + "version": "6", + "when": 1745705657846, + "tag": "0045_add_rule_engine", + "breakpoints": true } ] -} +}
\ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index dd65370b..bedcf9ad 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3,6 +3,7 @@ import { createId } from "@paralleldrive/cuid2"; import { relations } from "drizzle-orm"; import { AnySQLiteColumn, + foreignKey, index, integer, primaryKey, @@ -283,6 +284,7 @@ export const bookmarkTags = sqliteTable( }, (bt) => [ unique().on(bt.userId, bt.name), + unique("bookmarkTags_userId_id_idx").on(bt.userId, bt.id), index("bookmarkTags_name_idx").on(bt.name), index("bookmarkTags_userId_idx").on(bt.userId), ], @@ -332,7 +334,10 @@ export const bookmarkLists = sqliteTable( { onDelete: "set null" }, ), }, - (bl) => [index("bookmarkLists_userId_idx").on(bl.userId)], + (bl) => [ + index("bookmarkLists_userId_idx").on(bl.userId), + unique("bookmarkLists_userId_id_idx").on(bl.userId, bl.id), + ], ); export const bookmarksInLists = sqliteTable( @@ -444,12 +449,87 @@ export const config = sqliteTable("config", { value: text("value").notNull(), }); +export const ruleEngineRulesTable = sqliteTable( + "ruleEngineRules", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + name: text("name").notNull(), + description: text("description"), + event: text("event").notNull(), + condition: text("condition").notNull(), + + // References + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + listId: text("listId"), + tagId: text("tagId"), + }, + (rl) => [ + index("ruleEngine_userId_idx").on(rl.userId), + + // Ensures correct ownership + foreignKey({ + columns: [rl.userId, rl.tagId], + foreignColumns: [bookmarkTags.userId, bookmarkTags.id], + name: "ruleEngineRules_userId_tagId_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [rl.userId, rl.listId], + foreignColumns: [bookmarkLists.userId, bookmarkLists.id], + name: "ruleEngineRules_userId_listId_fk", + }).onDelete("cascade"), + ], +); + +export const ruleEngineActionsTable = sqliteTable( + "ruleEngineActions", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + ruleId: text("ruleId") + .notNull() + .references(() => ruleEngineRulesTable.id, { onDelete: "cascade" }), + action: text("action").notNull(), + + // References + listId: text("listId"), + tagId: text("tagId"), + }, + (rl) => [ + index("ruleEngineActions_userId_idx").on(rl.userId), + index("ruleEngineActions_ruleId_idx").on(rl.ruleId), + // Ensures correct ownership + foreignKey({ + columns: [rl.userId, rl.tagId], + foreignColumns: [bookmarkTags.userId, bookmarkTags.id], + name: "ruleEngineActions_userId_tagId_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [rl.userId, rl.listId], + foreignColumns: [bookmarkLists.userId, bookmarkLists.id], + name: "ruleEngineActions_userId_listId_fk", + }).onDelete("cascade"), + ], +); + // Relations export const userRelations = relations(users, ({ many }) => ({ tags: many(bookmarkTags), bookmarks: many(bookmarks), webhooks: many(webhooksTable), + rules: many(ruleEngineRulesTable), })); export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({ @@ -472,6 +552,7 @@ export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({ tagsOnBookmarks: many(tagsOnBookmarks), bookmarksInLists: many(bookmarksInLists), assets: many(assets), + rssFeeds: many(rssFeedImportsTable), })); export const assetRelations = relations(assets, ({ one }) => ({ @@ -548,3 +629,38 @@ export const webhooksRelations = relations(webhooksTable, ({ one }) => ({ references: [users.id], }), })); + +export const ruleEngineRulesRelations = relations( + ruleEngineRulesTable, + ({ one, many }) => ({ + user: one(users, { + fields: [ruleEngineRulesTable.userId], + references: [users.id], + }), + actions: many(ruleEngineActionsTable), + }), +); + +export const ruleEngineActionsTableRelations = relations( + ruleEngineActionsTable, + ({ one }) => ({ + rule: one(ruleEngineRulesTable, { + fields: [ruleEngineActionsTable.ruleId], + references: [ruleEngineRulesTable.id], + }), + }), +); + +export const rssFeedImportsTableRelations = relations( + rssFeedImportsTable, + ({ one }) => ({ + rssFeed: one(rssFeedsTable, { + fields: [rssFeedImportsTable.rssFeedId], + references: [rssFeedsTable.id], + }), + bookmark: one(bookmarks, { + fields: [rssFeedImportsTable.bookmarkId], + references: [bookmarks.id], + }), + }), +); diff --git a/packages/shared-react/hooks/rules.ts b/packages/shared-react/hooks/rules.ts new file mode 100644 index 00000000..16a72f75 --- /dev/null +++ b/packages/shared-react/hooks/rules.ts @@ -0,0 +1,40 @@ +import { api } from "../trpc"; + +export function useCreateRule( + ...opts: Parameters<typeof api.rules.create.useMutation> +) { + const apiUtils = api.useUtils(); + return api.rules.create.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.rules.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + +export function useUpdateRule( + ...opts: Parameters<typeof api.rules.update.useMutation> +) { + const apiUtils = api.useUtils(); + return api.rules.update.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.rules.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + +export function useDeleteRule( + ...opts: Parameters<typeof api.rules.delete.useMutation> +) { + const apiUtils = api.useUtils(); + return api.rules.delete.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.rules.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} diff --git a/packages/shared/queues.ts b/packages/shared/queues.ts index 624f2bca..571df568 100644 --- a/packages/shared/queues.ts +++ b/packages/shared/queues.ts @@ -3,6 +3,7 @@ import { buildDBClient, migrateDB, SqliteQueue } from "liteque"; import { z } from "zod"; import serverConfig from "./config"; +import { zRuleEngineEventSchema } from "./types/rules"; const QUEUE_DB_PATH = path.join(serverConfig.dataDir, "queue.db"); @@ -193,3 +194,30 @@ export async function triggerWebhook( operation, }); } + +// RuleEgine worker +export const zRuleEngineRequestSchema = z.object({ + bookmarkId: z.string(), + events: z.array(zRuleEngineEventSchema), +}); +export type ZRuleEngineRequest = z.infer<typeof zRuleEngineRequestSchema>; +export const RuleEngineQueue = new SqliteQueue<ZRuleEngineRequest>( + "rule_engine_queue", + queueDB, + { + defaultJobArgs: { + numRetries: 1, + }, + keepFailedJobs: false, + }, +); + +export async function triggerRuleEngineOnEvent( + bookmarkId: string, + events: z.infer<typeof zRuleEngineEventSchema>[], +) { + await RuleEngineQueue.enqueue({ + events, + bookmarkId, + }); +} diff --git a/packages/shared/types/rules.ts b/packages/shared/types/rules.ts new file mode 100644 index 00000000..92300b3c --- /dev/null +++ b/packages/shared/types/rules.ts @@ -0,0 +1,333 @@ +import { RefinementCtx, z } from "zod"; + +// Events +const zBookmarkAddedEvent = z.object({ + type: z.literal("bookmarkAdded"), +}); + +const zTagAddedEvent = z.object({ + type: z.literal("tagAdded"), + tagId: z.string(), +}); + +const zTagRemovedEvent = z.object({ + type: z.literal("tagRemoved"), + tagId: z.string(), +}); + +const zAddedToListEvent = z.object({ + type: z.literal("addedToList"), + listId: z.string(), +}); + +const zRemovedFromListEvent = z.object({ + type: z.literal("removedFromList"), + listId: z.string(), +}); + +const zFavouritedEvent = z.object({ + type: z.literal("favourited"), +}); + +const zArchivedEvent = z.object({ + type: z.literal("archived"), +}); + +export const zRuleEngineEventSchema = z.discriminatedUnion("type", [ + zBookmarkAddedEvent, + zTagAddedEvent, + zTagRemovedEvent, + zAddedToListEvent, + zRemovedFromListEvent, + zFavouritedEvent, + zArchivedEvent, +]); +export type RuleEngineEvent = z.infer<typeof zRuleEngineEventSchema>; + +// Conditions +const zAlwaysTrueCondition = z.object({ + type: z.literal("alwaysTrue"), +}); + +const zUrlContainsCondition = z.object({ + type: z.literal("urlContains"), + str: z.string(), +}); + +const zImportedFromFeedCondition = z.object({ + type: z.literal("importedFromFeed"), + feedId: z.string(), +}); + +const zBookmarkTypeIsCondition = z.object({ + type: z.literal("bookmarkTypeIs"), + bookmarkType: z.enum(["link", "text", "asset"]), +}); + +const zHasTagCondition = z.object({ + type: z.literal("hasTag"), + tagId: z.string(), +}); + +const zIsFavouritedCondition = z.object({ + type: z.literal("isFavourited"), +}); + +const zIsArchivedCondition = z.object({ + type: z.literal("isArchived"), +}); + +const nonRecursiveCondition = z.discriminatedUnion("type", [ + zAlwaysTrueCondition, + zUrlContainsCondition, + zImportedFromFeedCondition, + zBookmarkTypeIsCondition, + zHasTagCondition, + zIsFavouritedCondition, + zIsArchivedCondition, +]); + +type NonRecursiveCondition = z.infer<typeof nonRecursiveCondition>; +export type RuleEngineCondition = + | NonRecursiveCondition + | { type: "and"; conditions: RuleEngineCondition[] } + | { type: "or"; conditions: RuleEngineCondition[] }; + +export const zRuleEngineConditionSchema: z.ZodType<RuleEngineCondition> = + z.lazy(() => + z.discriminatedUnion("type", [ + zAlwaysTrueCondition, + zUrlContainsCondition, + zImportedFromFeedCondition, + zBookmarkTypeIsCondition, + zHasTagCondition, + zIsFavouritedCondition, + zIsArchivedCondition, + z.object({ + type: z.literal("and"), + conditions: z.array(zRuleEngineConditionSchema), + }), + z.object({ + type: z.literal("or"), + conditions: z.array(zRuleEngineConditionSchema), + }), + ]), + ); + +// Actions +const zAddTagAction = z.object({ + type: z.literal("addTag"), + tagId: z.string(), +}); + +const zRemoveTagAction = z.object({ + type: z.literal("removeTag"), + tagId: z.string(), +}); + +const zAddToListAction = z.object({ + type: z.literal("addToList"), + listId: z.string(), +}); + +const zRemoveFromListAction = z.object({ + type: z.literal("removeFromList"), + listId: z.string(), +}); + +const zDownloadFullPageArchiveAction = z.object({ + type: z.literal("downloadFullPageArchive"), +}); + +const zFavouriteBookmarkAction = z.object({ + type: z.literal("favouriteBookmark"), +}); + +const zArchiveBookmarkAction = z.object({ + type: z.literal("archiveBookmark"), +}); + +export const zRuleEngineActionSchema = z.discriminatedUnion("type", [ + zAddTagAction, + zRemoveTagAction, + zAddToListAction, + zRemoveFromListAction, + zDownloadFullPageArchiveAction, + zFavouriteBookmarkAction, + zArchiveBookmarkAction, +]); +export type RuleEngineAction = z.infer<typeof zRuleEngineActionSchema>; + +export const zRuleEngineRuleSchema = z.object({ + id: z.string(), + name: z.string().min(1), + description: z.string().nullable(), + enabled: z.boolean(), + event: zRuleEngineEventSchema, + condition: zRuleEngineConditionSchema, + actions: z.array(zRuleEngineActionSchema), +}); +export type RuleEngineRule = z.infer<typeof zRuleEngineRuleSchema>; + +const ruleValidaitorFn = ( + r: Omit<RuleEngineRule, "id">, + ctx: RefinementCtx, +) => { + const validateEvent = (event: RuleEngineEvent) => { + switch (event.type) { + case "bookmarkAdded": + case "favourited": + case "archived": + return true; + case "tagAdded": + case "tagRemoved": + if (event.tagId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a tag for this event type", + path: ["event", "tagId"], + }); + return false; + } + return true; + case "addedToList": + case "removedFromList": + if (event.listId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a list for this event type", + path: ["event", "listId"], + }); + return false; + } + return true; + default: { + const _exhaustiveCheck: never = event; + return false; + } + } + }; + + const validateCondition = ( + condition: RuleEngineCondition, + depth: number, + ): boolean => { + if (depth > 10) { + ctx.addIssue({ + code: "custom", + message: + "Rule conditions are too complex. Maximum allowed depth is 10.", + }); + return false; + } + switch (condition.type) { + case "alwaysTrue": + case "bookmarkTypeIs": + case "isFavourited": + case "isArchived": + return true; + case "urlContains": + if (condition.str.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a URL for this condition type", + path: ["condition", "str"], + }); + return false; + } + return true; + case "hasTag": + if (condition.tagId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a tag for this condition type", + path: ["condition", "tagId"], + }); + return false; + } + return true; + case "importedFromFeed": + if (condition.feedId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a feed for this condition type", + path: ["condition", "feedId"], + }); + return false; + } + return true; + case "and": + case "or": + if (condition.conditions.length == 0) { + ctx.addIssue({ + code: "custom", + message: + "You must specify at least one condition for this condition type", + path: ["condition"], + }); + return false; + } + return condition.conditions.every((c) => + validateCondition(c, depth + 1), + ); + default: { + const _exhaustiveCheck: never = condition; + return false; + } + } + }; + const validateAction = (action: RuleEngineAction): boolean => { + switch (action.type) { + case "addTag": + case "removeTag": + if (action.tagId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a tag for this action type", + path: ["actions", "tagId"], + }); + return false; + } + return true; + case "addToList": + case "removeFromList": + if (action.listId.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify a list for this action type", + path: ["actions", "listId"], + }); + return false; + } + return true; + case "downloadFullPageArchive": + case "favouriteBookmark": + case "archiveBookmark": + return true; + default: { + const _exhaustiveCheck: never = action; + return false; + } + } + }; + validateEvent(r.event); + validateCondition(r.condition, 0); + if (r.actions.length == 0) { + ctx.addIssue({ + code: "custom", + message: "You must specify at least one action", + path: ["actions"], + }); + return false; + } + r.actions.every((a) => validateAction(a)); +}; + +export const zNewRuleEngineRuleSchema = zRuleEngineRuleSchema + .omit({ + id: true, + }) + .superRefine(ruleValidaitorFn); + +export const zUpdateRuleEngineRuleSchema = + zRuleEngineRuleSchema.superRefine(ruleValidaitorFn); diff --git a/packages/shared/types/search.ts b/packages/shared/types/search.ts index 4c64c0f5..26d5bd42 100644 --- a/packages/shared/types/search.ts +++ b/packages/shared/types/search.ts @@ -25,7 +25,7 @@ const zArchivedMatcher = z.object({ archived: z.boolean(), }); -const urlMatcher = z.object({ +const zUrlMatcher = z.object({ type: z.literal("url"), url: z.string(), inverse: z.boolean(), @@ -81,7 +81,7 @@ const zNonRecursiveMatcher = z.union([ zTagNameMatcher, zListNameMatcher, zArchivedMatcher, - urlMatcher, + zUrlMatcher, zFavouritedMatcher, zDateAfterMatcher, zDateBeforeMatcher, @@ -103,7 +103,7 @@ export const zMatcherSchema: z.ZodType<Matcher> = z.lazy(() => { zTagNameMatcher, zListNameMatcher, zArchivedMatcher, - urlMatcher, + zUrlMatcher, zFavouritedMatcher, zDateAfterMatcher, zDateBeforeMatcher, diff --git a/packages/trpc/lib/__tests__/ruleEngine.test.ts b/packages/trpc/lib/__tests__/ruleEngine.test.ts new file mode 100644 index 00000000..cbb4b978 --- /dev/null +++ b/packages/trpc/lib/__tests__/ruleEngine.test.ts @@ -0,0 +1,664 @@ +import { and, eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getInMemoryDB } from "@karakeep/db/drizzle"; +import { + bookmarkLinks, + bookmarkLists, + bookmarks, + bookmarksInLists, + bookmarkTags, + rssFeedImportsTable, + rssFeedsTable, + ruleEngineActionsTable as ruleActions, + ruleEngineRulesTable as rules, + tagsOnBookmarks, + users, +} from "@karakeep/db/schema"; +import { LinkCrawlerQueue } from "@karakeep/shared/queues"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { + RuleEngineAction, + RuleEngineCondition, + RuleEngineEvent, + RuleEngineRule, +} from "@karakeep/shared/types/rules"; + +import { AuthedContext } from "../.."; +import { TestDB } from "../../testUtils"; +import { RuleEngine } from "../ruleEngine"; + +// Mock the queue +vi.mock("@karakeep/shared/queues", () => ({ + LinkCrawlerQueue: { + enqueue: vi.fn(), + }, + triggerRuleEngineOnEvent: vi.fn(), +})); + +describe("RuleEngine", () => { + let db: TestDB; + let ctx: AuthedContext; + let userId: string; + let bookmarkId: string; + let linkBookmarkId: string; + let _textBookmarkId: string; + let tagId1: string; + let tagId2: string; + let feedId1: string; + let listId1: string; + + // Helper to seed a rule + const seedRule = async ( + ruleData: Omit<RuleEngineRule, "id"> & { userId: string }, + ): Promise<string> => { + const [insertedRule] = await db + .insert(rules) + .values({ + userId: ruleData.userId, + name: ruleData.name, + description: ruleData.description, + enabled: ruleData.enabled, + event: JSON.stringify(ruleData.event), + condition: JSON.stringify(ruleData.condition), + }) + .returning({ id: rules.id }); + + await db.insert(ruleActions).values( + ruleData.actions.map((action) => ({ + ruleId: insertedRule.id, + action: JSON.stringify(action), + userId: ruleData.userId, + })), + ); + return insertedRule.id; + }; + + beforeEach(async () => { + vi.resetAllMocks(); + db = getInMemoryDB(/* runMigrations */ true); + + // Seed User + [userId] = ( + await db + .insert(users) + .values({ name: "Test User", email: "test@test.com" }) + .returning({ id: users.id }) + ).map((u) => u.id); + + ctx = { + user: { id: userId, role: "user" }, + db: db, // Cast needed because TestDB might have extra test methods + req: { ip: null }, + }; + + // Seed Tags + [tagId1, tagId2] = ( + await db + .insert(bookmarkTags) + .values([ + { name: "Tag1", userId }, + { name: "Tag2", userId }, + ]) + .returning({ id: bookmarkTags.id }) + ).map((t) => t.id); + + // Seed Feed + [feedId1] = ( + await db + .insert(rssFeedsTable) + .values({ name: "Feed1", userId, url: "https://example.com/feed1" }) + .returning({ id: rssFeedsTable.id }) + ).map((f) => f.id); + + // Seed List + [listId1] = ( + await db + .insert(bookmarkLists) + .values({ name: "List1", userId, type: "manual", icon: "📚" }) + .returning({ id: bookmarkLists.id }) + ).map((l) => l.id); + + // Seed Bookmarks + [linkBookmarkId] = ( + await db + .insert(bookmarks) + .values({ + userId, + type: BookmarkTypes.LINK, + favourited: false, + archived: false, + }) + .returning({ id: bookmarks.id }) + ).map((b) => b.id); + await db.insert(bookmarkLinks).values({ + id: linkBookmarkId, + url: "https://example.com/test", + }); + await db.insert(tagsOnBookmarks).values({ + bookmarkId: linkBookmarkId, + tagId: tagId1, + attachedBy: "human", + }); + await db.insert(rssFeedImportsTable).values({ + bookmarkId: linkBookmarkId, + rssFeedId: feedId1, + entryId: "entry-id", + }); + + [_textBookmarkId] = ( + await db + .insert(bookmarks) + .values({ + userId, + type: BookmarkTypes.TEXT, + favourited: true, + archived: false, + }) + .returning({ id: bookmarks.id }) + ).map((b) => b.id); + + bookmarkId = linkBookmarkId; // Default bookmark for most tests + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("RuleEngine.forBookmark static method", () => { + it("should initialize RuleEngine successfully for an existing bookmark", async () => { + const engine = await RuleEngine.forBookmark(ctx, bookmarkId); + expect(engine).toBeInstanceOf(RuleEngine); + }); + + it("should throw an error if bookmark is not found", async () => { + await expect( + RuleEngine.forBookmark(ctx, "nonexistent-bookmark"), + ).rejects.toThrow("Bookmark nonexistent-bookmark not found"); + }); + + it("should load rules associated with the bookmark's user", async () => { + const ruleId = await seedRule({ + userId, + name: "Test Rule", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "urlContains", str: "example" }, + actions: [{ type: "addTag", tagId: tagId2 }], + }); + + const engine = await RuleEngine.forBookmark(ctx, bookmarkId); + // @ts-expect-error Accessing private property for test verification + expect(engine.rules).toHaveLength(1); + // @ts-expect-error Accessing private property for test verification + expect(engine.rules[0].id).toBe(ruleId); + }); + }); + + describe("doesBookmarkMatchConditions", () => { + let engine: RuleEngine; + + beforeEach(async () => { + engine = await RuleEngine.forBookmark(ctx, bookmarkId); + }); + + it("should return true for urlContains condition", () => { + const condition: RuleEngineCondition = { + type: "urlContains", + str: "example.com", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for urlContains condition mismatch", () => { + const condition: RuleEngineCondition = { + type: "urlContains", + str: "nonexistent", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for importedFromFeed condition", () => { + const condition: RuleEngineCondition = { + type: "importedFromFeed", + feedId: feedId1, + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for importedFromFeed condition mismatch", () => { + const condition: RuleEngineCondition = { + type: "importedFromFeed", + feedId: "other-feed", + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for bookmarkTypeIs condition (link)", () => { + const condition: RuleEngineCondition = { + type: "bookmarkTypeIs", + bookmarkType: BookmarkTypes.LINK, + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for bookmarkTypeIs condition mismatch", () => { + const condition: RuleEngineCondition = { + type: "bookmarkTypeIs", + bookmarkType: BookmarkTypes.TEXT, + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for hasTag condition", () => { + const condition: RuleEngineCondition = { type: "hasTag", tagId: tagId1 }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for hasTag condition mismatch", () => { + const condition: RuleEngineCondition = { type: "hasTag", tagId: tagId2 }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return false for isFavourited condition (default)", () => { + const condition: RuleEngineCondition = { type: "isFavourited" }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for isFavourited condition when favourited", async () => { + await db + .update(bookmarks) + .set({ favourited: true }) + .where(eq(bookmarks.id, bookmarkId)); + const updatedEngine = await RuleEngine.forBookmark(ctx, bookmarkId); + const condition: RuleEngineCondition = { type: "isFavourited" }; + expect(updatedEngine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should return false for isArchived condition (default)", () => { + const condition: RuleEngineCondition = { type: "isArchived" }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should return true for isArchived condition when archived", async () => { + await db + .update(bookmarks) + .set({ archived: true }) + .where(eq(bookmarks.id, bookmarkId)); + const updatedEngine = await RuleEngine.forBookmark(ctx, bookmarkId); + const condition: RuleEngineCondition = { type: "isArchived" }; + expect(updatedEngine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should handle and condition (true)", () => { + const condition: RuleEngineCondition = { + type: "and", + conditions: [ + { type: "urlContains", str: "example" }, + { type: "hasTag", tagId: tagId1 }, + ], + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should handle and condition (false)", () => { + const condition: RuleEngineCondition = { + type: "and", + conditions: [ + { type: "urlContains", str: "example" }, + { type: "hasTag", tagId: tagId2 }, // This one is false + ], + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + + it("should handle or condition (true)", () => { + const condition: RuleEngineCondition = { + type: "or", + conditions: [ + { type: "urlContains", str: "nonexistent" }, // false + { type: "hasTag", tagId: tagId1 }, // true + ], + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(true); + }); + + it("should handle or condition (false)", () => { + const condition: RuleEngineCondition = { + type: "or", + conditions: [ + { type: "urlContains", str: "nonexistent" }, // false + { type: "hasTag", tagId: tagId2 }, // false + ], + }; + expect(engine.doesBookmarkMatchConditions(condition)).toBe(false); + }); + }); + + describe("evaluateRule", () => { + let ruleId: string; + let engine: RuleEngine; + let testRule: RuleEngineRule; + + beforeEach(async () => { + const tmp = { + id: "", // Will be set after seeding + userId, + name: "Evaluate Rule Test", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "urlContains", str: "example" }, + actions: [{ type: "addTag", tagId: tagId2 }], + } as Omit<RuleEngineRule, "id"> & { userId: string }; + ruleId = await seedRule(tmp); + testRule = { ...tmp, id: ruleId }; + engine = await RuleEngine.forBookmark(ctx, bookmarkId); + }); + + it("should evaluate rule successfully when event and conditions match", async () => { + const event: RuleEngineEvent = { type: "bookmarkAdded" }; + const results = await engine.evaluateRule(testRule, event); + expect(results).toEqual([ + { type: "success", ruleId: ruleId, message: `Added tag ${tagId2}` }, + ]); + // Verify action was performed + const tags = await db.query.tagsOnBookmarks.findMany({ + where: eq(tagsOnBookmarks.bookmarkId, bookmarkId), + }); + expect(tags.map((t) => t.tagId)).toContain(tagId2); + }); + + it("should return empty array if rule is disabled", async () => { + await db + .update(rules) + .set({ enabled: false }) + .where(eq(rules.id, ruleId)); + const disabledRule = { ...testRule, enabled: false }; + const event: RuleEngineEvent = { type: "bookmarkAdded" }; + const results = await engine.evaluateRule(disabledRule, event); + expect(results).toEqual([]); + }); + + it("should return empty array if event does not match", async () => { + const event: RuleEngineEvent = { type: "favourited" }; + const results = await engine.evaluateRule(testRule, event); + expect(results).toEqual([]); + }); + + it("should return empty array if condition does not match", async () => { + const nonMatchingRule: RuleEngineRule = { + ...testRule, + condition: { type: "urlContains", str: "nonexistent" }, + }; + await db + .update(rules) + .set({ condition: JSON.stringify(nonMatchingRule.condition) }) + .where(eq(rules.id, ruleId)); + + const event: RuleEngineEvent = { type: "bookmarkAdded" }; + const results = await engine.evaluateRule(nonMatchingRule, event); + expect(results).toEqual([]); + }); + + it("should return failure result if action fails", async () => { + // Mock addBookmark to throw an error + const listAddBookmarkSpy = vi + .spyOn(RuleEngine.prototype, "executeAction") + .mockImplementation(async (action: RuleEngineAction) => { + if (action.type === "addToList") { + throw new Error("Failed to add to list"); + } + // Call original for other actions if needed, though not strictly necessary here + return Promise.resolve(`Action ${action.type} executed`); + }); + + const ruleWithFailingAction = { + ...testRule, + actions: [{ type: "addToList", listId: "invalid-list" } as const], + }; + await db.delete(ruleActions).where(eq(ruleActions.ruleId, ruleId)); // Clear old actions + await db.insert(ruleActions).values({ + ruleId: ruleId, + action: JSON.stringify(ruleWithFailingAction.actions[0]), + userId, + }); + + const event: RuleEngineEvent = { type: "bookmarkAdded" }; + const results = await engine.evaluateRule(ruleWithFailingAction, event); + + expect(results).toEqual([ + { + type: "failure", + ruleId: ruleId, + message: "Failed to add to list", + }, + ]); + listAddBookmarkSpy.mockRestore(); + }); + }); + + describe("executeAction", () => { + let engine: RuleEngine; + + beforeEach(async () => { + engine = await RuleEngine.forBookmark(ctx, bookmarkId); + }); + + it("should execute addTag action", async () => { + const action: RuleEngineAction = { type: "addTag", tagId: tagId2 }; + const result = await engine.executeAction(action); + expect(result).toBe(`Added tag ${tagId2}`); + const tagLink = await db.query.tagsOnBookmarks.findFirst({ + where: and( + eq(tagsOnBookmarks.bookmarkId, bookmarkId), + eq(tagsOnBookmarks.tagId, tagId2), + ), + }); + expect(tagLink).toBeDefined(); + }); + + it("should execute removeTag action", async () => { + // Ensure tag exists first + expect( + await db.query.tagsOnBookmarks.findFirst({ + where: and( + eq(tagsOnBookmarks.bookmarkId, bookmarkId), + eq(tagsOnBookmarks.tagId, tagId1), + ), + }), + ).toBeDefined(); + + const action: RuleEngineAction = { type: "removeTag", tagId: tagId1 }; + const result = await engine.executeAction(action); + expect(result).toBe(`Removed tag ${tagId1}`); + const tagLink = await db.query.tagsOnBookmarks.findFirst({ + where: and( + eq(tagsOnBookmarks.bookmarkId, bookmarkId), + eq(tagsOnBookmarks.tagId, tagId1), + ), + }); + expect(tagLink).toBeUndefined(); + }); + + it("should execute addToList action", async () => { + const action: RuleEngineAction = { type: "addToList", listId: listId1 }; + const result = await engine.executeAction(action); + expect(result).toBe(`Added to list ${listId1}`); + const listLink = await db.query.bookmarksInLists.findFirst({ + where: and( + eq(bookmarksInLists.bookmarkId, bookmarkId), + eq(bookmarksInLists.listId, listId1), + ), + }); + expect(listLink).toBeDefined(); + }); + + it("should execute removeFromList action", async () => { + // Add to list first + await db + .insert(bookmarksInLists) + .values({ bookmarkId: bookmarkId, listId: listId1 }); + expect( + await db.query.bookmarksInLists.findFirst({ + where: and( + eq(bookmarksInLists.bookmarkId, bookmarkId), + eq(bookmarksInLists.listId, listId1), + ), + }), + ).toBeDefined(); + + const action: RuleEngineAction = { + type: "removeFromList", + listId: listId1, + }; + const result = await engine.executeAction(action); + expect(result).toBe(`Removed from list ${listId1}`); + const listLink = await db.query.bookmarksInLists.findFirst({ + where: and( + eq(bookmarksInLists.bookmarkId, bookmarkId), + eq(bookmarksInLists.listId, listId1), + ), + }); + expect(listLink).toBeUndefined(); + }); + + it("should execute downloadFullPageArchive action", async () => { + const action: RuleEngineAction = { type: "downloadFullPageArchive" }; + const result = await engine.executeAction(action); + expect(result).toBe(`Enqueued full page archive`); + expect(LinkCrawlerQueue.enqueue).toHaveBeenCalledWith({ + bookmarkId: bookmarkId, + archiveFullPage: true, + runInference: false, + }); + }); + + it("should execute favouriteBookmark action", async () => { + let bm = await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + expect(bm?.favourited).toBe(false); + + const action: RuleEngineAction = { type: "favouriteBookmark" }; + const result = await engine.executeAction(action); + expect(result).toBe(`Marked as favourited`); + + bm = await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + expect(bm?.favourited).toBe(true); + }); + + it("should execute archiveBookmark action", async () => { + let bm = await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + expect(bm?.archived).toBe(false); + + const action: RuleEngineAction = { type: "archiveBookmark" }; + const result = await engine.executeAction(action); + expect(result).toBe(`Marked as archived`); + + bm = await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + expect(bm?.archived).toBe(true); + }); + }); + + describe("onEvent", () => { + let ruleMatchId: string; + let _ruleNoMatchConditionId: string; + let _ruleNoMatchEventId: string; + let _ruleDisabledId: string; + let engine: RuleEngine; + + beforeEach(async () => { + // Rule that should match and execute + ruleMatchId = await seedRule({ + userId, + name: "Match Rule", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "urlContains", str: "example" }, + actions: [{ type: "addTag", tagId: tagId2 }], + }); + + // Rule with non-matching condition + _ruleNoMatchConditionId = await seedRule({ + userId, + name: "No Match Condition Rule", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "urlContains", str: "nonexistent" }, + actions: [{ type: "favouriteBookmark" }], + }); + + // Rule with non-matching event + _ruleNoMatchEventId = await seedRule({ + userId, + name: "No Match Event Rule", + description: "", + enabled: true, + event: { type: "favourited" }, // Must match rule event type + condition: { type: "urlContains", str: "example" }, + actions: [{ type: "archiveBookmark" }], + }); + + // Disabled rule + _ruleDisabledId = await seedRule({ + userId, + name: "Disabled Rule", + description: "", + enabled: false, // Disabled + event: { type: "bookmarkAdded" }, + condition: { type: "urlContains", str: "example" }, + actions: [{ type: "addToList", listId: listId1 }], + }); + + engine = await RuleEngine.forBookmark(ctx, bookmarkId); + }); + + it("should process event and return only results for matching, enabled rules", async () => { + const event: RuleEngineEvent = { type: "bookmarkAdded" }; + const results = await engine.onEvent(event); + + expect(results).toHaveLength(1); // Only ruleMatchId should produce a result + expect(results[0]).toEqual({ + type: "success", + ruleId: ruleMatchId, + message: `Added tag ${tagId2}`, + }); + + // Verify only the action from the matching rule was executed + const tags = await db.query.tagsOnBookmarks.findMany({ + where: eq(tagsOnBookmarks.bookmarkId, bookmarkId), + }); + expect(tags.map((t) => t.tagId)).toContain(tagId2); // Tag added by ruleMatchId + + const bm = await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + expect(bm?.favourited).toBe(false); // Action from ruleNoMatchConditionId not executed + expect(bm?.archived).toBe(false); // Action from ruleNoMatchEventId not executed + + const listLink = await db.query.bookmarksInLists.findFirst({ + where: and( + eq(bookmarksInLists.bookmarkId, bookmarkId), + eq(bookmarksInLists.listId, listId1), + ), + }); + expect(listLink).toBeUndefined(); // Action from ruleDisabledId not executed + }); + + it("should return empty array if no rules match the event", async () => { + const event: RuleEngineEvent = { type: "tagAdded", tagId: "some-tag" }; // Event that matches no rules + const results = await engine.onEvent(event); + expect(results).toEqual([]); + }); + }); +}); diff --git a/packages/trpc/lib/ruleEngine.ts b/packages/trpc/lib/ruleEngine.ts new file mode 100644 index 00000000..0bef8cdc --- /dev/null +++ b/packages/trpc/lib/ruleEngine.ts @@ -0,0 +1,231 @@ +import deepEql from "deep-equal"; +import { and, eq } from "drizzle-orm"; + +import { bookmarks, tagsOnBookmarks } from "@karakeep/db/schema"; +import { LinkCrawlerQueue } from "@karakeep/shared/queues"; +import { + RuleEngineAction, + RuleEngineCondition, + RuleEngineEvent, + RuleEngineRule, +} from "@karakeep/shared/types/rules"; + +import { AuthedContext } from ".."; +import { List } from "../models/lists"; +import { RuleEngineRuleModel } from "../models/rules"; + +async function fetchBookmark(db: AuthedContext["db"], bookmarkId: string) { + return await db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + with: { + link: { + columns: { + url: true, + }, + }, + text: true, + asset: true, + tagsOnBookmarks: true, + rssFeeds: { + columns: { + rssFeedId: true, + }, + }, + user: { + columns: {}, + with: { + rules: { + with: { + actions: true, + }, + }, + }, + }, + }, + }); +} + +type ReturnedBookmark = NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>; + +export interface RuleEngineEvaluationResult { + type: "success" | "failure"; + ruleId: string; + message: string; +} + +export class RuleEngine { + private constructor( + private ctx: AuthedContext, + private bookmark: Omit<ReturnedBookmark, "user">, + private rules: RuleEngineRule[], + ) {} + + static async forBookmark(ctx: AuthedContext, bookmarkId: string) { + const [bookmark, rules] = await Promise.all([ + fetchBookmark(ctx.db, bookmarkId), + RuleEngineRuleModel.getAll(ctx), + ]); + if (!bookmark) { + throw new Error(`Bookmark ${bookmarkId} not found`); + } + return new RuleEngine( + ctx, + bookmark, + rules.map((r) => r.rule), + ); + } + + doesBookmarkMatchConditions(condition: RuleEngineCondition): boolean { + switch (condition.type) { + case "alwaysTrue": { + return true; + } + case "urlContains": { + return (this.bookmark.link?.url ?? "").includes(condition.str); + } + case "importedFromFeed": { + return this.bookmark.rssFeeds.some( + (f) => f.rssFeedId === condition.feedId, + ); + } + case "bookmarkTypeIs": { + return this.bookmark.type === condition.bookmarkType; + } + case "hasTag": { + return this.bookmark.tagsOnBookmarks.some( + (t) => t.tagId === condition.tagId, + ); + } + case "isFavourited": { + return this.bookmark.favourited; + } + case "isArchived": { + return this.bookmark.archived; + } + case "and": { + return condition.conditions.every((c) => + this.doesBookmarkMatchConditions(c), + ); + } + case "or": { + return condition.conditions.some((c) => + this.doesBookmarkMatchConditions(c), + ); + } + default: { + const _exhaustiveCheck: never = condition; + return false; + } + } + } + + async evaluateRule( + rule: RuleEngineRule, + event: RuleEngineEvent, + ): Promise<RuleEngineEvaluationResult[]> { + if (!rule.enabled) { + return []; + } + if (!deepEql(rule.event, event, { strict: true })) { + return []; + } + if (!this.doesBookmarkMatchConditions(rule.condition)) { + return []; + } + const results = await Promise.allSettled( + rule.actions.map((action) => this.executeAction(action)), + ); + return results.map((result) => { + if (result.status === "fulfilled") { + return { + type: "success", + ruleId: rule.id, + message: result.value, + }; + } else { + return { + type: "failure", + ruleId: rule.id, + message: (result.reason as Error).message, + }; + } + }); + } + + async executeAction(action: RuleEngineAction): Promise<string> { + switch (action.type) { + case "addTag": { + await this.ctx.db + .insert(tagsOnBookmarks) + .values([ + { + attachedBy: "human", + bookmarkId: this.bookmark.id, + tagId: action.tagId, + }, + ]) + .onConflictDoNothing(); + return `Added tag ${action.tagId}`; + } + case "removeTag": { + await this.ctx.db + .delete(tagsOnBookmarks) + .where( + and( + eq(tagsOnBookmarks.tagId, action.tagId), + eq(tagsOnBookmarks.bookmarkId, this.bookmark.id), + ), + ); + return `Removed tag ${action.tagId}`; + } + case "addToList": { + const list = await List.fromId(this.ctx, action.listId); + await list.addBookmark(this.bookmark.id); + return `Added to list ${action.listId}`; + } + case "removeFromList": { + const list = await List.fromId(this.ctx, action.listId); + await list.removeBookmark(this.bookmark.id); + return `Removed from list ${action.listId}`; + } + case "downloadFullPageArchive": { + await LinkCrawlerQueue.enqueue({ + bookmarkId: this.bookmark.id, + archiveFullPage: true, + runInference: false, + }); + return `Enqueued full page archive`; + } + case "favouriteBookmark": { + await this.ctx.db + .update(bookmarks) + .set({ + favourited: true, + }) + .where(eq(bookmarks.id, this.bookmark.id)); + return `Marked as favourited`; + } + case "archiveBookmark": { + await this.ctx.db + .update(bookmarks) + .set({ + archived: true, + }) + .where(eq(bookmarks.id, this.bookmark.id)); + return `Marked as archived`; + } + default: { + const _exhaustiveCheck: never = action; + return ""; + } + } + } + + async onEvent(event: RuleEngineEvent): Promise<RuleEngineEvaluationResult[]> { + const results = await Promise.all( + this.rules.map((rule) => this.evaluateRule(rule, event)), + ); + + return results.flat(); + } +} diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index 8072060f..4da127d2 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { SqliteError } from "@karakeep/db"; import { bookmarkLists, bookmarksInLists } from "@karakeep/db/schema"; +import { triggerRuleEngineOnEvent } from "@karakeep/shared/queues"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { ZBookmarkList, @@ -117,7 +118,9 @@ export abstract class List implements PrivacyAware { } } - async update(input: z.infer<typeof zEditBookmarkListSchemaWithValidation>) { + async update( + input: z.infer<typeof zEditBookmarkListSchemaWithValidation>, + ): Promise<void> { const result = await this.ctx.db .update(bookmarkLists) .set({ @@ -137,7 +140,7 @@ export abstract class List implements PrivacyAware { if (result.length == 0) { throw new TRPCError({ code: "NOT_FOUND" }); } - return result[0]; + this.list = result[0]; } abstract get type(): "manual" | "smart"; @@ -248,6 +251,12 @@ export class ManualList extends List { listId: this.list.id, bookmarkId, }); + await triggerRuleEngineOnEvent(bookmarkId, [ + { + type: "addedToList", + listId: this.list.id, + }, + ]); } catch (e) { if (e instanceof SqliteError) { if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") { @@ -279,6 +288,12 @@ export class ManualList extends List { message: `Bookmark ${bookmarkId} is already not in list ${this.list.id}`, }); } + await triggerRuleEngineOnEvent(bookmarkId, [ + { + type: "removedFromList", + listId: this.list.id, + }, + ]); } async update(input: z.infer<typeof zEditBookmarkListSchemaWithValidation>) { diff --git a/packages/trpc/models/rules.ts b/packages/trpc/models/rules.ts new file mode 100644 index 00000000..7b17fd8a --- /dev/null +++ b/packages/trpc/models/rules.ts @@ -0,0 +1,233 @@ +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { db as DONT_USE_DB } from "@karakeep/db"; +import { + ruleEngineActionsTable, + ruleEngineRulesTable, +} from "@karakeep/db/schema"; +import { + RuleEngineRule, + zNewRuleEngineRuleSchema, + zRuleEngineActionSchema, + zRuleEngineConditionSchema, + zRuleEngineEventSchema, + zUpdateRuleEngineRuleSchema, +} from "@karakeep/shared/types/rules"; + +import { AuthedContext } from ".."; +import { PrivacyAware } from "./privacy"; + +function dummy_fetchRule(ctx: AuthedContext, id: string) { + return DONT_USE_DB.query.ruleEngineRulesTable.findFirst({ + where: and( + eq(ruleEngineRulesTable.id, id), + eq(ruleEngineRulesTable.userId, ctx.user.id), + ), + with: { + actions: true, // Assuming actions are related; adjust if needed + }, + }); +} + +type FetchedRuleType = NonNullable<Awaited<ReturnType<typeof dummy_fetchRule>>>; + +export class RuleEngineRuleModel implements PrivacyAware { + protected constructor( + protected ctx: AuthedContext, + public rule: RuleEngineRule & { userId: string }, + ) {} + + private static fromData( + ctx: AuthedContext, + ruleData: FetchedRuleType, + ): RuleEngineRuleModel { + return new RuleEngineRuleModel(ctx, { + id: ruleData.id, + userId: ruleData.userId, + name: ruleData.name, + description: ruleData.description, + enabled: ruleData.enabled, + event: zRuleEngineEventSchema.parse(JSON.parse(ruleData.event)), + condition: zRuleEngineConditionSchema.parse( + JSON.parse(ruleData.condition), + ), + actions: ruleData.actions.map((a) => + zRuleEngineActionSchema.parse(JSON.parse(a.action)), + ), + }); + } + + static async fromId( + ctx: AuthedContext, + id: string, + ): Promise<RuleEngineRuleModel> { + const ruleData = await ctx.db.query.ruleEngineRulesTable.findFirst({ + where: and( + eq(ruleEngineRulesTable.id, id), + eq(ruleEngineRulesTable.userId, ctx.user.id), + ), + with: { + actions: true, // Assuming actions are related; adjust if needed + }, + }); + + if (!ruleData) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Rule not found", + }); + } + + return this.fromData(ctx, ruleData); + } + + ensureCanAccess(ctx: AuthedContext): void { + if (this.rule.userId != ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } + } + + static async create( + ctx: AuthedContext, + input: z.infer<typeof zNewRuleEngineRuleSchema>, + ): Promise<RuleEngineRuleModel> { + // Similar to lists create, but for rules + const insertedRule = await ctx.db.transaction(async (tx) => { + const [newRule] = await tx + .insert(ruleEngineRulesTable) + .values({ + name: input.name, + description: input.description, + enabled: input.enabled, + event: JSON.stringify(input.event), + condition: JSON.stringify(input.condition), + userId: ctx.user.id, + listId: + input.event.type === "addedToList" || + input.event.type === "removedFromList" + ? input.event.listId + : null, + tagId: + input.event.type === "tagAdded" || input.event.type === "tagRemoved" + ? input.event.tagId + : null, + }) + .returning(); + + if (input.actions.length > 0) { + await tx.insert(ruleEngineActionsTable).values( + input.actions.map((action) => ({ + ruleId: newRule.id, + userId: ctx.user.id, + action: JSON.stringify(action), + listId: + action.type === "addToList" || action.type === "removeFromList" + ? action.listId + : null, + tagId: + action.type === "addTag" || action.type === "removeTag" + ? action.tagId + : null, + })), + ); + } + return newRule; + }); + + // Fetch the full rule after insertion + return await RuleEngineRuleModel.fromId(ctx, insertedRule.id); + } + + async update( + input: z.infer<typeof zUpdateRuleEngineRuleSchema>, + ): Promise<void> { + if (this.rule.id !== input.id) { + throw new TRPCError({ code: "BAD_REQUEST", message: "ID mismatch" }); + } + + await this.ctx.db.transaction(async (tx) => { + const result = await tx + .update(ruleEngineRulesTable) + .set({ + name: input.name, + description: input.description, + enabled: input.enabled, + event: JSON.stringify(input.event), + condition: JSON.stringify(input.condition), + listId: + input.event.type === "addedToList" || + input.event.type === "removedFromList" + ? input.event.listId + : null, + tagId: + input.event.type === "tagAdded" || input.event.type === "tagRemoved" + ? input.event.tagId + : null, + }) + .where( + and( + eq(ruleEngineRulesTable.id, input.id), + eq(ruleEngineRulesTable.userId, this.ctx.user.id), + ), + ); + + if (result.changes === 0) { + throw new TRPCError({ code: "NOT_FOUND", message: "Rule not found" }); + } + + if (input.actions.length > 0) { + await tx + .delete(ruleEngineActionsTable) + .where(eq(ruleEngineActionsTable.ruleId, input.id)); + await tx.insert(ruleEngineActionsTable).values( + input.actions.map((action) => ({ + ruleId: input.id, + userId: this.ctx.user.id, + action: JSON.stringify(action), + listId: + action.type === "addToList" || action.type === "removeFromList" + ? action.listId + : null, + tagId: + action.type === "addTag" || action.type === "removeTag" + ? action.tagId + : null, + })), + ); + } + }); + + this.rule = await RuleEngineRuleModel.fromId(this.ctx, this.rule.id).then( + (r) => r.rule, + ); + } + + async delete(): Promise<void> { + const result = await this.ctx.db + .delete(ruleEngineRulesTable) + .where( + and( + eq(ruleEngineRulesTable.id, this.rule.id), + eq(ruleEngineRulesTable.userId, this.ctx.user.id), + ), + ); + + if (result.changes === 0) { + throw new TRPCError({ code: "NOT_FOUND", message: "Rule not found" }); + } + } + + static async getAll(ctx: AuthedContext): Promise<RuleEngineRuleModel[]> { + const rulesData = await ctx.db.query.ruleEngineRulesTable.findMany({ + where: eq(ruleEngineRulesTable.userId, ctx.user.id), + with: { actions: true }, + }); + + return rulesData.map((r) => this.fromData(ctx, r)); + } +} diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 94fdee1b..5b5bad86 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -17,6 +17,7 @@ "@karakeep/shared": "workspace:*", "@trpc/server": "11.0.0", "bcryptjs": "^2.4.3", + "deep-equal": "^2.2.3", "drizzle-orm": "^0.38.3", "superjson": "^2.2.1", "tiny-invariant": "^1.3.3", @@ -27,6 +28,7 @@ "@karakeep/prettier-config": "workspace:^0.1.0", "@karakeep/tsconfig": "workspace:^0.1.0", "@types/bcryptjs": "^2.4.6", + "@types/deep-equal": "^1.0.4", "vite-tsconfig-paths": "^4.3.1", "vitest": "^1.6.1" }, diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index 7af19884..394e95e7 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -7,6 +7,7 @@ import { feedsAppRouter } from "./feeds"; import { highlightsAppRouter } from "./highlights"; import { listsAppRouter } from "./lists"; import { promptsAppRouter } from "./prompts"; +import { rulesAppRouter } from "./rules"; import { tagsAppRouter } from "./tags"; import { usersAppRouter } from "./users"; import { webhooksAppRouter } from "./webhooks"; @@ -23,6 +24,7 @@ export const appRouter = router({ highlights: highlightsAppRouter, webhooks: webhooksAppRouter, assets: assetsAppRouter, + rules: rulesAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 9a1b6b0b..b9a21400 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -45,6 +45,7 @@ import { AssetPreprocessingQueue, LinkCrawlerQueue, OpenAIQueue, + triggerRuleEngineOnEvent, triggerSearchDeletion, triggerSearchReindex, triggerWebhook, @@ -430,6 +431,11 @@ export const bookmarksAppRouter = router({ break; } } + await triggerRuleEngineOnEvent(bookmark.id, [ + { + type: "bookmarkAdded", + }, + ]); await triggerSearchReindex(bookmark.id); await triggerWebhook(bookmark.id, "created"); return bookmark; @@ -573,6 +579,17 @@ export const bookmarksAppRouter = router({ /* includeContent: */ false, ); + if (input.favourited === true || input.archived === true) { + await triggerRuleEngineOnEvent( + input.bookmarkId, + [ + ...(input.favourited === true ? ["favourited" as const] : []), + ...(input.archived === true ? ["archived" as const] : []), + ].map((t) => ({ + type: t, + })), + ); + } // Trigger re-indexing and webhooks await triggerSearchReindex(input.bookmarkId); await triggerWebhook(input.bookmarkId, "edited"); @@ -1141,6 +1158,16 @@ export const bookmarksAppRouter = router({ ), ); + await triggerRuleEngineOnEvent(input.bookmarkId, [ + ...idsToRemove.map((t) => ({ + type: "tagRemoved" as const, + tagId: t, + })), + ...allIds.map((t) => ({ + type: "tagAdded" as const, + tagId: t, + })), + ]); await triggerSearchReindex(input.bookmarkId); await triggerWebhook(input.bookmarkId, "edited"); return { diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index 12960316..65cffd2d 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -38,7 +38,8 @@ export const listsAppRouter = router({ .output(zBookmarkListSchema) .use(ensureListOwnership) .mutation(async ({ input, ctx }) => { - return await ctx.list.update(input); + await ctx.list.update(input); + return ctx.list.list; }), merge: authedProcedure .input(zMergeListSchema) diff --git a/packages/trpc/routers/rules.test.ts b/packages/trpc/routers/rules.test.ts new file mode 100644 index 00000000..6bbbcd84 --- /dev/null +++ b/packages/trpc/routers/rules.test.ts @@ -0,0 +1,379 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import { RuleEngineRule } from "@karakeep/shared/types/rules"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +describe("Rules Routes", () => { + let tagId1: string; + let tagId2: string; + let otherUserTagId: string; + + let listId: string; + let otherUserListId: string; + + beforeEach<CustomTestContext>(async (ctx) => { + await defaultBeforeEach(true)(ctx); + + tagId1 = ( + await ctx.apiCallers[0].tags.create({ + name: "Tag 1", + }) + ).id; + + tagId2 = ( + await ctx.apiCallers[0].tags.create({ + name: "Tag 2", + }) + ).id; + + otherUserTagId = ( + await ctx.apiCallers[1].tags.create({ + name: "Tag 1", + }) + ).id; + + listId = ( + await ctx.apiCallers[0].lists.create({ + name: "List 1", + icon: "😘", + }) + ).id; + + otherUserListId = ( + await ctx.apiCallers[1].lists.create({ + name: "List 1", + icon: "😘", + }) + ).id; + }); + + test<CustomTestContext>("create rule with valid data", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const validRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Valid Rule", + description: "A test rule", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [ + { type: "addTag", tagId: tagId1 }, + { type: "addToList", listId: listId }, + ], + }; + + const createdRule = await api.create(validRuleInput); + expect(createdRule).toMatchObject({ + name: "Valid Rule", + description: "A test rule", + enabled: true, + event: validRuleInput.event, + condition: validRuleInput.condition, + actions: validRuleInput.actions, + }); + }); + + test<CustomTestContext>("create rule fails with invalid data (no actions)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Missing actions", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [], // Empty actions array - should fail validation + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /You must specify at least one action/, + ); + }); + + test<CustomTestContext>("create rule fails with invalid event (empty tagId)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Invalid event", + enabled: true, + event: { type: "tagAdded", tagId: "" }, // Empty tagId - should fail + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /You must specify a tag for this event type/, + ); + }); + + test<CustomTestContext>("create rule fails with invalid condition (empty tagId in hasTag)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Invalid condition", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "hasTag", tagId: "" }, // Empty tagId - should fail + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /You must specify a tag for this condition type/, + ); + }); + + test<CustomTestContext>("update rule with valid data", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + // First, create a rule + const createdRule = await api.create({ + name: "Original Rule", + description: "Original desc", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + const validUpdateInput: RuleEngineRule = { + id: createdRule.id, + name: "Updated Rule", + description: "Updated desc", + enabled: false, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "removeTag", tagId: tagId2 }], + }; + + const updatedRule = await api.update(validUpdateInput); + expect(updatedRule).toMatchObject({ + id: createdRule.id, + name: "Updated Rule", + description: "Updated desc", + enabled: false, + event: validUpdateInput.event, + condition: validUpdateInput.condition, + actions: validUpdateInput.actions, + }); + }); + + test<CustomTestContext>("update rule fails with invalid data (empty action tagId)", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; + + // First, create a rule + const createdRule = await api.create({ + name: "Original Rule", + description: "Original desc", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + const invalidUpdateInput: RuleEngineRule = { + id: createdRule.id, + name: "Updated Rule", + description: "Updated desc", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "removeTag", tagId: "" }], // Empty tagId - should fail + }; + + await expect(() => api.update(invalidUpdateInput)).rejects.toThrow( + /You must specify a tag for this action type/, + ); + }); + + test<CustomTestContext>("delete rule", async ({ apiCallers }) => { + const api = apiCallers[0].rules; + + const createdRule = await api.create({ + name: "Rule to Delete", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + await api.delete({ id: createdRule.id }); + + // Attempt to fetch the rule should fail + await expect(() => + api.update({ ...createdRule, name: "Updated" }), + ).rejects.toThrow(/Rule not found/); + }); + + test<CustomTestContext>("list rules", async ({ apiCallers }) => { + const api = apiCallers[0].rules; + + await api.create({ + name: "Rule 1", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + await api.create({ + name: "Rule 2", + description: "", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId2 }], + }); + + const rulesList = await api.list(); + expect(rulesList.rules.length).toBeGreaterThanOrEqual(2); + expect(rulesList.rules.some((rule) => rule.name === "Rule 1")).toBeTruthy(); + expect(rulesList.rules.some((rule) => rule.name === "Rule 2")).toBeTruthy(); + }); + + describe("privacy checks", () => { + test<CustomTestContext>("cannot access or manipulate another user's rule", async ({ + apiCallers, + }) => { + const apiUserA = apiCallers[0].rules; // First user + const apiUserB = apiCallers[1].rules; // Second user + + // User A creates a rule + const createdRule = await apiUserA.create({ + name: "User A's Rule", + description: "A rule for User A", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }); + + // User B tries to update User A's rule + const updateInput: RuleEngineRule = { + id: createdRule.id, + name: "Trying to Update", + description: "Unauthorized update", + enabled: true, + event: createdRule.event, + condition: createdRule.condition, + actions: createdRule.actions, + }; + + await expect(() => apiUserB.update(updateInput)).rejects.toThrow( + /Rule not found/, + ); + }); + + test<CustomTestContext>("cannot create rule with event on another user's tag", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's tag + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Event with other user's tag", + enabled: true, + event: { type: "tagAdded", tagId: otherUserTagId }, // Other user's tag + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /Tag not found/, // Expect an error indicating lack of ownership + ); + }); + + test<CustomTestContext>("cannot create rule with condition on another user's tag", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's tag + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Condition with other user's tag", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "hasTag", tagId: otherUserTagId }, // Other user's tag + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /Tag not found/, + ); + }); + + test<CustomTestContext>("cannot create rule with action on another user's tag", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's tag + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Action with other user's tag", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: otherUserTagId }], // Other user's tag + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /Tag not found/, + ); + }); + + test<CustomTestContext>("cannot create rule with event on another user's list", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's list + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Event with other user's list", + enabled: true, + event: { type: "addedToList", listId: otherUserListId }, // Other user's list + condition: { type: "alwaysTrue" }, + actions: [{ type: "addTag", tagId: tagId1 }], + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /List not found/, + ); + }); + + test<CustomTestContext>("cannot create rule with action on another user's list", async ({ + apiCallers, + }) => { + const api = apiCallers[0].rules; // First user trying to use second user's list + + const invalidRuleInput: Omit<RuleEngineRule, "id"> = { + name: "Invalid Rule", + description: "Action with other user's list", + enabled: true, + event: { type: "bookmarkAdded" }, + condition: { type: "alwaysTrue" }, + actions: [{ type: "addToList", listId: otherUserListId }], // Other user's list + }; + + await expect(() => api.create(invalidRuleInput)).rejects.toThrow( + /List not found/, + ); + }); + }); +}); diff --git a/packages/trpc/routers/rules.ts b/packages/trpc/routers/rules.ts new file mode 100644 index 00000000..5def8003 --- /dev/null +++ b/packages/trpc/routers/rules.ts @@ -0,0 +1,120 @@ +import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; +import { and, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; + +import { bookmarkTags } from "@karakeep/db/schema"; +import { + RuleEngineRule, + zNewRuleEngineRuleSchema, + zRuleEngineRuleSchema, + zUpdateRuleEngineRuleSchema, +} from "@karakeep/shared/types/rules"; + +import { AuthedContext, authedProcedure, router } from "../index"; +import { List } from "../models/lists"; +import { RuleEngineRuleModel } from "../models/rules"; + +const ensureRuleOwnership = experimental_trpcMiddleware<{ + ctx: AuthedContext; + input: { id: string }; +}>().create(async (opts) => { + const rule = await RuleEngineRuleModel.fromId(opts.ctx, opts.input.id); + return opts.next({ + ctx: { + ...opts.ctx, + rule, + }, + }); +}); + +const ensureTagListOwnership = experimental_trpcMiddleware<{ + ctx: AuthedContext; + input: Omit<RuleEngineRule, "id">; +}>().create(async (opts) => { + const tagIds = [ + ...(opts.input.event.type === "tagAdded" || + opts.input.event.type === "tagRemoved" + ? [opts.input.event.tagId] + : []), + ...(opts.input.condition.type === "hasTag" + ? [opts.input.condition.tagId] + : []), + ...opts.input.actions.flatMap((a) => + a.type == "addTag" || a.type == "removeTag" ? [a.tagId] : [], + ), + ]; + + const validateTags = async () => { + if (tagIds.length == 0) { + return; + } + const userTags = await opts.ctx.db.query.bookmarkTags.findMany({ + where: and( + eq(bookmarkTags.userId, opts.ctx.user.id), + inArray(bookmarkTags.id, tagIds), + ), + columns: { + id: true, + }, + }); + if (tagIds.some((t) => userTags.find((u) => u.id == t) == null)) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Tag not found", + }); + } + }; + + const listIds = [ + ...(opts.input.event.type === "addedToList" || + opts.input.event.type === "removedFromList" + ? [opts.input.event.listId] + : []), + ...opts.input.actions.flatMap((a) => + a.type == "addToList" || a.type == "removeFromList" ? [a.listId] : [], + ), + ]; + + const [_tags, _lists] = await Promise.all([ + validateTags(), + Promise.all(listIds.map((l) => List.fromId(opts.ctx, l))), + ]); + return opts.next(); +}); + +export const rulesAppRouter = router({ + create: authedProcedure + .input(zNewRuleEngineRuleSchema) + .output(zRuleEngineRuleSchema) + .use(ensureTagListOwnership) + .mutation(async ({ input, ctx }) => { + const newRule = await RuleEngineRuleModel.create(ctx, input); + return newRule.rule; + }), + update: authedProcedure + .input(zUpdateRuleEngineRuleSchema) + .output(zRuleEngineRuleSchema) + .use(ensureRuleOwnership) + .use(ensureTagListOwnership) + .mutation(async ({ ctx, input }) => { + await ctx.rule.update(input); + return ctx.rule.rule; + }), + delete: authedProcedure + .input(z.object({ id: z.string() })) + .use(ensureRuleOwnership) + .mutation(async ({ ctx }) => { + await ctx.rule.delete(); + }), + list: authedProcedure + .output( + z.object({ + rules: z.array(zRuleEngineRuleSchema), + }), + ) + .query(async ({ ctx }) => { + return { + rules: (await RuleEngineRuleModel.getAll(ctx)).map((r) => r.rule), + }; + }), +}); diff --git a/packages/trpc/routers/tags.ts b/packages/trpc/routers/tags.ts index cdf47f4f..7f75c16e 100644 --- a/packages/trpc/routers/tags.ts +++ b/packages/trpc/routers/tags.ts @@ -18,7 +18,7 @@ function conditionFromInput(input: { tagId: string }, userId: string) { return and(eq(bookmarkTags.id, input.tagId), eq(bookmarkTags.userId, userId)); } -const ensureTagOwnership = experimental_trpcMiddleware<{ +export const ensureTagOwnership = experimental_trpcMiddleware<{ ctx: Context; input: { tagId: string }; }>().create(async (opts) => { |
