aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/models
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-04-27 00:02:20 +0100
committerGitHub <noreply@github.com>2025-04-27 00:02:20 +0100
commit136f126296af65f50da598d084d1485c0e40437a (patch)
tree2725c7932ebbcb9b48b5af98eb9b72329a400260 /packages/trpc/models
parentca47be7fe7be128f459c37614a04902a873fe289 (diff)
downloadkarakeep-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/trpc/models')
-rw-r--r--packages/trpc/models/lists.ts19
-rw-r--r--packages/trpc/models/rules.ts233
2 files changed, 250 insertions, 2 deletions
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));
+ }
+}