From 136f126296af65f50da598d084d1485c0e40437a Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 27 Apr 2025 00:02:20 +0100 Subject: 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 --- packages/trpc/lib/__tests__/ruleEngine.test.ts | 664 +++++++++++++++++++++++++ packages/trpc/lib/ruleEngine.ts | 231 +++++++++ 2 files changed, 895 insertions(+) create mode 100644 packages/trpc/lib/__tests__/ruleEngine.test.ts create mode 100644 packages/trpc/lib/ruleEngine.ts (limited to 'packages/trpc/lib') 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 & { userId: string }, + ): Promise => { + 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 & { 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>>; + +export interface RuleEngineEvaluationResult { + type: "success" | "failure"; + ruleId: string; + message: string; +} + +export class RuleEngine { + private constructor( + private ctx: AuthedContext, + private bookmark: Omit, + 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 { + 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 { + 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 { + const results = await Promise.all( + this.rules.map((rule) => this.evaluateRule(rule, event)), + ); + + return results.flat(); + } +} -- cgit v1.2.3-70-g09d2