diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx index 272394b19d..13b62a64cb 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx @@ -156,6 +156,7 @@ export function Rules({ size = "md" }: { size?: "sm" | "md" }) { from: null, subject: null, body: null, + promptText: null, }; return [...(baseRules || []), coldEmailBlockerRule]; }, [baseRules, emailAccountData, emailAccountId]); diff --git a/apps/web/prisma/migrations/20250609204102_rule_history/migration.sql b/apps/web/prisma/migrations/20250609204102_rule_history/migration.sql new file mode 100644 index 0000000000..18ede6f537 --- /dev/null +++ b/apps/web/prisma/migrations/20250609204102_rule_history/migration.sql @@ -0,0 +1,37 @@ +-- AlterTable +ALTER TABLE "Rule" ADD COLUMN "promptText" TEXT; + +-- CreateTable +CREATE TABLE "RuleHistory" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ruleId" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "triggerType" TEXT NOT NULL, + "promptText" TEXT, + "name" TEXT NOT NULL, + "instructions" TEXT, + "enabled" BOOLEAN NOT NULL, + "automate" BOOLEAN NOT NULL, + "runOnThreads" BOOLEAN NOT NULL, + "conditionalOperator" TEXT NOT NULL, + "from" TEXT, + "to" TEXT, + "subject" TEXT, + "body" TEXT, + "categoryFilterType" TEXT, + "systemType" TEXT, + "actions" JSONB NOT NULL, + "categoryFilters" JSONB, + + CONSTRAINT "RuleHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RuleHistory_ruleId_createdAt_idx" ON "RuleHistory"("ruleId", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "RuleHistory_ruleId_version_key" ON "RuleHistory"("ruleId", "version"); + +-- AddForeignKey +ALTER TABLE "RuleHistory" ADD CONSTRAINT "RuleHistory_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "Rule"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index ba6a705983..145ad1519d 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -250,6 +250,10 @@ model Rule { systemType SystemType? + promptText String? // natural language for this rule for prompt file. prompt file is combination of these fields + + history RuleHistory[] + @@unique([name, emailAccountId]) @@unique([emailAccountId, systemType]) } @@ -272,6 +276,35 @@ model Action { url String? } +model RuleHistory { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + ruleId String + rule Rule @relation(fields: [ruleId], references: [id], onDelete: Cascade) + version Int + triggerType String // "ai_update" (AI), "manual_update" (user), "ai_creation" (AI), "manual_creation" (user), "system_creation" (system), "system_update" (system) + promptText String? // The prompt text that generated this version + + name String + instructions String? + enabled Boolean + automate Boolean + runOnThreads Boolean + conditionalOperator String + from String? + to String? + subject String? + body String? + categoryFilterType String? + systemType String? + + actions Json + categoryFilters Json? + + @@unique([ruleId, version]) + @@index([ruleId, createdAt]) +} + // Rule/Action models represent the rules and actions that the AI can take. // ExecutedRule/ExecutedAction models represent the rules/actions that have been planned or executed by the AI. model ExecutedRule { diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 490b435aa2..941b220e0f 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -43,6 +43,7 @@ import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; import { getEmailAccountWithAi } from "@/utils/user/get"; import { prefixPath } from "@/utils/path"; +import { createRuleHistory } from "@/utils/rule/rule-history"; const logger = createScopedLogger("actions/rule"); @@ -110,6 +111,11 @@ export const createRuleAction = actionClient include: { actions: true, categoryFilters: true, group: true }, }); + // Track rule creation in history + after(() => + createRuleHistory({ rule, triggerType: "manual_creation" }), + ); + after(() => updatePromptFileOnRuleCreated({ emailAccountId, rule })); return { rule }; @@ -237,6 +243,14 @@ export const updateRuleAction = actionClient : []), ]); + // Track rule update in history + after(() => + createRuleHistory({ + rule: updatedRule, + triggerType: "manual_update", + }), + ); + // update prompt file after(() => updatePromptFileOnRuleUpdated({ diff --git a/apps/web/utils/reply-tracker/enable.ts b/apps/web/utils/reply-tracker/enable.ts index d16540a87d..67cf254380 100644 --- a/apps/web/utils/reply-tracker/enable.ts +++ b/apps/web/utils/reply-tracker/enable.ts @@ -113,6 +113,7 @@ export async function enableReplyTracker({ }, emailAccountId: emailAccount.id, systemType: SystemType.TO_REPLY, + triggerType: "system_creation", }); if (newRule && "error" in newRule) { diff --git a/apps/web/utils/rule/rule-history.ts b/apps/web/utils/rule/rule-history.ts new file mode 100644 index 0000000000..75e443d131 --- /dev/null +++ b/apps/web/utils/rule/rule-history.ts @@ -0,0 +1,74 @@ +import prisma from "@/utils/prisma"; +import type { RuleWithRelations } from "@/utils/ai/rule/create-prompt-from-rule"; + +export type RuleHistoryTrigger = + | "ai_update" // AI updates existing rule from prompt changes + | "manual_update" // User manually edits existing rule + | "ai_creation" // AI creates rule from parsing prompts + | "manual_creation" // User manually creates new rule + | "system_creation" // System automatically creates rule (e.g., reply tracker) + | "system_update"; // System automatically updates rule + +/** + * Creates a complete snapshot of a rule in the RuleHistory table + */ +export async function createRuleHistory({ + rule, + triggerType, +}: { + rule: RuleWithRelations; + triggerType: RuleHistoryTrigger; +}) { + // Get the current version number for this rule + const lastHistory = await prisma.ruleHistory.findFirst({ + where: { ruleId: rule.id }, + orderBy: { version: "desc" }, + select: { version: true }, + }); + + const nextVersion = (lastHistory?.version ?? 0) + 1; + + // Serialize actions to JSON + const actionsSnapshot = rule.actions.map((action) => ({ + id: action.id, + type: action.type, + label: action.label, + subject: action.subject, + content: action.content, + to: action.to, + cc: action.cc, + bcc: action.bcc, + url: action.url, + })); + + // Serialize category filters to JSON + const categoryFiltersSnapshot = rule.categoryFilters?.map((category) => ({ + id: category.id, + name: category.name, + description: category.description, + })); + + return prisma.ruleHistory.create({ + data: { + ruleId: rule.id, + name: rule.name, + instructions: rule.instructions, + enabled: rule.enabled, + automate: rule.automate, + runOnThreads: rule.runOnThreads, + conditionalOperator: rule.conditionalOperator, + from: rule.from, + to: rule.to, + subject: rule.subject, + body: rule.body, + categoryFilterType: rule.categoryFilterType, + systemType: rule.systemType, + promptText: rule.promptText, + actions: actionsSnapshot, + categoryFilters: categoryFiltersSnapshot, + triggerType, + // Note: this is unique and can fail in race conditions. Not a big deal for now. + version: nextVersion, + }, + }); +} diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index de96cb70e2..610b2eda71 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -11,6 +11,7 @@ import { getUserCategoriesForNames } from "@/utils/category.server"; import { getActionRiskLevel, type RiskAction } from "@/utils/risk"; import { hasExampleParams } from "@/app/(app)/[emailAccountId]/assistant/examples"; import { SafeError } from "@/utils/error"; +import { createRuleHistory } from "@/utils/rule/rule-history"; const logger = createScopedLogger("rule"); @@ -33,11 +34,13 @@ export async function safeCreateRule({ emailAccountId, categoryNames, systemType, + triggerType = "ai_creation", }: { result: CreateOrUpdateRuleSchemaWithCategories; emailAccountId: string; categoryNames?: string[] | null; systemType?: SystemType | null; + triggerType?: "ai_creation" | "manual_creation" | "system_creation"; }) { const categoryIds = await getUserCategoriesForNames({ emailAccountId, @@ -50,6 +53,7 @@ export async function safeCreateRule({ emailAccountId, categoryIds, systemType, + triggerType, }); return rule; } catch (error) { @@ -59,6 +63,7 @@ export async function safeCreateRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, emailAccountId, categoryIds, + triggerType, }); return rule; } @@ -79,11 +84,13 @@ export async function safeUpdateRule({ result, emailAccountId, categoryIds, + triggerType = "ai_update", }: { ruleId: string; result: CreateOrUpdateRuleSchemaWithCategories; emailAccountId: string; categoryIds?: string[] | null; + triggerType?: "ai_update" | "manual_update" | "system_update"; }) { try { const rule = await updateRule({ @@ -91,6 +98,7 @@ export async function safeUpdateRule({ result, emailAccountId, categoryIds, + triggerType, }); return { id: rule.id }; } catch (error) { @@ -100,6 +108,7 @@ export async function safeUpdateRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, emailAccountId, categoryIds, + triggerType: "ai_creation", // Default for safeUpdateRule fallback }); return { id: rule.id }; } @@ -121,15 +130,17 @@ export async function createRule({ emailAccountId, categoryIds, systemType, + triggerType = "ai_creation", }: { result: CreateOrUpdateRuleSchemaWithCategories; emailAccountId: string; categoryIds?: string[] | null; systemType?: SystemType | null; + triggerType?: "ai_creation" | "manual_creation" | "system_creation"; }) { const mappedActions = mapActionFields(result.actions); - return prisma.rule.create({ + const rule = await prisma.rule.create({ data: { name: result.name, emailAccountId, @@ -163,6 +174,11 @@ export async function createRule({ }, include: { actions: true, categoryFilters: true, group: true }, }); + + // Track rule creation in history + await createRuleHistory({ rule, triggerType }); + + return rule; } async function updateRule({ @@ -170,13 +186,15 @@ async function updateRule({ result, emailAccountId, categoryIds, + triggerType = "ai_update", }: { ruleId: string; result: CreateOrUpdateRuleSchemaWithCategories; emailAccountId: string; categoryIds?: string[] | null; + triggerType?: "ai_update" | "manual_update" | "system_update"; }) { - return prisma.rule.update({ + const rule = await prisma.rule.update({ where: { id: ruleId }, data: { name: result.name, @@ -201,7 +219,13 @@ async function updateRule({ } : undefined, }, + include: { actions: true, categoryFilters: true, group: true }, }); + + // Track rule update in history + await createRuleHistory({ rule, triggerType }); + + return rule; } export async function updateRuleActions({ diff --git a/version.txt b/version.txt index 981f7cd00c..ec7b967829 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.3.29 \ No newline at end of file +v1.4.0 \ No newline at end of file