diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index 591623a828..b149aac83e 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -3,13 +3,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import useSWR from "swr"; import { type FieldError, - type FieldErrors, type SubmitHandler, - type UseFormRegisterReturn, - type UseFormSetValue, useFieldArray, useForm, } from "react-hook-form"; @@ -23,11 +19,7 @@ import { Card } from "@/components/Card"; import { Button } from "@/components/ui/button"; import { ErrorMessage, Input, Label } from "@/components/Input"; import { toastError, toastSuccess } from "@/components/Toast"; -import { - MessageText, - SectionDescription, - TypographyH3, -} from "@/components/Typography"; +import { SectionDescription, TypographyH3 } from "@/components/Typography"; import { ActionType, CategoryFilterType, @@ -42,16 +34,8 @@ import { import { actionInputs } from "@/utils/action-item"; import { Select } from "@/components/Select"; import { Toggle } from "@/components/Toggle"; -import type { GroupsResponse } from "@/app/api/user/group/route"; import { LoadingContent } from "@/components/LoadingContent"; import { TooltipExplanation } from "@/components/TooltipExplanation"; -import { ViewGroupButton } from "@/app/(app)/automation/group/ViewGroup"; -import { CreateGroupModalButton } from "@/app/(app)/automation/group/CreateGroupModal"; -import { createPredefinedGroupAction } from "@/utils/actions/group"; -import { - NEWSLETTER_GROUP_ID, - RECEIPT_GROUP_ID, -} from "@/app/(app)/automation/create/examples"; import { isActionError } from "@/utils/error"; import { Combobox } from "@/components/Combobox"; import { useLabels } from "@/hooks/useLabels"; @@ -70,6 +54,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { LearnedPatterns } from "@/app/(app)/automation/group/LearnedPatterns"; export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { const { @@ -78,7 +63,7 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { watch, setValue, control, - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, isSubmitted }, trigger, } = useForm({ resolver: zodResolver(createRuleBody), @@ -168,7 +153,9 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { const conditions = watch("conditions"); const unusedCondition = useMemo(() => { const usedConditions = new Set(conditions?.map(({ type }) => type)); - return Object.values(RuleType).find((type) => !usedConditions.has(type)); + return [RuleType.AI, RuleType.STATIC, RuleType.CATEGORY].find( + (type) => !usedConditions.has(type), + ) as Exclude | undefined; }, [conditions]); // biome-ignore lint/correctness/useExhaustiveDependencies: @@ -192,6 +179,21 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { return (
+ {isSubmitted && Object.keys(errors).length > 0 && ( +
+ + {Object.values(errors).map((error) => ( +
  • {error.message}
  • + ))} + + } + /> +
    + )} +
    )} - {watch(`conditions.${index}.type`) === RuleType.GROUP && ( - - )} - {watch(`conditions.${index}.type`) === RuleType.CATEGORY && ( <>
    @@ -498,6 +489,12 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) {
    )} + {rule.groupId && ( +
    + +
    + )} + Actions {actionErrors.length > 0 && ( @@ -717,93 +714,6 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { ); } -function GroupsTab(props: { - registerProps: UseFormRegisterReturn; - setValue: UseFormSetValue; - errors: FieldErrors; - groupId?: string | null; - ruleId?: string | null; -}) { - const { setValue, ruleId } = props; - const { data, isLoading, error, mutate } = - useSWR("/api/user/group"); - const [loadingCreateGroup, setLoadingCreateGroup] = useState(false); - - useEffect(() => { - async function createGroup(groupId: string) { - setLoadingCreateGroup(true); - - const result = await createPredefinedGroupAction(groupId); - - if (isActionError(result)) { - toastError({ description: result.error }); - } else if (!result) { - toastError({ description: "Error creating group" }); - } else { - mutate(); - setValue("conditions", [{ groupId: result.id, type: RuleType.GROUP }]); - } - - setLoadingCreateGroup(false); - } - - if ( - props.groupId === NEWSLETTER_GROUP_ID || - props.groupId === RECEIPT_GROUP_ID - ) { - createGroup(props.groupId); - } - }, [mutate, props.groupId, setValue]); - - return ( -
    - - A group is a static collection of senders or subjects. - - - {loadingCreateGroup && ( - - Creating group with AI... This will take up to 30 seconds. - - )} - - -
    - {data?.groups && data?.groups.length > 0 && ( -
    - - - - - ); -} diff --git a/apps/web/app/(app)/automation/group/Groups.tsx b/apps/web/app/(app)/automation/group/Groups.tsx index e7ce43e02b..84ca21edcc 100644 --- a/apps/web/app/(app)/automation/group/Groups.tsx +++ b/apps/web/app/(app)/automation/group/Groups.tsx @@ -19,7 +19,6 @@ import { TableRow, } from "@/components/ui/table"; import type { GroupsResponse } from "@/app/api/user/group/route"; -import { CreateGroupModalButton } from "@/app/(app)/automation/group/CreateGroupModal"; import { Button } from "@/components/ui/button"; import { RuleType } from "@prisma/client"; @@ -38,11 +37,6 @@ export function Groups() { group using our AI.
    -
    - group.name) || []} - /> -
    diff --git a/apps/web/app/(app)/automation/group/LearnedPatterns.tsx b/apps/web/app/(app)/automation/group/LearnedPatterns.tsx new file mode 100644 index 0000000000..e09d606bc0 --- /dev/null +++ b/apps/web/app/(app)/automation/group/LearnedPatterns.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from "@/components/ui/collapsible"; +import { BrainIcon, ChevronDownIcon } from "lucide-react"; +import { ViewGroup } from "@/app/(app)/automation/group/ViewGroup"; + +export function LearnedPatterns({ groupId }: { groupId: string }) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + +
    + + + Learned Patterns (previously known as Groups) + +
    + +
    + {/*
    + + setAutoLearn(enabled)} + /> +
    */} + + +
    +
    + + + + +
    + ); +} diff --git a/apps/web/app/(app)/automation/group/ViewGroup.tsx b/apps/web/app/(app)/automation/group/ViewGroup.tsx index ee0971cd4a..61f0feb3f2 100644 --- a/apps/web/app/(app)/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/automation/group/ViewGroup.tsx @@ -1,13 +1,8 @@ "use client"; import useSWR, { type KeyedMutator } from "swr"; -import { - PlusIcon, - SparklesIcon, - TrashIcon, - PenIcon, - MailIcon, -} from "lucide-react"; +import Link from "next/link"; +import { PlusIcon, ExternalLinkIcon, TrashIcon } from "lucide-react"; import { useState, useCallback, @@ -19,215 +14,85 @@ import { capitalCase } from "capital-case"; import { toastSuccess, toastError } from "@/components/Toast"; import type { GroupItemsResponse } from "@/app/api/user/group/[groupId]/items/route"; import { LoadingContent } from "@/components/LoadingContent"; -import { Modal, useModal } from "@/components/Modal"; import { Button } from "@/components/ui/button"; -import { ButtonLoader } from "@/components/Loading"; import { Skeleton } from "@/components/ui/skeleton"; import { Table, - TableHeader, TableRow, - TableHead, TableBody, TableCell, + TableHeader, + TableHead, } from "@/components/ui/table"; -import { - MessageText, - PageHeading, - SectionDescription, -} from "@/components/Typography"; +import { MessageText } from "@/components/Typography"; import { addGroupItemAction, - deleteGroupAction, deleteGroupItemAction, - regenerateGroupAction, - updateGroupPromptAction, } from "@/utils/actions/group"; -import { GroupName } from "@/utils/config"; -import { type GroupItem, GroupItemType, RuleType } from "@prisma/client"; +import { type GroupItem, GroupItemType } from "@prisma/client"; import { Input } from "@/components/Input"; import { Select } from "@/components/Select"; import { zodResolver } from "@hookform/resolvers/zod"; import { type AddGroupItemBody, addGroupItemBody, - updateGroupPromptBody, - type UpdateGroupPromptBody, } from "@/utils/actions/validation"; import { isActionError } from "@/utils/error"; import { Badge } from "@/components/ui/badge"; -import Link from "next/link"; - -export function ViewGroupButton({ - groupId, - ButtonComponent, -}: { - groupId: string; - ButtonComponent?: React.ComponentType<{ onClick: () => void }>; -}) { - const { isModalOpen, openModal, closeModal } = useModal(); - - return ( - <> - {ButtonComponent ? ( - - ) : ( - - )} - - - - - ); -} -export function ViewGroup({ - groupId, - onDelete, -}: { - groupId: string; - onDelete: () => void; -}) { +export function ViewGroup({ groupId }: { groupId: string }) { const { data, isLoading, error, mutate } = useSWR( `/api/user/group/${groupId}/items`, ); const group = data?.group; - const groupName = group?.name; const [showAddItem, setShowAddItem] = useState(false); - const [isRegenerating, setIsRegenerating] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); return ( -
    - {groupName} - {group?.prompt && ( - + {showAddItem ? ( + - )} + ) : ( +
    +
    + {/*
    + + {}} + /> +
    */} + +
    + -
    - {showAddItem ? ( - - ) : ( - <> - {group?.rule ? ( + - )} - -
    - - - - - {(groupName === GroupName.NEWSLETTER || - groupName === GroupName.RECEIPT || - group?.prompt) && ( - - )} - - -
    - - )} -
    + +
    +
    + )} -
    +
    {data && (group?.items.length ? ( - <> - - - - Sender - - - - - {group?.items.map((item) => { - // within last 2 minutes - const isRecent = - new Date(item.createdAt) > - new Date(Date.now() - 1000 * 60 * 2); - - return ( - - - {isRecent && ( - - New! - - )} - - - - - - - - ); - })} - -
    - + ) : ( There are no senders in this group. @@ -336,10 +150,21 @@ const AddGroupItemForm = ({ [mutate, onClose], ); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + void handleSubmit(onSubmit)(e); + } + }, + [handleSubmit, onSubmit], + ); + return ( -
    -
    - - -
    -
    +
    + +
    +
    + + + + + + ); + })} + + {items.length === 0 && ( + + + No items + + + )} + + ); } diff --git a/apps/web/app/(app)/automation/group/[groupId]/page.tsx b/apps/web/app/(app)/automation/group/[groupId]/page.tsx index 8abee03f47..fa9c818262 100644 --- a/apps/web/app/(app)/automation/group/[groupId]/page.tsx +++ b/apps/web/app/(app)/automation/group/[groupId]/page.tsx @@ -1,21 +1,12 @@ -"use client"; - -import { useRouter } from "next/navigation"; import { ViewGroup } from "@/app/(app)/automation/group/ViewGroup"; import { Container } from "@/components/Container"; +// Not in use anymore. Could delete this. export default function GroupPage({ params }: { params: { groupId: string } }) { - const router = useRouter(); - return (
    - { - router.push("/automation?tab=groups"); - }} - /> +
    ); diff --git a/apps/web/app/(app)/automation/page.tsx b/apps/web/app/(app)/automation/page.tsx index 7cfbfad23e..639fdd115e 100644 --- a/apps/web/app/(app)/automation/page.tsx +++ b/apps/web/app/(app)/automation/page.tsx @@ -61,6 +61,7 @@ export default async function AutomationPage() { + {/* no longer in use */} diff --git a/apps/web/app/(app)/automation/rule/create/page.tsx b/apps/web/app/(app)/automation/rule/create/page.tsx index 99921e99ce..09e01ccd27 100644 --- a/apps/web/app/(app)/automation/rule/create/page.tsx +++ b/apps/web/app/(app)/automation/rule/create/page.tsx @@ -9,7 +9,7 @@ export default function CreateRulePage({ searchParams: { example?: string; groupId?: string; - type?: RuleType; + type?: Exclude; categoryId?: string; label?: string; }; @@ -33,13 +33,7 @@ export default function CreateRulePage({ ] : [], conditions: searchParams.type - ? [ - getEmptyCondition( - searchParams.type, - searchParams.groupId, - searchParams.categoryId, - ), - ] + ? [getEmptyCondition(searchParams.type, searchParams.categoryId)] : [], automate: true, } diff --git a/apps/web/app/(app)/smart-categories/page.tsx b/apps/web/app/(app)/smart-categories/page.tsx index a1f4eb6113..8fa15869f6 100644 --- a/apps/web/app/(app)/smart-categories/page.tsx +++ b/apps/web/app/(app)/smart-categories/page.tsx @@ -100,10 +100,10 @@ export default async function CategoriesPage() { {senders.length === 0 && ( - Categorize with AI + Categorize Senders Now that you have some categories, our AI can categorize - senders for you automatically. + senders. diff --git a/apps/web/app/(app)/smart-categories/setup/SetUpCategories.tsx b/apps/web/app/(app)/smart-categories/setup/SetUpCategories.tsx index cc716bfc86..a345025620 100644 --- a/apps/web/app/(app)/smart-categories/setup/SetUpCategories.tsx +++ b/apps/web/app/(app)/smart-categories/setup/SetUpCategories.tsx @@ -110,10 +110,10 @@ export function SetUpCategories({ <> - Set up categories + Set up sender categories - Automatically sort your emails by sender type to enable smart - archiving and AI automation. + Automatically categorize senders for bulk archiving and AI + automation. diff --git a/apps/web/components/ui/collapsible.tsx b/apps/web/components/ui/collapsible.tsx new file mode 100644 index 0000000000..cb003d1756 --- /dev/null +++ b/apps/web/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client"; + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/apps/web/package.json b/apps/web/package.json index a25931f429..47311f6a4d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,6 +41,7 @@ "@portabletext/react": "^3.2.0", "@prisma/client": "^6.1.0", "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-hover-card": "^1.1.4", diff --git a/apps/web/prisma/migrations/20250128141602_cascade_delete_group/migration.sql b/apps/web/prisma/migrations/20250128141602_cascade_delete_group/migration.sql new file mode 100644 index 0000000000..69013839af --- /dev/null +++ b/apps/web/prisma/migrations/20250128141602_cascade_delete_group/migration.sql @@ -0,0 +1,13 @@ +-- DropForeignKey +ALTER TABLE "Rule" DROP CONSTRAINT "Rule_groupId_fkey"; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Delete groups that are not used by any rule +DELETE FROM "Group" +WHERE "id" NOT IN ( + SELECT "groupId" + FROM "Rule" + WHERE "groupId" IS NOT NULL +); \ No newline at end of file diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index a8d55a9e4f..53ef072346 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -191,7 +191,7 @@ model Rule { // group condition groupId String? @unique - group Group? @relation(fields: [groupId], references: [id]) + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) // static condition // automatically apply this rule if it matches a filter. supports regex @@ -276,6 +276,12 @@ model ExecutedAction { url String? } +// Notes: +// In the past groups stood on their own. Now they are attached to a rule. +// A group without a rule does not do anything anymore. I may delete all detached groups in the future, and then make rule required +// "Prompt" is no longer in use. It was used to generate the group, but now it's based on the rule the group is attached to. +// "Name" is no longer in use although still required. +// If we really wanted we could remove Group and just have a relation between Rule and GroupItem, but leaving as is for now. model Group { id String @id @default(cuid()) createdAt DateTime @default(now()) diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 3394b444cd..bb5a473d7d 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -12,11 +12,6 @@ import { } from "@/utils/ai/choose-rule/run-rules"; import { emailToContent, parseMessage } from "@/utils/mail"; import { getMessage, getMessages } from "@/utils/gmail/message"; -import { - createNewsletterGroupAction, - createReceiptGroupAction, -} from "@/utils/actions/group"; -import { GroupName } from "@/utils/config"; import { executeAct } from "@/utils/ai/choose-rule/execute"; import { isDefined, type ParsedMessage } from "@/utils/types"; import { getSessionAndGmailClient } from "@/utils/actions/helpers"; @@ -42,7 +37,7 @@ import { aiFindSnippets } from "@/utils/ai/snippets/find-snippets"; import { aiRuleFix } from "@/utils/ai/rule/rule-fix"; import { labelVisibility } from "@/utils/gmail/constants"; import type { CreateOrUpdateRuleSchemaWithCategories } from "@/utils/ai/rule/create-rule-schema"; -import { safeCreateRule, safeUpdateRule } from "@/utils/rule/rule"; +import { deleteRule, safeCreateRule, safeUpdateRule } from "@/utils/rule/rule"; import { getUserCategoriesForNames } from "@/utils/category.server"; const logger = createScopedLogger("ai-rule"); @@ -192,6 +187,7 @@ export const createAutomationAction = withActionInstrumentation< const session = await auth(); const userId = session?.user.id; if (!userId) return { error: "Not logged in" }; + if (!session.accessToken) return { error: "No access token" }; const user = await prisma.user.findUnique({ where: { id: userId }, @@ -215,76 +211,9 @@ export const createAutomationAction = withActionInstrumentation< if (!result) return { error: "AI error creating rule." }; - const groupIdResult = await getGroupId(result, userId); - if (isActionError(groupIdResult)) return groupIdResult; - return await safeCreateRule(result, userId, groupIdResult, null); + return await safeCreateRule(result, userId, null); }); -async function getGroupId( - result: CreateOrUpdateRuleSchemaWithCategories, - userId: string, -) { - let groupId: string | null = null; - - if (result.condition.group && result.condition.group) { - const groups = await prisma.group.findMany({ - where: { userId }, - select: { id: true, name: true, rule: true }, - }); - - if (result.condition.group === GroupName.NEWSLETTER) { - const newsletterGroup = groups.find((g) => - g.name.toLowerCase().includes("newsletter"), - ); - if (newsletterGroup) { - if (newsletterGroup.rule) { - return { - error: "Newsletter group already has a rule", - existingRuleId: newsletterGroup.rule.id, - }; - } - - groupId = newsletterGroup.id; - } else { - const result = await createNewsletterGroupAction(); - if (isActionError(result)) { - return result; - } - if (!result) { - return { error: "Error creating newsletter group" }; - } - groupId = result.id; - } - } else if (result.condition.group === GroupName.RECEIPT) { - const receiptsGroup = groups.find((g) => - g.name.toLowerCase().includes("receipt"), - ); - - if (receiptsGroup) { - groupId = receiptsGroup.id; - - if (receiptsGroup.rule) { - return { - error: "Receipt group already has a rule", - existingRuleId: receiptsGroup.rule.id, - }; - } - } else { - const result = await createReceiptGroupAction(); - if (isActionError(result)) { - return result; - } - if (!result) { - return { error: "Error creating receipt group" }; - } - groupId = result.id; - } - } - } - - return groupId; -} - export const setRuleAutomatedAction = withActionInstrumentation( "setRuleAutomated", async ({ ruleId, automate }: { ruleId: string; automate: boolean }) => { @@ -378,6 +307,7 @@ export const saveRulesPromptAction = withActionInstrumentation( async (unsafeData: SaveRulesPromptBody) => { const session = await auth(); if (!session?.user.email) return { error: "Not logged in" }; + if (!session.accessToken) return { error: "No access token" }; setUser({ email: session.user.email }); logger.info("Starting saveRulesPromptAction", { @@ -524,8 +454,10 @@ export const saveRulesPromptAction = withActionInstrumentation( }); } else { try { - await prisma.rule.delete({ - where: { id: rule.rule.id, userId: session.user.id }, + await deleteRule({ + ruleId: rule.rule.id, + userId: session.user.id, + groupId: rule.rule.groupId, }); } catch (error) { if (!isNotFoundError(error)) { @@ -569,16 +501,6 @@ export const saveRulesPromptAction = withActionInstrumentation( ruleId: rule.ruleId, }); - const groupIdResult = await getGroupId(rule, session.user.id); - if (isActionError(groupIdResult)) { - logger.error("Error updating group for rule", { - email: user.email, - ruleId: rule.ruleId, - error: groupIdResult.error, - }); - continue; - } - const categoryIds = await getUserCategoriesForNames( session.user.id, rule.condition.categories?.categoryFilters || [], @@ -586,13 +508,7 @@ export const saveRulesPromptAction = withActionInstrumentation( editRulesCount++; - await safeUpdateRule( - rule.ruleId, - rule, - session.user.id, - groupIdResult, - categoryIds, - ); + await safeUpdateRule(rule.ruleId, rule, session.user.id, categoryIds); } } } else { @@ -619,20 +535,9 @@ export const saveRulesPromptAction = withActionInstrumentation( ruleId: rule.ruleId, }); - const groupIdResult = await getGroupId(rule, session.user.id); - if (isActionError(groupIdResult)) { - logger.error("Error creating group for rule", { - email: user.email, - ruleId: rule.ruleId, - error: groupIdResult.error, - }); - continue; - } - await safeCreateRule( rule, session.user.id, - groupIdResult, rule.condition.categories?.categoryFilters || [], ); } diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index 7a58322cca..4ab9210a1c 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -1,391 +1,14 @@ "use server"; import { revalidatePath } from "next/cache"; -import type { gmail_v1 } from "@googleapis/gmail"; -import uniqBy from "lodash/uniqBy"; -import prisma, { isDuplicateError } from "@/utils/prisma"; +import prisma from "@/utils/prisma"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { type AddGroupItemBody, addGroupItemBody, - type CreateGroupBody, - createGroupBody, - type UpdateGroupPromptBody, - updateGroupPromptBody, } from "@/utils/actions/validation"; -import { findNewsletters } from "@/utils/ai/group/find-newsletters"; -import { findReceipts } from "@/utils/ai/group/find-receipts"; -import { getGmailClient, getGmailAccessToken } from "@/utils/gmail/client"; -import { GroupItemType, type Prisma, type User } from "@prisma/client"; -import { - NEWSLETTER_GROUP_ID, - RECEIPT_GROUP_ID, -} from "@/app/(app)/automation/create/examples"; -import { GroupName } from "@/utils/config"; -import { aiGenerateGroupItems } from "@/utils/ai/group/create-group"; -import type { UserAIFields } from "@/utils/llms/types"; import { withActionInstrumentation } from "@/utils/actions/middleware"; -import { createScopedLogger } from "@/utils/logger"; import { addGroupItem, deleteGroupItem } from "@/utils/group/group-item"; -import { createGroup } from "@/utils/group/group"; - -const logger = createScopedLogger("Group Action"); - -export const createGroupAction = withActionInstrumentation( - "createGroup", - async (body: CreateGroupBody) => { - const { name, prompt } = createGroupBody.parse(body); - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; - - const group = await createGroup({ - name, - prompt, - userId: session.user.id, - }); - - if ("error" in group) return { error: group.error }; - - if (prompt) { - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { - email: true, - aiModel: true, - aiProvider: true, - aiApiKey: true, - }, - }); - if (!user) return { error: "User not found" }; - if (!token.token) return { error: "No access token" }; - - await generateGroupItemsFromPrompt( - group.id, - user, - gmail, - token.token, - name, - prompt, - ); - } - - revalidatePath("/automation"); - - return { id: group.id }; - }, -); - -async function generateGroupItemsFromPrompt( - groupId: string, - user: Pick & UserAIFields, - gmail: gmail_v1.Gmail, - token: string, - name: string, - prompt: string, -) { - logger.info("generateGroupItemsFromPrompt", { name, prompt }); - - const result = await aiGenerateGroupItems(user, gmail, token, { - name, - prompt, - }); - - logger.info("generateGroupItemsFromPrompt result", { - name, - senders: result.senders.length, - subjects: result.subjects.length, - }); - - await prisma.$transaction([ - ...result.senders - .filter((sender) => !sender.includes(user.email!)) - .map((sender) => - prisma.groupItem.upsert({ - where: { - groupId_type_value: { - groupId, - type: GroupItemType.FROM, - value: sender, - }, - }, - update: {}, // No update needed if it exists - create: { - type: GroupItemType.FROM, - value: sender, - groupId, - }, - }), - ), - ...result.subjects.map((subject) => - prisma.groupItem.upsert({ - where: { - groupId_type_value: { - groupId, - type: GroupItemType.SUBJECT, - value: subject, - }, - }, - update: {}, // No update needed if it exists - create: { - type: GroupItemType.SUBJECT, - value: subject, - groupId, - }, - }), - ), - ]); -} - -export const createPredefinedGroupAction = withActionInstrumentation( - "createPredefinedGroup", - async (groupId: string) => { - if (groupId === NEWSLETTER_GROUP_ID) { - return await createNewsletterGroupAction(); - } - if (groupId === RECEIPT_GROUP_ID) { - return await createReceiptGroupAction(); - } - - return { error: "Unknown group type" }; - }, -); - -export const createNewsletterGroupAction = withActionInstrumentation( - "createNewsletterGroup", - async () => { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; - - const name = GroupName.NEWSLETTER; - const existingGroup = await prisma.group.findFirst({ - where: { name, userId: session.user.id }, - select: { id: true }, - }); - if (existingGroup) return { id: existingGroup.id }; - - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - - if (!token.token) return { error: "No access token" }; - - const newsletters = await findNewsletters( - gmail, - token.token, - session.user.email, - ); - - const group = await createGroup({ - name, - userId: session.user.id, - items: newsletters.map((newsletter) => ({ - type: GroupItemType.FROM, - value: newsletter, - })), - }); - - revalidatePath("/automation"); - - if ("error" in group) return { error: group.error }; - - return { id: group.id }; - }, -); - -export const createReceiptGroupAction = withActionInstrumentation( - "createReceiptGroup", - async () => { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; - - const name = GroupName.RECEIPT; - const existingGroup = await prisma.group.findFirst({ - where: { name, userId: session.user.id }, - select: { id: true }, - }); - if (existingGroup) return { id: existingGroup.id }; - - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - - if (!token.token) return { error: "No access token" }; - - const receipts = await findReceipts(gmail, token.token, session.user.email); - - const group = await createGroup({ - name, - userId: session.user.id, - items: receipts, - }); - - revalidatePath("/automation"); - - if ("error" in group) return { error: group.error }; - return { id: group.id }; - }, -); - -type ExistingGroup = Prisma.GroupGetPayload<{ - select: { - id: true; - name: true; - prompt: true; - items: { select: { id: true; type: true; value: true } }; - }; -}>; - -export const regenerateGroupAction = withActionInstrumentation( - "regenerateGroup", - async (groupId: string) => { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; - - const existingGroup = await prisma.group.findUnique({ - where: { id: groupId, userId: session.user.id }, - select: { - id: true, - name: true, - prompt: true, - items: { select: { id: true, type: true, value: true } }, - }, - }); - if (!existingGroup) return { error: "Group not found" }; - - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - - if (!token.token) return { error: "No access token" }; - - if (existingGroup.name === GroupName.NEWSLETTER) { - await regenerateNewsletterGroup( - existingGroup, - gmail, - token.token, - session.user.email, - ); - } else if (existingGroup.name === GroupName.RECEIPT) { - await regenerateReceiptGroup( - existingGroup, - gmail, - token.token, - session.user.email, - ); - } else if (existingGroup.prompt) { - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { - email: true, - aiModel: true, - aiProvider: true, - aiApiKey: true, - }, - }); - if (!user) return { error: "User not found" }; - - await generateGroupItemsFromPrompt( - groupId, - user, - gmail, - token.token, - existingGroup.name, - existingGroup.prompt, - ); - } else { - return { error: "Invalid group type or missing prompt" }; - } - - revalidatePath("/automation"); - }, -); - -async function regenerateNewsletterGroup( - existingGroup: ExistingGroup, - gmail: gmail_v1.Gmail, - token: string, - userEmail: string, -) { - const newsletters = await findNewsletters(gmail, token, userEmail); - - const items = newsletters.map((item) => ({ - type: GroupItemType.FROM, - value: item, - groupId: existingGroup.id, - })); - const newItems = filterOutExisting(items, existingGroup.items); - - await createGroupItems(newItems); - - revalidatePath("/automation"); -} - -async function regenerateReceiptGroup( - existingGroup: ExistingGroup, - gmail: gmail_v1.Gmail, - token: string, - userEmail: string, -) { - const receipts = await findReceipts(gmail, token, userEmail); - const newItems = filterOutExisting(receipts, existingGroup.items); - - await createGroupItems( - newItems.map((item) => ({ - ...item, - groupId: existingGroup.id, - })), - ); - - revalidatePath("/automation"); -} - -async function createGroupItems( - data: { groupId: string; type: GroupItemType; value: string }[], -) { - const uniqueItems = uniqBy(data, (item) => `${item.value}-${item.type}`); - try { - return await prisma.groupItem.createMany({ data: uniqueItems }); - } catch (error) { - if (isDuplicateError(error)) { - // Create items one by one, skipping duplicates - for (const item of uniqueItems) { - try { - await prisma.groupItem.create({ data: item }); - } catch (error) { - if (!isDuplicateError(error)) throw error; - } - } - } - - throw error; - } -} - -function filterOutExisting( - newItems: T[], - existingItems: { type: GroupItemType; value: string }[], -) { - const filtered = newItems.filter( - (newItem) => - !existingItems.find( - (item) => item.value === newItem.value && item.type === newItem.type, - ), - ); - - return uniqBy(filtered, (item) => `${item.value}-${item.type}`); -} - -export const deleteGroupAction = withActionInstrumentation( - "deleteGroup", - async (id: string) => { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; - - await prisma.group.delete({ where: { id, userId: session.user.id } }); - - revalidatePath("/automation"); - }, -); export const addGroupItemAction = withActionInstrumentation( "addGroupItem", @@ -420,20 +43,3 @@ export const deleteGroupItemAction = withActionInstrumentation( revalidatePath("/automation"); }, ); - -export const updateGroupPromptAction = withActionInstrumentation( - "updateGroupPrompt", - async (unsafeData: UpdateGroupPromptBody) => { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; - - const { success, error, data } = - updateGroupPromptBody.safeParse(unsafeData); - if (!success) return { error: error.message }; - - await prisma.group.update({ - where: { id: data.groupId, userId: session.user.id }, - data: { prompt: data.prompt }, - }); - }, -); diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index ff5edd709c..e52ebaca92 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -19,7 +19,7 @@ import { getGmailAccessToken, getGmailClient } from "@/utils/gmail/client"; import { aiFindExampleMatches } from "@/utils/ai/example-matches/find-example-matches"; import { withActionInstrumentation } from "@/utils/actions/middleware"; import { flattenConditions } from "@/utils/condition"; -import { LogicalOperator } from "@prisma/client"; +import { LogicalOperator, RuleType } from "@prisma/client"; import { updatePromptFileOnRuleUpdated, updateRuleInstructionsAndPromptFile, @@ -27,6 +27,7 @@ import { } from "@/utils/rule/prompt-file"; import { generatePromptOnDeleteRule } from "@/utils/ai/rule/generate-prompt-on-delete-rule"; import { sanitizeActionFields } from "@/utils/action-item"; +import { deleteRule } from "@/utils/rule/rule"; export const createRuleAction = withActionInstrumentation( "createRule", @@ -73,7 +74,6 @@ export const createRuleAction = withActionInstrumentation( to: conditions.to || null, subject: conditions.subject || null, // body: conditions.body || null, - groupId: conditions.groupId || null, categoryFilterType: conditions.categoryFilterType || null, categoryFilters: conditions.categoryFilterType && conditions.categoryFilters @@ -146,7 +146,6 @@ export const updateRuleAction = withActionInstrumentation( to: conditions.to || null, subject: conditions.subject || null, // body: conditions.body || null, - groupId: conditions.groupId || null, categoryFilterType: conditions.categoryFilterType || null, categoryFilters: conditions.categoryFilterType && conditions.categoryFilters @@ -272,8 +271,10 @@ export const deleteRuleAction = withActionInstrumentation( return { error: "You don't have permission to delete this rule" }; try { - await prisma.rule.delete({ - where: { id: ruleId, userId: session.user.id }, + await deleteRule({ + ruleId, + userId: session.user.id, + groupId: rule.groupId, }); const user = await prisma.user.findUnique({ diff --git a/apps/web/utils/actions/validation.ts b/apps/web/utils/actions/validation.ts index faf9560f43..5e57c9b025 100644 --- a/apps/web/utils/actions/validation.ts +++ b/apps/web/utils/actions/validation.ts @@ -7,12 +7,6 @@ import { import { ActionType, RuleType } from "@prisma/client"; // groups -export const createGroupBody = z.object({ - name: z.string(), - prompt: z.string().optional(), -}); -export type CreateGroupBody = z.infer; - export const addGroupItemBody = z.object({ groupId: z.string(), type: z.enum([GroupItemType.FROM, GroupItemType.SUBJECT]), @@ -20,12 +14,6 @@ export const addGroupItemBody = z.object({ }); export type AddGroupItemBody = z.infer; -export const updateGroupPromptBody = z.object({ - groupId: z.string(), - prompt: z.string().nullable(), -}); -export type UpdateGroupPromptBody = z.infer; - // rules export const zodActionType = z.enum([ ActionType.ARCHIVE, @@ -84,7 +72,6 @@ const zodAction = z export const zodRuleType = z.enum([ RuleType.AI, RuleType.STATIC, - RuleType.GROUP, RuleType.CATEGORY, ]); @@ -92,10 +79,6 @@ const zodAiCondition = z.object({ instructions: z.string().nullish(), }); -const zodGroupCondition = z.object({ - groupId: z.string().nullish(), -}); - const zodStaticCondition = z.object({ to: z.string().nullish(), from: z.string().nullish(), @@ -113,7 +96,6 @@ const zodCategoryCondition = z.object({ const zodCondition = z.object({ type: zodRuleType, ...zodAiCondition.shape, - ...zodGroupCondition.shape, ...zodStaticCondition.shape, ...zodCategoryCondition.shape, }); @@ -121,8 +103,9 @@ export type ZodCondition = z.infer; export const createRuleBody = z.object({ id: z.string().optional(), - name: z.string(), + name: z.string().min(1, "Please enter a name"), instructions: z.string().nullish(), + groupId: z.string().nullish(), automate: z.boolean().nullish(), runOnThreads: z.boolean().nullish(), actions: z.array(zodAction).min(1, "You must have at least one action"), diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 89ad4b4538..92abc4a28d 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -17,7 +17,7 @@ import { type CreateRuleSchemaWithCategories, getCreateRuleSchemaWithCategories, } from "@/utils/ai/rule/create-rule-schema"; -import { addGroupItem, deleteGroupItem } from "@/utils/group/group-item"; +import { deleteGroupItem } from "@/utils/group/group-item"; import { addRuleCategories, partialUpdateRule, @@ -66,32 +66,36 @@ You can fix rules using these specific operations: - Update static conditions (from, to, subject, body) - Add or remove categories -2. Manage Groups: -- Add items to groups (email addresses or subject patterns) -- Remove items from groups -- Never mix static conditions with group conditions - -3. Create New Rules: +2. Create New Rules: - Create new rules when existing ones cannot be modified to fit the need +${ + matchedRule?.group?.items?.length + ? `3. Manage Learned Patterns: +- These are patterns that have been learned from the user's email history to always be matched (and they ignore the conditionalOperator setting) +- Patterns are email addresses or subjects +- You can remove patterns` + : "" +} + When fixing rules: - Make one precise change at a time - Prefer minimal changes that solve the problem -- Only add AI instructions if simpler conditions won't suffice - Keep rules general and maintainable Rule matching logic: -- All static conditions (from, to, subject, body) use AND logic - meaning all conditions must match -- Top level conditions (static, group, category, AI instructions) can use either AND or OR logic, controlled by the conditionalOperator setting +- All static conditions (from, to, subject, body) use AND logic - meaning all static conditions must match +- Top level conditions (AI instructions, static, category) can use either AND or OR logic, controlled by the conditionalOperator setting Best practices: - For static conditions, use email patterns (e.g., '@company.com') when matching multiple addresses -- For groups, collect similar items that are likely to appear together (e.g., newsletter senders, receipt subject patterns) -- Only use subject patterns if they are likely to be recurring (e.g., "Your Monthly Statement", "Order Confirmation") -- IMPORTANT: do not create new rules unless absolutely necessary and the user has asked for it. +- IMPORTANT: do not create new rules unless absolutely necessary. Avoid duplicate rules, so make sure to check if the rule already exists. +- You can use multiple conditions in a rule, but aim for simplicity. +- When creating rules, in most cases, you should use the "aiInstructions" and sometimes you will use other fields in addition. +- If a rule can be handled fully with static conditions, do so, but this is rarely possible. Always end by using the reply tool to explain what changes were made. -Include the updated rule in your reply so that the user understands what was changed. +Include the updated rule in simple English in your reply so that the user understands what was changed. Use simple language and avoid jargon in your reply.`; const prompt = `${ @@ -180,29 +184,21 @@ ${senderCategory || "No category"} tools: { create_rule: tool({ description: "Create a new rule", - parameters: (categories + parameters: categories ? getCreateRuleSchemaWithCategories( categories.map((c) => c.name) as [string, ...string[]], ) - : createRuleSchema - ) - // Simplify rule creation to not include groups - .extend({ - condition: createRuleSchema.shape.condition.omit({ group: true }), - }), + : createRuleSchema, execute: async ({ name, condition, actions }) => { logger.info("Create Rule", { name, condition, actions }); const conditions = condition as CreateRuleSchemaWithCategories["condition"]; - const groupId = null; - try { const rule = await safeCreateRule( { name, condition, actions }, user.id, - groupId, conditions.categories?.categoryFilters || [], ); @@ -287,91 +283,92 @@ ${senderCategory || "No category"} }); }, }), - add_to_group: tool({ - description: "Add a group item", - parameters: z.object({ - groupName: z - .string() - .describe("The name of the group to add the group item to"), - type: z - .enum(["from", "subject"]) - .describe("The type of the group item to add"), - value: z - .string() - .describe( - "The value of the group item to add. e.g. '@company.com', 'matt@company.com', 'Receipt from'", - ), - }), - execute: async ({ groupName, type, value }) => { - logger.info("Add To Group", { groupName, type, value }); - - const group = rules.find((r) => r.group?.name === groupName)?.group; - const groupId = group?.id; - - if (!groupId) { - logger.error("Group not found", { - ...loggerOptions, - groupName, - }); - return { error: "Group not found" }; - } - - const groupItemType = getGroupItemType(type); - - if (!groupItemType) { - logger.error("Invalid group item type", { - ...loggerOptions, - type, - }); - return { error: "Invalid group item type" }; - } - - try { - await addGroupItem({ groupId, type: groupItemType, value }); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - - logger.error("Error while adding group item", { - ...loggerOptions, - groupId, - type: groupItemType, - value, - error: message, - }); - return { - error: "Failed to add group item", - message, - }; - } - - return { success: true }; - }, - }), + // We may bring this back as "learned patterns" + // add_pattern: tool({ + // description: "Add a pattern", + // parameters: z.object({ + // ruleName: z + // .string() + // .describe("The name of the rule to add the pattern to"), + // type: z + // .enum(["from", "subject"]) + // .describe("The type of the pattern to add"), + // value: z + // .string() + // .describe( + // "The value of the pattern to add. e.g. '@company.com', 'matt@company.com', 'Receipt from'", + // ), + // }), + // execute: async ({ ruleName, type, value }) => { + // logger.info("Add To Learned Patterns", { ruleName, type, value }); + + // const group = rules.find((r) => r.group?.name === groupName)?.group; + // const groupId = group?.id; + + // if (!groupId) { + // logger.error("Group not found", { + // ...loggerOptions, + // groupName, + // }); + // return { error: "Group not found" }; + // } + + // const groupItemType = getPatternType(type); + + // if (!groupItemType) { + // logger.error("Invalid pattern type", { + // ...loggerOptions, + // type, + // }); + // return { error: "Invalid pattern type" }; + // } + + // try { + // await addGroupItem({ groupId, type: groupItemType, value }); + // } catch (error) { + // const message = + // error instanceof Error ? error.message : String(error); + + // logger.error("Error while adding pattern", { + // ...loggerOptions, + // groupId, + // type: groupItemType, + // value, + // error: message, + // }); + // return { + // error: "Failed to add pattern", + // message, + // }; + // } + + // return { success: true }; + // }, + // }), ...(matchedRule?.group ? { - remove_from_group: tool({ - description: "Remove a group item ", + remove_pattern: tool({ + description: "Remove a pattern", parameters: z.object({ type: z .enum(["from", "subject"]) - .describe("The type of the group item to remove"), + .describe("The type of the pattern to remove"), value: z .string() - .describe("The value of the group item to remove"), + .describe("The value of the pattern to remove"), }), execute: async ({ type, value }) => { - logger.info("Remove From Group", { type, value }); + logger.info("Remove Pattern", { type, value }); - const groupItemType = getGroupItemType(type); + const groupItemType = getPatternType(type); if (!groupItemType) { - logger.error("Invalid group item type", { + logger.error("Invalid pattern type", { ...loggerOptions, type, value, }); - return { error: "Invalid group item type" }; + return { error: "Invalid pattern type" }; } const groupItem = matchedRule?.group?.items?.find( @@ -379,24 +376,21 @@ ${senderCategory || "No category"} ); if (!groupItem) { - logger.error("Group item not found", { + logger.error("Pattern not found", { ...loggerOptions, type, value, }); - return { error: "Group item not found" }; + return { error: "Pattern not found" }; } try { - await deleteGroupItem({ - id: groupItem.id, - userId: user.id, - }); + await deleteGroupItem({ id: groupItem.id, userId: user.id }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - logger.error("Error while deleting group item", { + logger.error("Error while deleting pattern", { ...loggerOptions, groupItemId: groupItem.id, type: groupItemType, @@ -405,7 +399,7 @@ ${senderCategory || "No category"} }); return { - error: "Failed to delete group item", + error: "Failed to delete pattern", message, }; } @@ -649,28 +643,6 @@ function ruleToXML(rule: RuleWithRelations) { ` : "" } - ${ - rule.group - ? ` - ${rule.group.name} - - ${ - rule.group.items - ? rule.group.items - .map( - (item) => - ` - ${item.type} - ${item.value} -`, - ) - .join("\n ") - : "No group items" - } - - ` - : "" - } ${ hasCategoryConditions(rule) ? ` @@ -680,6 +652,22 @@ function ruleToXML(rule: RuleWithRelations) { : "" } + + ${ + rule.group?.items?.length + ? ` + ${rule.group.items + .map( + (item) => + ` +${item.type} +${item.value} +`, + ) + .join("\n ")} + ` + : "" + } `; } @@ -697,7 +685,7 @@ function hasCategoryConditions(rule: RuleWithRelations) { return Boolean(rule.categoryFilters && rule.categoryFilters.length > 0); } -function getGroupItemType(type: string) { +function getPatternType(type: string) { if (type === "from") return GroupItemType.FROM; if (type === "subject") return GroupItemType.SUBJECT; } diff --git a/apps/web/utils/ai/choose-rule/match-rules.test.ts b/apps/web/utils/ai/choose-rule/match-rules.test.ts index 7efd4c91b5..859b466d8f 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts @@ -169,9 +169,7 @@ describe("findMatchingRule", () => { const result = await findMatchingRule(rules, message, user); expect(result.rule?.id).toBe(rule.id); - expect(result.reason).toBe( - `Matched group item: "FROM: test@example.com", Matched category: "category"`, - ); + expect(result.reason).toBe(`Matched group item: "FROM: test@example.com"`); }); it("matches a rule with multiple conditions AND (category and AI)", async () => { @@ -236,7 +234,7 @@ describe("findMatchingRule", () => { expect(aiChooseRule).toHaveBeenCalledOnce(); }); - it("doesn't match with only one of category or group", async () => { + it("should match with only one of category or group", async () => { prisma.newsletter.findUnique.mockResolvedValue( getNewsletter({ categoryId: "category1" }), ); @@ -253,8 +251,8 @@ describe("findMatchingRule", () => { const result = await findMatchingRule(rules, message, user); - expect(result.rule?.id).toBeUndefined(); - expect(result.reason).toBeUndefined(); + expect(result.rule?.id).toBe(rule.id); + expect(result.reason).toBe('Matched category: "category"'); }); it("matches with OR and one of category or group", async () => { diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 22ea045c51..a60fc699fb 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -74,42 +74,40 @@ async function findPotentialMatchingRules({ if (isThread && !runOnThreads) continue; const conditionTypes = getConditionTypes(rule); - const unmatchedConditions = new Set( - Object.keys(conditionTypes) as RuleType[], - ); const matchReasons: MatchReason[] = []; - // static - if (conditionTypes.STATIC) { - const match = matchesStaticRule(rule, message); - if (match) { - unmatchedConditions.delete(RuleType.STATIC); - matchReasons.push({ type: RuleType.STATIC }); - if (operator === LogicalOperator.OR || !unmatchedConditions.size) - return { match: rule, matchReasons }; - } else { - // no match, so can't be a match with AND - if (operator === LogicalOperator.AND) continue; - } - } - - // group - if (conditionTypes.GROUP) { + // group - ignores conditional operator + // if a match is found, return it + if (rule.groupId) { const { matchingItem, group } = await matchesGroupRule( rule, await getGroups(rule.userId), message, ); if (matchingItem) { - unmatchedConditions.delete(RuleType.GROUP); matchReasons.push({ type: RuleType.GROUP, groupItem: matchingItem, group, }); - if (operator === LogicalOperator.OR || !unmatchedConditions.size) { + + return { match: rule, matchReasons }; + } + } + + // Regular conditions: + const unmatchedConditions = new Set( + Object.keys(conditionTypes) as RuleType[], + ); + + // static + if (conditionTypes.STATIC) { + const match = matchesStaticRule(rule, message); + if (match) { + unmatchedConditions.delete(RuleType.STATIC); + matchReasons.push({ type: RuleType.STATIC }); + if (operator === LogicalOperator.OR || !unmatchedConditions.size) return { match: rule, matchReasons }; - } } else { // no match, so can't be a match with AND if (operator === LogicalOperator.AND) continue; diff --git a/apps/web/utils/ai/group/create-group.ts b/apps/web/utils/ai/group/create-group.ts index f151d48bd1..37aa74b251 100644 --- a/apps/web/utils/ai/group/create-group.ts +++ b/apps/web/utils/ai/group/create-group.ts @@ -6,6 +6,8 @@ import { queryBatchMessages } from "@/utils/gmail/message"; import type { UserAIFields } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; +// no longer in use. delete? + const logger = createScopedLogger("aiCreateGroup"); const GENERATE_GROUP_ITEMS = "generateGroupItems"; diff --git a/apps/web/utils/ai/rule/create-prompt-from-rule.test.ts b/apps/web/utils/ai/rule/create-prompt-from-rule.test.ts index 5c7824372b..764197b73c 100644 --- a/apps/web/utils/ai/rule/create-prompt-from-rule.test.ts +++ b/apps/web/utils/ai/rule/create-prompt-from-rule.test.ts @@ -181,7 +181,7 @@ Alice`, } as Rule & { actions: Action[]; group: Group }; expect(createPromptFromRule(rule)).toBe( - 'For emails in group "Receipts", label as "Receipt" and archive', + 'For all emails, label as "Receipt" and archive', ); }); @@ -193,7 +193,7 @@ Alice`, } as Rule & { actions: Action[]; group: Group }; expect(createPromptFromRule(rule)).toBe( - 'For emails with subject containing "order" and in group "Receipts", archive', + 'For emails with subject containing "order", archive', ); }); @@ -206,7 +206,7 @@ Alice`, } as Rule & { actions: Action[]; group: Group }; expect(createPromptFromRule(rule)).toBe( - 'For emails with subject containing "order" or in group "Receipts", archive', + 'For emails with subject containing "order", archive', ); }); }); diff --git a/apps/web/utils/ai/rule/create-prompt-from-rule.ts b/apps/web/utils/ai/rule/create-prompt-from-rule.ts index 6433a717c2..39e0c17493 100644 --- a/apps/web/utils/ai/rule/create-prompt-from-rule.ts +++ b/apps/web/utils/ai/rule/create-prompt-from-rule.ts @@ -33,11 +33,6 @@ export function createPromptFromRule(rule: RuleWithRelations): string { conditions.push(`with subject containing "${rule.subject}"`); if (rule.body) conditions.push(`with body containing "${rule.body}"`); - // Add group if present - if (rule.group?.name) { - conditions.push(`in group "${rule.group.name}"`); - } - // Add category filters if present if (rule.categoryFilters?.length) { const categories = rule.categoryFilters.map((c) => c.name).join(", "); diff --git a/apps/web/utils/ai/rule/create-rule-schema.ts b/apps/web/utils/ai/rule/create-rule-schema.ts index 7e7e69e09f..a836a0b4e0 100644 --- a/apps/web/utils/ai/rule/create-rule-schema.ts +++ b/apps/web/utils/ai/rule/create-rule-schema.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { GroupName } from "@/utils/config"; import { ActionType, CategoryFilterType, @@ -30,12 +29,6 @@ const conditionSchema = z .describe( "The static conditions to match. If multiple static conditions are specified, the rule will match if ALL of the conditions match (AND operation)", ), - group: z - .enum([GroupName.RECEIPT, GroupName.NEWSLETTER]) - .optional() - .describe( - "The group to match. Only 'Receipt' and 'Newsletter' are supported.", - ), }) .describe("The conditions to match"); diff --git a/apps/web/utils/ai/rule/prompt-to-rules.ts b/apps/web/utils/ai/rule/prompt-to-rules.ts index 0b400afba5..d10d0970c9 100644 --- a/apps/web/utils/ai/rule/prompt-to-rules.ts +++ b/apps/web/utils/ai/rule/prompt-to-rules.ts @@ -63,7 +63,7 @@ ${promptFile} `; if (env.NODE_ENV === "development") { - logger.trace("prompt-to-rules", { + logger.trace("Input", { system, prompt, parameters: zodToJsonSchema(parameters), @@ -88,7 +88,7 @@ ${promptFile} rules: CreateOrUpdateRuleSchemaWithCategories[]; }; - logger.trace("Result", { rules }); + logger.trace("Output", { rules }); return rules.map((rule) => ({ ...rule, @@ -115,7 +115,8 @@ function getSystemPrompt({ IMPORTANT: If a user provides a snippet, use that full snippet in the rule. Don't include placeholders unless it's clear one is needed. You can use multiple conditions in a rule, but aim for simplicity. -If a rule can be handed without ai instructions, that's preferred, but often this won't be possible, so you can use the "aiInstructions" field. +In most cases, you should use the "aiInstructions" and sometimes you will use other fields in addition. +If a rule can be handled fully with static conditions, do so, but this is rarely possible. @@ -127,7 +128,8 @@ If a rule can be handed without ai instructions, that's preferred, but often thi "rules": [{ "name": "Label Newsletters", "condition": { - "group": "Newsletters"${ + "aiInstructions": "Apply this rule to newsletters" + ${ hasSmartCategories ? `, "categories": { @@ -186,7 +188,7 @@ If a rule can be handed without ai instructions, that's preferred, but often thi - Label all urgent emails from matt@company.com as "Urgent" + Label all urgent emails from company.com as "Urgent" { @@ -196,7 +198,7 @@ If a rule can be handed without ai instructions, that's preferred, but often thi "conditionalOperator": "AND", "aiInstructions": "Apply this rule to urgent emails", "static": { - "from": "matt@company.com" + "from": "@company.com" } }, "actions": [ diff --git a/apps/web/utils/condition.ts b/apps/web/utils/condition.ts index af4c7c9c6e..de0aec1d86 100644 --- a/apps/web/utils/condition.ts +++ b/apps/web/utils/condition.ts @@ -54,13 +54,6 @@ export function getConditions(rule: RuleConditions) { }); } - if (isGroupRule(rule)) { - conditions.push({ - type: RuleType.GROUP, - groupId: rule.groupId, - }); - } - if (isStaticRule(rule)) { conditions.push({ type: RuleType.STATIC, @@ -95,8 +88,7 @@ export function getConditionTypes( } export function getEmptyCondition( - type: RuleType, - groupId?: string, + type: Exclude, category?: string, ): ZodCondition { switch (type) { @@ -105,11 +97,6 @@ export function getEmptyCondition( type: RuleType.AI, instructions: "", }; - case RuleType.GROUP: - return { - type: RuleType.GROUP, - groupId: groupId || "", - }; case RuleType.STATIC: return { type: RuleType.STATIC, @@ -133,7 +120,6 @@ export function getEmptyCondition( type FlattenedConditions = { instructions?: string | null; - groupId?: string | null; from?: string | null; to?: string | null; subject?: string | null; @@ -150,9 +136,6 @@ export const flattenConditions = ( case RuleType.AI: acc.instructions = condition.instructions; break; - case RuleType.GROUP: - acc.groupId = condition.groupId; - break; case RuleType.STATIC: acc.to = condition.to; acc.from = condition.from; @@ -201,43 +184,43 @@ export function ruleTypeToString(ruleType: RuleType): string { } export function conditionsToString(rule: RuleConditions) { - let result = ""; - + const conditions: string[] = []; const connector = rule.conditionalOperator === LogicalOperator.AND ? " AND " : " OR "; - if (rule.groupId) { - result += `Group: ${rule.group?.name || "MISSING"}`; - } + // Static conditions - grouped with commas + const staticConditions: string[] = []; + if (rule.from) staticConditions.push(`From: "${rule.from}"`); + if (rule.subject) staticConditions.push(`Subject: "${rule.subject}"`); + if (rule.to) staticConditions.push(`To: "${rule.to}"`); + if (rule.body) staticConditions.push(`Body: "${rule.body}"`); + if (staticConditions.length) conditions.push(staticConditions.join(", ")); - if (rule.from || rule.to || rule.subject || rule.body) { - const from = rule.from ? `From: "${rule.from}"` : ""; - if (from && result) result += connector; - result += from; - - const subject = rule.subject ? `Subject: "${rule.subject}"` : ""; - if (subject && result) result += connector; - result += subject; - } - - if (rule.instructions) { - if (result) result += connector; - result += `AI: ${rule.instructions}`; - } + // AI condition + if (rule.instructions) conditions.push(`AI: ${rule.instructions}`); + // Category condition const categoryFilters = rule.categoryFilters; if (rule.categoryFilterType && categoryFilters?.length) { - if (result) result += connector; const max = 3; const categories = categoryFilters .slice(0, max) .map((category) => category.name) .join(", ") + (categoryFilters.length > max ? ", ..." : ""); - result += `${rule.categoryFilterType === CategoryFilterType.EXCLUDE ? "Exclude " : ""}${categoryFilters.length === 1 ? "Category" : "Categories"}: ${categories}`; + conditions.push( + `${rule.categoryFilterType === CategoryFilterType.EXCLUDE ? "Exclude " : ""}${ + categoryFilters.length === 1 ? "Category" : "Categories" + }: ${categories}`, + ); + } + + // Group condition + if (rule.groupId) { + conditions.push("Group"); } - return result; + return conditions.join(connector); } export function categoryFilterTypeToString( diff --git a/apps/web/utils/config.ts b/apps/web/utils/config.ts index 75b1bb3115..03b73cb583 100644 --- a/apps/web/utils/config.ts +++ b/apps/web/utils/config.ts @@ -2,9 +2,4 @@ export const AI_GENERATED_FIELD_VALUE = "___AI_GENERATE___"; export const appHomePath = "/automation"; -export const GroupName = { - NEWSLETTER: "Newsletters", - RECEIPT: "Receipts", -}; - export const userCount = "10,000+"; diff --git a/apps/web/utils/group/group.ts b/apps/web/utils/group/group.ts deleted file mode 100644 index 87e6694cce..0000000000 --- a/apps/web/utils/group/group.ts +++ /dev/null @@ -1,32 +0,0 @@ -import prisma, { isDuplicateError } from "@/utils/prisma"; -import type { Prisma } from "@prisma/client"; - -export async function createGroup({ - userId, - name, - prompt, - items, -}: { - userId: string; - name: string; - prompt?: string; - items?: Prisma.GroupItemCreateManyInput[]; -}) { - try { - const group = await prisma.group.create({ - data: { - name, - prompt, - userId, - items: { create: items }, - }, - }); - - return group; - } catch (error) { - if (isDuplicateError(error, "name")) - return { error: "Group with this name already exists" }; - - throw error; - } -} diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index bc797f5717..b2223133ee 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -22,7 +22,6 @@ export function partialUpdateRule(ruleId: string, data: Partial) { export async function safeCreateRule( result: CreateOrUpdateRuleSchemaWithCategories, userId: string, - groupId?: string | null, categoryNames?: string[] | null, ) { const categoryIds = await getUserCategoriesForNames( @@ -31,17 +30,20 @@ export async function safeCreateRule( ); try { - const rule = await createRule(result, userId, groupId, categoryNames); + const rule = await createRule({ + result, + userId, + categoryIds, + }); return rule; } catch (error) { if (isDuplicateError(error, "name")) { // if rule name already exists, create a new rule with a unique name - const rule = await createRule( - { ...result, name: `${result.name} - ${Date.now()}` }, + const rule = await createRule({ + result: { ...result, name: `${result.name} - ${Date.now()}` }, userId, - groupId, categoryIds, - ); + }); return rule; } @@ -61,21 +63,19 @@ export async function safeUpdateRule( ruleId: string, result: CreateOrUpdateRuleSchemaWithCategories, userId: string, - groupId?: string | null, categoryIds?: string[] | null, ) { try { - const rule = await updateRule(ruleId, result, userId, groupId, categoryIds); + const rule = await updateRule(ruleId, result, userId, categoryIds); return { id: rule.id }; } catch (error) { if (isDuplicateError(error, "name")) { // if rule name already exists, create a new rule with a unique name - const rule = await createRule( - { ...result, name: `${result.name} - ${Date.now()}` }, + const rule = await createRule({ + result: { ...result, name: `${result.name} - ${Date.now()}` }, userId, - groupId, categoryIds, - ); + }); return { id: rule.id }; } @@ -91,12 +91,15 @@ export async function safeUpdateRule( } } -async function createRule( - result: CreateOrUpdateRuleSchemaWithCategories, - userId: string, - groupId?: string | null, - categoryIds?: string[] | null, -) { +async function createRule({ + result, + userId, + categoryIds, +}: { + result: CreateOrUpdateRuleSchemaWithCategories; + userId: string; + categoryIds?: string[] | null; +}) { return prisma.rule.create({ data: { name: result.name, @@ -113,7 +116,6 @@ async function createRule( from: result.condition.static?.from, to: result.condition.static?.to, subject: result.condition.static?.subject, - groupId, categoryFilterType: result.condition.categories?.categoryFilterType, categoryFilters: categoryIds ? { @@ -131,7 +133,6 @@ async function updateRule( ruleId: string, result: CreateOrUpdateRuleSchemaWithCategories, userId: string, - groupId?: string | null, categoryIds?: string[] | null, ) { return prisma.rule.update({ @@ -152,7 +153,6 @@ async function updateRule( from: result.condition.static?.from, to: result.condition.static?.to, subject: result.condition.static?.subject, - groupId, categoryFilterType: result.condition.categories?.categoryFilterType, categoryFilters: categoryIds ? { @@ -165,6 +165,22 @@ async function updateRule( }); } +export async function deleteRule({ + userId, + ruleId, + groupId, +}: { + ruleId: string; + userId: string; + groupId?: string | null; +}) { + return Promise.all([ + prisma.rule.delete({ where: { id: ruleId, userId } }), + // in the future, we can make this a cascade delete, but we need to change the schema for this to happen + groupId ? prisma.group.delete({ where: { id: groupId, userId } }) : null, + ]); +} + function shouldAutomate(actions: Pick[]) { const types = new Set(actions.map((action) => action.type)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d502a78a1d..97fa133cd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: '@radix-ui/react-avatar': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.4 version: 1.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3390,6 +3393,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.2': + resolution: {integrity: sha512-PliMB63vxz7vggcyq0IxNYk8vGDrLXVWw4+W4B8YnwI1s18x7YZYqlG9PLX7XxAJUi0g2DxP4XKJMFHh/iVh9A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.1': resolution: {integrity: sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==} peerDependencies: @@ -15638,6 +15654,22 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-collapsible@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-collection@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1) diff --git a/turbo.json b/turbo.json index 22d95b074e..3f60a3c701 100644 --- a/turbo.json +++ b/turbo.json @@ -43,6 +43,9 @@ "ADMINS", "WEBHOOK_URL", "INTERNAL_API_KEY", + "USE_BACKUP_MODEL", + "WHITELIST_FROM", + "API_KEY_SALT", "NEXT_PUBLIC_LEMON_STORE_ID",