aboutsummaryrefslogtreecommitdiffstats
path: root/packages/shared
diff options
context:
space:
mode:
Diffstat (limited to 'packages/shared')
-rw-r--r--packages/shared/queues.ts28
-rw-r--r--packages/shared/types/rules.ts333
-rw-r--r--packages/shared/types/search.ts6
3 files changed, 364 insertions, 3 deletions
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,