From b377effcb34a038b0caf1f5fd28ae2f14d708ded Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 28 Jan 2025 14:23:14 +0200 Subject: [PATCH 01/28] Inline group view. Comment out old group code --- apps/web/app/(app)/automation/RuleForm.tsx | 40 +- .../app/(app)/automation/group/ViewGroup.tsx | 156 +----- .../(app)/automation/group/[groupId]/page.tsx | 13 +- apps/web/utils/actions/group.ts | 473 +++++++++--------- apps/web/utils/ai/group/create-group.ts | 2 + 5 files changed, 262 insertions(+), 422 deletions(-) diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index ca9d8f0448..ba2b67a0d3 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -45,8 +45,7 @@ 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 { ViewGroup } from "@/app/(app)/automation/group/ViewGroup"; import { createPredefinedGroupAction } from "@/utils/actions/group"; import { NEWSLETTER_GROUP_ID, @@ -70,6 +69,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { ScrollArea } from "@/components/ui/scroll-area"; export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { const { @@ -251,8 +251,8 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { options={[ { label: "AI", value: RuleType.AI }, { label: "Static", value: RuleType.STATIC }, - { label: "Group", value: RuleType.GROUP }, { label: "Smart Category", value: RuleType.CATEGORY }, + { label: "Group", value: RuleType.GROUP }, ]} error={ errors.conditions?.[index]?.type as FieldError | undefined @@ -755,7 +755,9 @@ function GroupsTab(props: { return (
- A group is a static collection of senders or subjects. + Advanced: Groups are usually managed by our AI. A group is a static + collection of senders and subjects. Our AI collects these for you to + more efficiently match future emails. {loadingCreateGroup && ( @@ -766,35 +768,11 @@ function GroupsTab(props: {
- {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/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/utils/actions/group.ts b/apps/web/utils/actions/group.ts index b805c5f39b..27ea77a171 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -6,8 +6,6 @@ import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { type AddGroupItemBody, addGroupItemBody, - type CreateGroupBody, - createGroupBody, type UpdateGroupPromptBody, updateGroupPromptBody, } from "@/utils/actions/validation"; @@ -24,116 +22,6 @@ import { withActionInstrumentation } from "@/utils/actions/middleware"; import { addGroupItem, deleteGroupItem } from "@/utils/group/group-item"; import { createGroup } from "@/utils/group/group"; -// commented out code is no longer in use. delete? - -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) => { @@ -222,166 +110,6 @@ export const createReceiptGroupAction = withActionInstrumentation( }, ); -// 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", async (unsafeData: AddGroupItemBody) => { diff --git a/apps/web/utils/actions/validation.ts b/apps/web/utils/actions/validation.ts index faf9560f43..7f411b474c 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]), From 7927da621fcf8327f59e2bd909a22c69d3f89e87 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 28 Jan 2025 15:55:59 +0200 Subject: [PATCH 03/28] Add group items. Delete more old group code --- apps/web/app/(app)/automation/RuleForm.tsx | 61 +++--- .../app/(app)/automation/group/ViewGroup.tsx | 188 ++++-------------- apps/web/utils/actions/group.ts | 17 -- apps/web/utils/actions/rule.ts | 15 +- apps/web/utils/actions/validation.ts | 2 +- .../utils/ai/rule/create-prompt-from-rule.ts | 8 +- apps/web/utils/group/group.ts | 6 +- 7 files changed, 87 insertions(+), 210 deletions(-) diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index 42520738de..6093f5a527 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -78,7 +78,7 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { watch, setValue, control, - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, isSubmitted }, trigger, } = useForm({ resolver: zodResolver(createRuleBody), @@ -188,8 +188,25 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { return actionErrors; }, [errors, watch]); + console.log(errors); + return (
+ {isSubmitted && Object.keys(errors).length > 0 && ( +
+ + {Object.values(errors).map((error) => ( +
  • {error.message}
  • + ))} + + } + /> +
    + )} +
    @@ -715,38 +731,9 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { function GroupsTab(props: { registerProps: UseFormRegisterReturn; - // setValue: UseFormSetValue; errors: FieldErrors; groupId?: string | null; }) { - // const { setValue } = props; - // 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 { - // setValue("conditions", [{ groupId: result.id, type: RuleType.GROUP }]); - // } - - // setLoadingCreateGroup(false); - // } - - // if ( - // props.groupId === NEWSLETTER_GROUP_ID || - // props.groupId === RECEIPT_GROUP_ID - // ) { - // createGroup(props.groupId); - // } - // }, [props.groupId, setValue]); - return (
    @@ -755,11 +742,15 @@ function GroupsTab(props: { more efficiently match future emails. -
    - {props.groupId && ( +
    + {props.groupId ? ( + ) : ( +
    + To add group items, create the rule first. +
    )}
    diff --git a/apps/web/app/(app)/automation/group/ViewGroup.tsx b/apps/web/app/(app)/automation/group/ViewGroup.tsx index 75acee1d4e..a70763b5e6 100644 --- a/apps/web/app/(app)/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/automation/group/ViewGroup.tsx @@ -1,7 +1,8 @@ "use client"; import useSWR, { type KeyedMutator } from "swr"; -import { PlusIcon, TrashIcon, PenIcon, ExternalLinkIcon } from "lucide-react"; +import Link from "next/link"; +import { PlusIcon, TrashIcon, ExternalLinkIcon } from "lucide-react"; import { useState, useCallback, @@ -13,15 +14,13 @@ 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 { Skeleton } from "@/components/ui/skeleton"; import { Table, TableRow, TableBody, TableCell } from "@/components/ui/table"; -import { MessageText, SectionDescription } from "@/components/Typography"; +import { MessageText } from "@/components/Typography"; import { addGroupItemAction, deleteGroupItemAction, - updateGroupPromptAction, } from "@/utils/actions/group"; import { type GroupItem, GroupItemType } from "@prisma/client"; import { Input } from "@/components/Input"; @@ -30,39 +29,11 @@ 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 }: { groupId: string }) { +export function ViewGroup({ groupId }: { groupId: string | null }) { const { data, isLoading, error, mutate } = useSWR( `/api/user/group/${groupId}/items`, ); @@ -125,14 +96,16 @@ export function ViewGroup({ groupId }: { groupId: string }) { return ( - {isRecent && ( - - New! - - )} - -
    - +
    + {isRecent && ( + + New! + + )} + +
    + +
    @@ -177,7 +150,7 @@ const AddGroupItemForm = ({ mutate, setShowAddItem, }: { - groupId: string; + groupId: string | null; mutate: KeyedMutator; setShowAddItem: Dispatch>; }) => { @@ -187,7 +160,7 @@ const AddGroupItemForm = ({ formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(addGroupItemBody), - defaultValues: { groupId }, + defaultValues: { groupId: groupId! }, }); const onClose = useCallback(() => { @@ -210,10 +183,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 ( - -
    - - -
    - - ); -} +}; export function GroupItemDisplay({ item, diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index 27ea77a171..0516bc34dd 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -143,20 +143,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..8c676ab5b8 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 { createGroup } from "@/utils/group/group"; export const createRuleAction = withActionInstrumentation( "createRule", @@ -85,6 +86,18 @@ export const createRuleAction = withActionInstrumentation( include: { actions: true, categoryFilters: true, group: true }, }); + const shouldCreateGroup = body.conditions.some( + (c) => c.type === RuleType.GROUP && !c.groupId, + ); + + if (shouldCreateGroup) { + await createGroup({ + name: body.name, + userId: session.user.id, + ruleId: rule.id, + }); + } + await updatePromptFileOnRuleCreated(session.user.id, rule); revalidatePath("/automation"); diff --git a/apps/web/utils/actions/validation.ts b/apps/web/utils/actions/validation.ts index 7f411b474c..f472320dc1 100644 --- a/apps/web/utils/actions/validation.ts +++ b/apps/web/utils/actions/validation.ts @@ -115,7 +115,7 @@ 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(), automate: z.boolean().nullish(), runOnThreads: z.boolean().nullish(), 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..a13351c59e 100644 --- a/apps/web/utils/ai/rule/create-prompt-from-rule.ts +++ b/apps/web/utils/ai/rule/create-prompt-from-rule.ts @@ -33,10 +33,10 @@ 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 group if present + // if (rule.group?.name) { + // conditions.push(`in group "${rule.group.name}"`); + // } // Add category filters if present if (rule.categoryFilters?.length) { diff --git a/apps/web/utils/group/group.ts b/apps/web/utils/group/group.ts index 87e6694cce..7cf4a91960 100644 --- a/apps/web/utils/group/group.ts +++ b/apps/web/utils/group/group.ts @@ -3,22 +3,22 @@ import type { Prisma } from "@prisma/client"; export async function createGroup({ userId, + ruleId, name, - prompt, items, }: { userId: string; + ruleId?: string; name: string; - prompt?: string; items?: Prisma.GroupItemCreateManyInput[]; }) { try { const group = await prisma.group.create({ data: { name, - prompt, userId, items: { create: items }, + rule: ruleId ? { connect: { id: ruleId } } : undefined, }, }); From 7d7e8ced4583ca781f570a11e112bbd9d0f08de6 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:16:35 +0200 Subject: [PATCH 04/28] cascade delete group when deleting rule --- .../20250128141601_cascade_delete_group/migration.sql | 5 +++++ apps/web/prisma/schema.prisma | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 apps/web/prisma/migrations/20250128141601_cascade_delete_group/migration.sql diff --git a/apps/web/prisma/migrations/20250128141601_cascade_delete_group/migration.sql b/apps/web/prisma/migrations/20250128141601_cascade_delete_group/migration.sql new file mode 100644 index 0000000000..990aebeb7d --- /dev/null +++ b/apps/web/prisma/migrations/20250128141601_cascade_delete_group/migration.sql @@ -0,0 +1,5 @@ +-- 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; 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()) From d5eb2c77f3df8585ec7b2da0013c07f2a2c969f4 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:18:06 +0200 Subject: [PATCH 05/28] delete detached rules --- .../migration.sql | 8 ++++++++ 1 file changed, 8 insertions(+) rename apps/web/prisma/migrations/{20250128141601_cascade_delete_group => 20250128141602_cascade_delete_group}/migration.sql (59%) diff --git a/apps/web/prisma/migrations/20250128141601_cascade_delete_group/migration.sql b/apps/web/prisma/migrations/20250128141602_cascade_delete_group/migration.sql similarity index 59% rename from apps/web/prisma/migrations/20250128141601_cascade_delete_group/migration.sql rename to apps/web/prisma/migrations/20250128141602_cascade_delete_group/migration.sql index 990aebeb7d..69013839af 100644 --- a/apps/web/prisma/migrations/20250128141601_cascade_delete_group/migration.sql +++ b/apps/web/prisma/migrations/20250128141602_cascade_delete_group/migration.sql @@ -3,3 +3,11 @@ 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 From 3cb29c8b680129dde60a14ae5c8072864bb040ae Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:28:52 +0200 Subject: [PATCH 06/28] remove unused imports --- apps/web/app/(app)/automation/RuleForm.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index 6093f5a527..a4cd7d709c 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -3,13 +3,11 @@ 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 +21,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,15 +36,9 @@ 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 { ViewGroup } from "@/app/(app)/automation/group/ViewGroup"; -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"; From 7022b980fea5a875bf938508b8456e50403c5948 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 28 Jan 2025 17:23:24 +0200 Subject: [PATCH 07/28] extract createnewsletter/receipt logic --- apps/web/utils/actions/ai-rule.ts | 77 +++++++++++++++++------ apps/web/utils/actions/group.ts | 100 ------------------------------ apps/web/utils/group/group.ts | 67 +++++++++++++++++++- 3 files changed, 124 insertions(+), 120 deletions(-) diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 3394b444cd..20bb5f017b 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -1,5 +1,6 @@ "use server"; +import type { gmail_v1 } from "@googleapis/gmail"; import { setUser } from "@sentry/nextjs"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma, { isNotFoundError } from "@/utils/prisma"; @@ -12,10 +13,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"; @@ -44,6 +41,7 @@ import { labelVisibility } from "@/utils/gmail/constants"; import type { CreateOrUpdateRuleSchemaWithCategories } from "@/utils/ai/rule/create-rule-schema"; import { safeCreateRule, safeUpdateRule } from "@/utils/rule/rule"; import { getUserCategoriesForNames } from "@/utils/category.server"; +import { createNewsletterGroup, createReceiptGroup } from "@/utils/group/group"; const logger = createScopedLogger("ai-rule"); @@ -192,6 +190,9 @@ 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 gmail = getGmailClient(session); const user = await prisma.user.findUnique({ where: { id: userId }, @@ -215,15 +216,28 @@ export const createAutomationAction = withActionInstrumentation< if (!result) return { error: "AI error creating rule." }; - const groupIdResult = await getGroupId(result, userId); + const groupIdResult = await getGroupId( + result, + userId, + user.email, + gmail, + session.accessToken, + ); if (isActionError(groupIdResult)) return groupIdResult; - return await safeCreateRule(result, userId, groupIdResult, null); + return await safeCreateRule(result, userId, groupIdResult.groupId, null); }); async function getGroupId( result: CreateOrUpdateRuleSchemaWithCategories, userId: string, -) { + userEmail: string, + gmail: gmail_v1.Gmail, + accessToken: string, +): Promise<{ + groupId?: string | null; + existingRuleId?: string | null; + error?: string; +}> { let groupId: string | null = null; if (result.condition.group && result.condition.group) { @@ -246,9 +260,14 @@ async function getGroupId( groupId = newsletterGroup.id; } else { - const result = await createNewsletterGroupAction(); - if (isActionError(result)) { - return result; + const result = await createNewsletterGroup({ + gmail, + accessToken, + userId, + userEmail, + }); + if ("error" in result) { + return { error: result.error }; } if (!result) { return { error: "Error creating newsletter group" }; @@ -270,9 +289,14 @@ async function getGroupId( }; } } else { - const result = await createReceiptGroupAction(); - if (isActionError(result)) { - return result; + const result = await createReceiptGroup({ + gmail, + accessToken, + userId, + userEmail, + }); + if ("error" in result) { + return { error: result.error }; } if (!result) { return { error: "Error creating receipt group" }; @@ -282,7 +306,7 @@ async function getGroupId( } } - return groupId; + return { groupId }; } export const setRuleAutomatedAction = withActionInstrumentation( @@ -378,6 +402,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", { @@ -569,8 +594,15 @@ export const saveRulesPromptAction = withActionInstrumentation( ruleId: rule.ruleId, }); - const groupIdResult = await getGroupId(rule, session.user.id); - if (isActionError(groupIdResult)) { + const gmail = getGmailClient(session); + const groupIdResult = await getGroupId( + rule, + session.user.id, + user.email, + gmail, + session.accessToken, + ); + if (groupIdResult && "error" in groupIdResult) { logger.error("Error updating group for rule", { email: user.email, ruleId: rule.ruleId, @@ -590,7 +622,7 @@ export const saveRulesPromptAction = withActionInstrumentation( rule.ruleId, rule, session.user.id, - groupIdResult, + groupIdResult?.groupId, categoryIds, ); } @@ -619,7 +651,14 @@ export const saveRulesPromptAction = withActionInstrumentation( ruleId: rule.ruleId, }); - const groupIdResult = await getGroupId(rule, session.user.id); + const gmail = getGmailClient(session); + const groupIdResult = await getGroupId( + rule, + session.user.id, + user.email, + gmail, + session.accessToken, + ); if (isActionError(groupIdResult)) { logger.error("Error creating group for rule", { email: user.email, @@ -632,7 +671,7 @@ export const saveRulesPromptAction = withActionInstrumentation( await safeCreateRule( rule, session.user.id, - groupIdResult, + groupIdResult?.groupId, rule.condition.categories?.categoryFilters || [], ); } diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index 0516bc34dd..4ab9210a1c 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -6,109 +6,9 @@ import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { type AddGroupItemBody, addGroupItemBody, - 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 } from "@prisma/client"; -import { - NEWSLETTER_GROUP_ID, - RECEIPT_GROUP_ID, -} from "@/app/(app)/automation/create/examples"; -import { GroupName } from "@/utils/config"; import { withActionInstrumentation } from "@/utils/actions/middleware"; import { addGroupItem, deleteGroupItem } from "@/utils/group/group-item"; -import { createGroup } from "@/utils/group/group"; - -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 }; - }, -); export const addGroupItemAction = withActionInstrumentation( "addGroupItem", diff --git a/apps/web/utils/group/group.ts b/apps/web/utils/group/group.ts index 7cf4a91960..871212a14b 100644 --- a/apps/web/utils/group/group.ts +++ b/apps/web/utils/group/group.ts @@ -1,5 +1,9 @@ +import type { gmail_v1 } from "@googleapis/gmail"; import prisma, { isDuplicateError } from "@/utils/prisma"; -import type { Prisma } from "@prisma/client"; +import { GroupItemType, type Prisma } from "@prisma/client"; +import { GroupName } from "@/utils/config"; +import { findNewsletters } from "@/utils/ai/group/find-newsletters"; +import { findReceipts } from "@/utils/ai/group/find-receipts"; export async function createGroup({ userId, @@ -30,3 +34,64 @@ export async function createGroup({ throw error; } } + +export async function createNewsletterGroup({ + gmail, + accessToken, + userId, + userEmail, +}: { + gmail: gmail_v1.Gmail; + accessToken: string; + userId: string; + userEmail: string; +}) { + const name = GroupName.NEWSLETTER; + const existingGroup = await prisma.group.findFirst({ + where: { name, userId }, + select: { id: true }, + }); + if (existingGroup) return { id: existingGroup.id }; + + const newsletters = await findNewsletters(gmail, accessToken, userEmail); + + const group = await createGroup({ + name, + userId, + items: newsletters.map((newsletter) => ({ + type: GroupItemType.FROM, + value: newsletter, + })), + }); + + return group; +} + +export async function createReceiptGroup({ + gmail, + accessToken, + userId, + userEmail, +}: { + gmail: gmail_v1.Gmail; + accessToken: string; + userId: string; + userEmail: string; +}) { + const name = GroupName.RECEIPT; + const existingGroup = await prisma.group.findFirst({ + where: { name, userId }, + select: { id: true }, + }); + if (existingGroup) return { id: existingGroup.id }; + + const receipts = await findReceipts(gmail, accessToken, userEmail); + + const group = await createGroup({ + name, + userId, + items: receipts, + }); + + return group; +} From 5391a7a89031c04bc6259aa0420a4e8f932dbb7d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 28 Jan 2025 17:36:31 +0200 Subject: [PATCH 08/28] delete group upon deleting rule --- apps/web/app/(app)/automation/RuleForm.tsx | 2 -- apps/web/utils/actions/ai-rule.ts | 8 +++++--- apps/web/utils/actions/rule.ts | 7 +++++-- apps/web/utils/rule/rule.ts | 16 ++++++++++++++++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index a4cd7d709c..98f6624556 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -176,8 +176,6 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { return actionErrors; }, [errors, watch]); - console.log(errors); - return (
    {isSubmitted && Object.keys(errors).length > 0 && ( diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 20bb5f017b..6a4b8d30d1 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -39,7 +39,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"; import { createNewsletterGroup, createReceiptGroup } from "@/utils/group/group"; @@ -549,8 +549,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)) { diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 8c676ab5b8..cd26198847 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -28,6 +28,7 @@ import { import { generatePromptOnDeleteRule } from "@/utils/ai/rule/generate-prompt-on-delete-rule"; import { sanitizeActionFields } from "@/utils/action-item"; import { createGroup } from "@/utils/group/group"; +import { deleteRule } from "@/utils/rule/rule"; export const createRuleAction = withActionInstrumentation( "createRule", @@ -285,8 +286,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/rule/rule.ts b/apps/web/utils/rule/rule.ts index bc797f5717..4bf0e878fc 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -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)); From abdf7329eb21807f897b86ddbe9e15882bf4c786 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 28 Jan 2025 17:55:44 +0200 Subject: [PATCH 09/28] Fix create predefined group error? --- apps/web/utils/rule/rule.ts | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 4bf0e878fc..5f2c6f441f 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -31,17 +31,22 @@ export async function safeCreateRule( ); try { - const rule = await createRule(result, userId, groupId, categoryNames); + const rule = await createRule({ + result, + userId, + groupId, + 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; } @@ -70,12 +75,12 @@ export async function safeUpdateRule( } 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 +96,17 @@ export async function safeUpdateRule( } } -async function createRule( - result: CreateOrUpdateRuleSchemaWithCategories, - userId: string, - groupId?: string | null, - categoryIds?: string[] | null, -) { +async function createRule({ + result, + userId, + groupId, + categoryIds, +}: { + result: CreateOrUpdateRuleSchemaWithCategories; + userId: string; + groupId?: string | null; + categoryIds?: string[] | null; +}) { return prisma.rule.create({ data: { name: result.name, From d1879bade12f3b340077d6a833c4fad5315bdc99 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 28 Jan 2025 21:26:57 +0200 Subject: [PATCH 10/28] Adjust copy for rules condition --- apps/web/utils/condition.ts | 44 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/web/utils/condition.ts b/apps/web/utils/condition.ts index af4c7c9c6e..ae41b84f27 100644 --- a/apps/web/utils/condition.ts +++ b/apps/web/utils/condition.ts @@ -201,43 +201,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"}`; - } - - 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; - } + // 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.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( From b54f0ff2793b4b886e1254f6398638281c5ca8a9 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 28 Jan 2025 21:44:28 +0200 Subject: [PATCH 11/28] Add toggle for auto add patterns --- .../app/(app)/automation/group/ViewGroup.tsx | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/apps/web/app/(app)/automation/group/ViewGroup.tsx b/apps/web/app/(app)/automation/group/ViewGroup.tsx index a70763b5e6..b50cccea8c 100644 --- a/apps/web/app/(app)/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/automation/group/ViewGroup.tsx @@ -32,6 +32,8 @@ import { } from "@/utils/actions/validation"; import { isActionError } from "@/utils/error"; import { Badge } from "@/components/ui/badge"; +import { Toggle } from "@/components/Toggle"; +import { TooltipExplanation } from "@/components/TooltipExplanation"; export function ViewGroup({ groupId }: { groupId: string | null }) { const { data, isLoading, error, mutate } = useSWR( @@ -42,39 +44,47 @@ export function ViewGroup({ groupId }: { groupId: string | null }) { const [showAddItem, setShowAddItem] = useState(false); return ( -
    -
    - {showAddItem ? ( - - ) : ( - <> -
    - +
    + {showAddItem ? ( + + ) : ( +
    +
    + + {}} + /> +
    - -
    - - )} -
    +
    + + + +
    +
    + )}
    Date: Wed, 29 Jan 2025 00:08:26 +0200 Subject: [PATCH 12/28] Add group item status --- .../app/(app)/automation/ReportMistake.tsx | 4 +- .../app/(app)/automation/group/ViewGroup.tsx | 178 ++++++++++++------ .../rule/[ruleId]/examples/example-list.tsx | 4 +- .../migration.sql | 5 + apps/web/prisma/schema.prisma | 15 +- apps/web/utils/actions/group.ts | 8 +- .../ai/assistant/process-user-request.ts | 4 +- apps/web/utils/group/group-item.ts | 9 +- 8 files changed, 157 insertions(+), 70 deletions(-) create mode 100644 apps/web/prisma/migrations/20250128220758_group_item_status/migration.sql diff --git a/apps/web/app/(app)/automation/ReportMistake.tsx b/apps/web/app/(app)/automation/ReportMistake.tsx index 76badf219e..9b137c89d0 100644 --- a/apps/web/app/(app)/automation/ReportMistake.tsx +++ b/apps/web/app/(app)/automation/ReportMistake.tsx @@ -49,7 +49,7 @@ import { Loading, LoadingMiniSpinner } from "@/components/Loading"; import type { ParsedMessage } from "@/utils/types"; import { addGroupItemAction, - deleteGroupItemAction, + rejectGroupItemAction, } from "@/utils/actions/group"; import { useRules } from "@/hooks/useRules"; import type { CategoryMatch, GroupMatch } from "@/utils/ai/choose-rule/types"; @@ -543,7 +543,7 @@ function GroupMismatchRemove({ setIsRemoving(false); throw new Error("No group item ID found"); } - const result = await deleteGroupItemAction(groupItemId); + const result = await rejectGroupItemAction(groupItemId); setIsRemoving(false); if (isActionError(result)) throw new Error(result.error); onClose(); diff --git a/apps/web/app/(app)/automation/group/ViewGroup.tsx b/apps/web/app/(app)/automation/group/ViewGroup.tsx index b50cccea8c..ac7b2549d9 100644 --- a/apps/web/app/(app)/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/automation/group/ViewGroup.tsx @@ -9,6 +9,7 @@ import { type Dispatch, type SetStateAction, } from "react"; +import groupBy from "lodash/groupBy"; import { type SubmitHandler, useForm } from "react-hook-form"; import { capitalCase } from "capital-case"; import { toastSuccess, toastError } from "@/components/Toast"; @@ -16,13 +17,20 @@ import type { GroupItemsResponse } from "@/app/api/user/group/[groupId]/items/ro import { LoadingContent } from "@/components/LoadingContent"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { Table, TableRow, TableBody, TableCell } from "@/components/ui/table"; +import { + Table, + TableRow, + TableBody, + TableCell, + TableHeader, + TableHead, +} from "@/components/ui/table"; import { MessageText } from "@/components/Typography"; import { addGroupItemAction, - deleteGroupItemAction, + rejectGroupItemAction, } from "@/utils/actions/group"; -import { type GroupItem, GroupItemType } from "@prisma/client"; +import { type GroupItem, GroupItemStatus, GroupItemType } from "@prisma/client"; import { Input } from "@/components/Input"; import { Select } from "@/components/Select"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -94,56 +102,7 @@ export function ViewGroup({ groupId }: { groupId: string | null }) { > {data && (group?.items.length ? ( - <> - - - {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. @@ -242,6 +201,119 @@ const AddGroupItemForm = ({ ); }; +function GroupItems({ + items, + mutate, +}: { + items: GroupItem[]; + mutate: KeyedMutator; +}) { + const groupedByStatus = groupBy( + items, + (item) => item.status || GroupItemStatus.APPROVED, + ); + + return ( +
    + + Match + +
    + } + items={groupedByStatus[GroupItemStatus.APPROVED] || []} + mutate={mutate} + /> + + +
    + ); +} + +function GroupItemList({ + title, + items, + mutate, +}: { + title?: React.ReactNode; + items: GroupItem[]; + mutate: KeyedMutator; +}) { + return ( + + {title && ( + + + {title} + + + + )} + + {items.map((item) => { + const twoMinutesAgo = new Date(Date.now() - 1000 * 60 * 2); + const isCreatedRecently = new Date(item.createdAt) > twoMinutesAgo; + const isUpdatedRecently = new Date(item.updatedAt) > twoMinutesAgo; + + return ( + + +
    + {isCreatedRecently || + (isUpdatedRecently && ( + + {isCreatedRecently ? "New!" : "Updated"} + + ))} + +
    + +
    +
    +
    + + + +
    + ); + })} + + {items.length === 0 && ( + + + No items + + + )} +
    +
    + ); +} + export function GroupItemDisplay({ item, }: { diff --git a/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx b/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx index 279519f431..51616ac77b 100644 --- a/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx +++ b/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx @@ -5,7 +5,7 @@ import clsx from "clsx"; import type { Dictionary } from "lodash"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { deleteGroupItemAction } from "@/utils/actions/group"; +import { rejectGroupItemAction } from "@/utils/actions/group"; import type { MessageWithGroupItem } from "@/app/(app)/automation/rule/[ruleId]/examples/types"; import { isActionError } from "@/utils/error"; import { toastError } from "@/components/Toast"; @@ -50,7 +50,7 @@ export function ExampleList({ size="sm" className="mt-4 text-wrap" onClick={() => { - const result = deleteGroupItemAction(matchingGroupItem.id); + const result = rejectGroupItemAction(matchingGroupItem.id); if (isActionError(result)) { toastError({ description: `Failed to remove ${matchingGroupItem.value} from group. ${result.error}`, diff --git a/apps/web/prisma/migrations/20250128220758_group_item_status/migration.sql b/apps/web/prisma/migrations/20250128220758_group_item_status/migration.sql new file mode 100644 index 0000000000..0e4d1c607c --- /dev/null +++ b/apps/web/prisma/migrations/20250128220758_group_item_status/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "GroupItemStatus" AS ENUM ('APPROVED', 'REJECTED', 'NEEDS_AI'); + +-- AlterTable +ALTER TABLE "GroupItem" ADD COLUMN "status" "GroupItemStatus" NOT NULL DEFAULT 'APPROVED'; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 53ef072346..70cc00a37f 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -297,13 +297,14 @@ model Group { } model GroupItem { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt groupId String? - group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) type GroupItemType value String // eg "@gmail.com", "matt@gmail.com", "Receipt from" + status GroupItemStatus @default(APPROVED) @@unique([groupId, type, value]) } @@ -461,3 +462,9 @@ enum LogicalOperator { AND OR } + +enum GroupItemStatus { + APPROVED // Auto-approve matching emails, no AI needed + REJECTED // Auto-reject matching emails, no AI needed + NEEDS_AI // Run AI but don't auto-approve even if AI suggests this pattern +} diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index 4ab9210a1c..dcb809e043 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -8,7 +8,7 @@ import { addGroupItemBody, } from "@/utils/actions/validation"; import { withActionInstrumentation } from "@/utils/actions/middleware"; -import { addGroupItem, deleteGroupItem } from "@/utils/group/group-item"; +import { addGroupItem, rejectGroupItem } from "@/utils/group/group-item"; export const addGroupItemAction = withActionInstrumentation( "addGroupItem", @@ -32,13 +32,13 @@ export const addGroupItemAction = withActionInstrumentation( }, ); -export const deleteGroupItemAction = withActionInstrumentation( - "deleteGroupItem", +export const rejectGroupItemAction = withActionInstrumentation( + "rejectGroupItem", async (id: string) => { const session = await auth(); if (!session?.user.id) return { error: "Not logged in" }; - await deleteGroupItem({ id, userId: session.user.id }); + await rejectGroupItem({ id, userId: session.user.id }); revalidatePath("/automation"); }, diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 89ad4b4538..0330a09b30 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 { addGroupItem, rejectGroupItem } from "@/utils/group/group-item"; import { addRuleCategories, partialUpdateRule, @@ -388,7 +388,7 @@ ${senderCategory || "No category"} } try { - await deleteGroupItem({ + await rejectGroupItem({ id: groupItem.id, userId: user.id, }); diff --git a/apps/web/utils/group/group-item.ts b/apps/web/utils/group/group-item.ts index fdcee6c947..1bef25f653 100644 --- a/apps/web/utils/group/group-item.ts +++ b/apps/web/utils/group/group-item.ts @@ -1,5 +1,5 @@ import prisma, { isDuplicateError } from "@/utils/prisma"; -import type { GroupItemType } from "@prisma/client"; +import { GroupItemStatus, type GroupItemType } from "@prisma/client"; import { captureException } from "@/utils/error"; export async function addGroupItem(data: { @@ -18,12 +18,15 @@ export async function addGroupItem(data: { } } -export async function deleteGroupItem({ +export async function rejectGroupItem({ id, userId, }: { id: string; userId: string; }) { - await prisma.groupItem.delete({ where: { id, group: { userId } } }); + await prisma.groupItem.update({ + where: { id, group: { userId } }, + data: { status: GroupItemStatus.REJECTED }, + }); } From cc5c15519cdb64cb60b2c37101f49d16cd54f21b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:26:36 +0200 Subject: [PATCH 13/28] Change groups to learned patterns. Move out of conditions --- apps/web/app/(app)/automation/RuleForm.tsx | 55 ++--- .../automation/group/LearnedPatterns.tsx | 191 ++++++++++++++++++ .../app/(app)/automation/group/ViewGroup.tsx | 37 ++-- apps/web/components/ui/collapsible.tsx | 11 + apps/web/package.json | 1 + .../migration.sql | 2 +- apps/web/prisma/schema.prisma | 6 +- apps/web/utils/actions/validation.ts | 2 +- apps/web/utils/condition.ts | 7 - pnpm-lock.yaml | 32 +++ 10 files changed, 279 insertions(+), 65 deletions(-) create mode 100644 apps/web/app/(app)/automation/group/LearnedPatterns.tsx create mode 100644 apps/web/components/ui/collapsible.tsx rename apps/web/prisma/migrations/{20250128220758_group_item_status => 20250128221751_group_item_status}/migration.sql (94%) diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index 8e637c4565..6d3dfade06 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import useSWR from "swr"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { @@ -16,7 +17,14 @@ import { toast } from "sonner"; import TextareaAutosize from "react-textarea-autosize"; import { capitalCase } from "capital-case"; import { usePostHog } from "posthog-js/react"; -import { ExternalLinkIcon, PlusIcon, FilterIcon } from "lucide-react"; +import { + ExternalLinkIcon, + PlusIcon, + FilterIcon, + ChevronDown, + Brain, + XIcon, +} from "lucide-react"; import { Card } from "@/components/Card"; import { Button } from "@/components/ui/button"; import { ErrorMessage, Input, Label } from "@/components/Input"; @@ -57,7 +65,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { ScrollArea } from "@/components/ui/scroll-area"; +import { LearnedPatterns } from "@/app/(app)/automation/group/LearnedPatterns"; export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { const { @@ -258,7 +266,6 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { { label: "AI", value: RuleType.AI }, { label: "Static", value: RuleType.STATIC }, { label: "Smart Category", value: RuleType.CATEGORY }, - { label: "Group", value: RuleType.GROUP }, ]} error={ errors.conditions?.[index]?.type as FieldError | undefined @@ -352,14 +359,6 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { )} - {watch(`conditions.${index}.type`) === RuleType.GROUP && ( - - )} - {watch(`conditions.${index}.type`) === RuleType.CATEGORY && ( <>
    @@ -499,6 +498,12 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) {
    )} + {rule.groupId && ( +
    + +
    + )} + Actions {actionErrors.length > 0 && ( @@ -718,34 +723,6 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { ); } -function GroupsTab(props: { - registerProps: UseFormRegisterReturn; - errors: FieldErrors; - groupId?: string | null; -}) { - return ( -
    - - Advanced: Groups are usually managed by our AI. A group is a static - collection of senders and subjects. Our AI collects these for you to - more efficiently match future emails. - - -
    - {props.groupId ? ( - - - - ) : ( -
    - To add group items, create the rule first. -
    - )} -
    -
    - ); -} - function LabelCombobox({ value, onChangeValue, 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..df577c50bb --- /dev/null +++ b/apps/web/app/(app)/automation/group/LearnedPatterns.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState } from "react"; +import { Toggle } from "@/components/Toggle"; +import { TooltipExplanation } from "@/components/TooltipExplanation"; +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); + const [autoLearn, setAutoLearn] = useState(false); + + return ( + + +
    + + Learned Patterns +
    + +
    +
    + + setAutoLearn(enabled)} + /> +
    + + +
    +
    + + + + +
    + ); +} + +// export function LearnedPatterns({ groupId }: { groupId: string }) { +// const [isOpen, setIsOpen] = useState(false); +// const [hasOpened, setHasOpened] = useState(false); +// const [autoLearn, setAutoLearn] = useState(false); + +// const { data, isLoading, error, mutate } = useSWR( +// hasOpened ? `/api/user/group/${groupId}/items` : null, +// ); + +// const handleOpen = useCallback((open: boolean) => { +// setIsOpen(open); +// if (open) setHasOpened(true); +// }, []); + +// const handleRemovePattern = useCallback(async (patternId: string) => { +// const result = await removePatternAction({ patternId }); +// if (isActionError(result)) { +// toastError({ +// title: "Error removing pattern", +// description: result.error, +// }); +// } +// }, []); + +// const handleAddPattern = useCallback(async (pattern: string) => { +// const result = await addPatternAction({ pattern }); +// if (isActionError(result)) { +// toastError({ +// title: "Error adding pattern", +// description: result.error, +// }); +// } +// }, []); + +// return ( +// +// +//
    +// +// Learned Patterns +// {!!data?.group?.items.length && ( +// +// ({data?.group?.items.length}{" "} +// {pluralize(data?.group?.items.length, "pattern")}) +// +// )} +//
    + +//
    +//
    +// +// setAutoLearn(enabled)} +// /> +//
    + +// +//
    +//
    + +// +//
    +//
    +// The AI has learned these patterns for matching emails: +//
    + +// +//
    +// {data?.group?.items.map((item) => { +// const isApproved = item.status === "APPROVED"; + +// return ( +// // +//
    +//
    +// +// {isApproved ? "✓" : "✗"} +// +// +// {item.type.toLowerCase()}: {item.value} +// +//
    +// +//
    +// ); +// })} + +// {data?.group?.items.length === 0 && ( +//
    +// No patterns learned yet +//
    +// )} +//
    +//
    + +// +//
    +//
    +//
    +// ); +// } diff --git a/apps/web/app/(app)/automation/group/ViewGroup.tsx b/apps/web/app/(app)/automation/group/ViewGroup.tsx index ac7b2549d9..003f31c7a7 100644 --- a/apps/web/app/(app)/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/automation/group/ViewGroup.tsx @@ -2,7 +2,7 @@ import useSWR, { type KeyedMutator } from "swr"; import Link from "next/link"; -import { PlusIcon, TrashIcon, ExternalLinkIcon } from "lucide-react"; +import { PlusIcon, ExternalLinkIcon, XCircleIcon } from "lucide-react"; import { useState, useCallback, @@ -40,7 +40,6 @@ import { } from "@/utils/actions/validation"; import { isActionError } from "@/utils/error"; import { Badge } from "@/components/ui/badge"; -import { Toggle } from "@/components/Toggle"; import { TooltipExplanation } from "@/components/TooltipExplanation"; export function ViewGroup({ groupId }: { groupId: string | null }) { @@ -52,7 +51,7 @@ export function ViewGroup({ groupId }: { groupId: string | null }) { const [showAddItem, setShowAddItem] = useState(false); return ( -
    +
    {showAddItem ? ( ) : (
    -
    +
    + {/*
    {}} /> -
    +
    */}
    )} -
    +
    - Match - +
    + When these patterns are encountered, the rule will automatically + match:
    } items={groupedByStatus[GroupItemStatus.APPROVED] || []} mutate={mutate} /> + These patterns will never match: +
    + } items={groupedByStatus[GroupItemStatus.REJECTED] || []} mutate={mutate} /> + These patterns will need evaluation (and the AI will not move them + to the Match or Never Match lists): +
    + } + items={groupedByStatus[GroupItemStatus.EVALUATE] || []} mutate={mutate} />
    @@ -295,7 +304,7 @@ function GroupItemList({ } }} > - + 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/20250128220758_group_item_status/migration.sql b/apps/web/prisma/migrations/20250128221751_group_item_status/migration.sql similarity index 94% rename from apps/web/prisma/migrations/20250128220758_group_item_status/migration.sql rename to apps/web/prisma/migrations/20250128221751_group_item_status/migration.sql index 0e4d1c607c..ce94324e46 100644 --- a/apps/web/prisma/migrations/20250128220758_group_item_status/migration.sql +++ b/apps/web/prisma/migrations/20250128221751_group_item_status/migration.sql @@ -1,5 +1,5 @@ -- CreateEnum -CREATE TYPE "GroupItemStatus" AS ENUM ('APPROVED', 'REJECTED', 'NEEDS_AI'); +CREATE TYPE "GroupItemStatus" AS ENUM ('APPROVED', 'REJECTED', 'EVALUATE'); -- AlterTable ALTER TABLE "GroupItem" ADD COLUMN "status" "GroupItemStatus" NOT NULL DEFAULT 'APPROVED'; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 70cc00a37f..4246643d4b 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -464,7 +464,7 @@ enum LogicalOperator { } enum GroupItemStatus { - APPROVED // Auto-approve matching emails, no AI needed - REJECTED // Auto-reject matching emails, no AI needed - NEEDS_AI // Run AI but don't auto-approve even if AI suggests this pattern + APPROVED // Auto match emails + REJECTED // Never match emails + EVALUATE // Neither auto-approve nor auto-reject; evaluate using other conditions } diff --git a/apps/web/utils/actions/validation.ts b/apps/web/utils/actions/validation.ts index f472320dc1..68c45a2a33 100644 --- a/apps/web/utils/actions/validation.ts +++ b/apps/web/utils/actions/validation.ts @@ -107,7 +107,6 @@ const zodCategoryCondition = z.object({ const zodCondition = z.object({ type: zodRuleType, ...zodAiCondition.shape, - ...zodGroupCondition.shape, ...zodStaticCondition.shape, ...zodCategoryCondition.shape, }); @@ -117,6 +116,7 @@ export const createRuleBody = z.object({ id: z.string().optional(), 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/condition.ts b/apps/web/utils/condition.ts index ae41b84f27..5e75cc755c 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, 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) From 3b96e1bcb8ce73bb9253fef5396757402640d3f4 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:29:18 +0200 Subject: [PATCH 14/28] disable auto learn toggle --- apps/web/app/(app)/automation/group/LearnedPatterns.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(app)/automation/group/LearnedPatterns.tsx b/apps/web/app/(app)/automation/group/LearnedPatterns.tsx index df577c50bb..b0ed413861 100644 --- a/apps/web/app/(app)/automation/group/LearnedPatterns.tsx +++ b/apps/web/app/(app)/automation/group/LearnedPatterns.tsx @@ -1,8 +1,6 @@ "use client"; import { useState } from "react"; -import { Toggle } from "@/components/Toggle"; -import { TooltipExplanation } from "@/components/TooltipExplanation"; import { Collapsible, CollapsibleTrigger, @@ -13,7 +11,6 @@ import { ViewGroup } from "@/app/(app)/automation/group/ViewGroup"; export function LearnedPatterns({ groupId }: { groupId: string }) { const [isOpen, setIsOpen] = useState(false); - const [autoLearn, setAutoLearn] = useState(false); return (
    -
    + {/*
    setAutoLearn(enabled)} /> -
    +
    */} Date: Wed, 29 Jan 2025 17:48:36 +0200 Subject: [PATCH 15/28] Revert group item status update --- .../app/(app)/automation/ReportMistake.tsx | 4 +- apps/web/app/(app)/automation/RuleForm.tsx | 1 - .../automation/group/LearnedPatterns.tsx | 138 +----------------- .../app/(app)/automation/group/ViewGroup.tsx | 28 ++-- .../rule/[ruleId]/examples/example-list.tsx | 4 +- .../migration.sql | 5 - apps/web/prisma/schema.prisma | 15 +- apps/web/utils/actions/group.ts | 8 +- .../ai/assistant/process-user-request.ts | 7 +- apps/web/utils/group/group-item.ts | 9 +- 10 files changed, 31 insertions(+), 188 deletions(-) delete mode 100644 apps/web/prisma/migrations/20250128221751_group_item_status/migration.sql diff --git a/apps/web/app/(app)/automation/ReportMistake.tsx b/apps/web/app/(app)/automation/ReportMistake.tsx index 9b137c89d0..76badf219e 100644 --- a/apps/web/app/(app)/automation/ReportMistake.tsx +++ b/apps/web/app/(app)/automation/ReportMistake.tsx @@ -49,7 +49,7 @@ import { Loading, LoadingMiniSpinner } from "@/components/Loading"; import type { ParsedMessage } from "@/utils/types"; import { addGroupItemAction, - rejectGroupItemAction, + deleteGroupItemAction, } from "@/utils/actions/group"; import { useRules } from "@/hooks/useRules"; import type { CategoryMatch, GroupMatch } from "@/utils/ai/choose-rule/types"; @@ -543,7 +543,7 @@ function GroupMismatchRemove({ setIsRemoving(false); throw new Error("No group item ID found"); } - const result = await rejectGroupItemAction(groupItemId); + const result = await deleteGroupItemAction(groupItemId); setIsRemoving(false); if (isActionError(result)) throw new Error(result.error); onClose(); diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index 6d3dfade06..a17c41ac0e 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -46,7 +46,6 @@ import { Select } from "@/components/Select"; import { Toggle } from "@/components/Toggle"; import { LoadingContent } from "@/components/LoadingContent"; import { TooltipExplanation } from "@/components/TooltipExplanation"; -import { ViewGroup } from "@/app/(app)/automation/group/ViewGroup"; import { isActionError } from "@/utils/error"; import { Combobox } from "@/components/Combobox"; import { useLabels } from "@/hooks/useLabels"; diff --git a/apps/web/app/(app)/automation/group/LearnedPatterns.tsx b/apps/web/app/(app)/automation/group/LearnedPatterns.tsx index b0ed413861..7623ab8802 100644 --- a/apps/web/app/(app)/automation/group/LearnedPatterns.tsx +++ b/apps/web/app/(app)/automation/group/LearnedPatterns.tsx @@ -21,7 +21,7 @@ export function LearnedPatterns({ groupId }: { groupId: string }) {
    - Learned Patterns + Learned Patterns (Group)
    @@ -50,139 +50,3 @@ export function LearnedPatterns({ groupId }: { groupId: string }) { ); } - -// export function LearnedPatterns({ groupId }: { groupId: string }) { -// const [isOpen, setIsOpen] = useState(false); -// const [hasOpened, setHasOpened] = useState(false); -// const [autoLearn, setAutoLearn] = useState(false); - -// const { data, isLoading, error, mutate } = useSWR( -// hasOpened ? `/api/user/group/${groupId}/items` : null, -// ); - -// const handleOpen = useCallback((open: boolean) => { -// setIsOpen(open); -// if (open) setHasOpened(true); -// }, []); - -// const handleRemovePattern = useCallback(async (patternId: string) => { -// const result = await removePatternAction({ patternId }); -// if (isActionError(result)) { -// toastError({ -// title: "Error removing pattern", -// description: result.error, -// }); -// } -// }, []); - -// const handleAddPattern = useCallback(async (pattern: string) => { -// const result = await addPatternAction({ pattern }); -// if (isActionError(result)) { -// toastError({ -// title: "Error adding pattern", -// description: result.error, -// }); -// } -// }, []); - -// return ( -// -// -//
    -// -// Learned Patterns -// {!!data?.group?.items.length && ( -// -// ({data?.group?.items.length}{" "} -// {pluralize(data?.group?.items.length, "pattern")}) -// -// )} -//
    - -//
    -//
    -// -// setAutoLearn(enabled)} -// /> -//
    - -// -//
    -//
    - -// -//
    -//
    -// The AI has learned these patterns for matching emails: -//
    - -// -//
    -// {data?.group?.items.map((item) => { -// const isApproved = item.status === "APPROVED"; - -// return ( -// // -//
    -//
    -// -// {isApproved ? "✓" : "✗"} -// -// -// {item.type.toLowerCase()}: {item.value} -// -//
    -// -//
    -// ); -// })} - -// {data?.group?.items.length === 0 && ( -//
    -// No patterns learned yet -//
    -// )} -//
    -//
    - -// -//
    -//
    -//
    -// ); -// } diff --git a/apps/web/app/(app)/automation/group/ViewGroup.tsx b/apps/web/app/(app)/automation/group/ViewGroup.tsx index 003f31c7a7..b62bfce90c 100644 --- a/apps/web/app/(app)/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/automation/group/ViewGroup.tsx @@ -9,7 +9,6 @@ import { type Dispatch, type SetStateAction, } from "react"; -import groupBy from "lodash/groupBy"; import { type SubmitHandler, useForm } from "react-hook-form"; import { capitalCase } from "capital-case"; import { toastSuccess, toastError } from "@/components/Toast"; @@ -28,9 +27,9 @@ import { import { MessageText } from "@/components/Typography"; import { addGroupItemAction, - rejectGroupItemAction, + deleteGroupItemAction, } from "@/utils/actions/group"; -import { type GroupItem, GroupItemStatus, GroupItemType } 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"; @@ -40,9 +39,8 @@ import { } from "@/utils/actions/validation"; import { isActionError } from "@/utils/error"; import { Badge } from "@/components/ui/badge"; -import { TooltipExplanation } from "@/components/TooltipExplanation"; -export function ViewGroup({ groupId }: { groupId: string | null }) { +export function ViewGroup({ groupId }: { groupId: string }) { const { data, isLoading, error, mutate } = useSWR( `/api/user/group/${groupId}/items`, ); @@ -119,7 +117,7 @@ const AddGroupItemForm = ({ mutate, setShowAddItem, }: { - groupId: string | null; + groupId: string; mutate: KeyedMutator; setShowAddItem: Dispatch>; }) => { @@ -129,7 +127,7 @@ const AddGroupItemForm = ({ formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(addGroupItemBody), - defaultValues: { groupId: groupId! }, + defaultValues: { groupId }, }); const onClose = useCallback(() => { @@ -208,10 +206,10 @@ function GroupItems({ items: GroupItem[]; mutate: KeyedMutator; }) { - const groupedByStatus = groupBy( - items, - (item) => item.status || GroupItemStatus.APPROVED, - ); + // const groupedByStatus = groupBy( + // items, + // (item) => item.status || GroupItemStatus.APPROVED, + // ); return (
    @@ -222,10 +220,10 @@ function GroupItems({ match:
    } - items={groupedByStatus[GroupItemStatus.APPROVED] || []} + items={items} mutate={mutate} /> - These patterns will never match: @@ -243,7 +241,7 @@ function GroupItems({ } items={groupedByStatus[GroupItemStatus.EVALUATE] || []} mutate={mutate} - /> + /> */}
    ); } @@ -294,7 +292,7 @@ function GroupItemList({ variant="outline" size="icon" onClick={async () => { - const result = await rejectGroupItemAction(item.id); + const result = await deleteGroupItemAction(item.id); if (isActionError(result)) { toastError({ description: `Failed to remove ${item.value} from group. ${result.error}`, diff --git a/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx b/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx index 51616ac77b..279519f431 100644 --- a/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx +++ b/apps/web/app/(app)/automation/rule/[ruleId]/examples/example-list.tsx @@ -5,7 +5,7 @@ import clsx from "clsx"; import type { Dictionary } from "lodash"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { rejectGroupItemAction } from "@/utils/actions/group"; +import { deleteGroupItemAction } from "@/utils/actions/group"; import type { MessageWithGroupItem } from "@/app/(app)/automation/rule/[ruleId]/examples/types"; import { isActionError } from "@/utils/error"; import { toastError } from "@/components/Toast"; @@ -50,7 +50,7 @@ export function ExampleList({ size="sm" className="mt-4 text-wrap" onClick={() => { - const result = rejectGroupItemAction(matchingGroupItem.id); + const result = deleteGroupItemAction(matchingGroupItem.id); if (isActionError(result)) { toastError({ description: `Failed to remove ${matchingGroupItem.value} from group. ${result.error}`, diff --git a/apps/web/prisma/migrations/20250128221751_group_item_status/migration.sql b/apps/web/prisma/migrations/20250128221751_group_item_status/migration.sql deleted file mode 100644 index ce94324e46..0000000000 --- a/apps/web/prisma/migrations/20250128221751_group_item_status/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- CreateEnum -CREATE TYPE "GroupItemStatus" AS ENUM ('APPROVED', 'REJECTED', 'EVALUATE'); - --- AlterTable -ALTER TABLE "GroupItem" ADD COLUMN "status" "GroupItemStatus" NOT NULL DEFAULT 'APPROVED'; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 4246643d4b..53ef072346 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -297,14 +297,13 @@ model Group { } model GroupItem { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt groupId String? - group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) type GroupItemType value String // eg "@gmail.com", "matt@gmail.com", "Receipt from" - status GroupItemStatus @default(APPROVED) @@unique([groupId, type, value]) } @@ -462,9 +461,3 @@ enum LogicalOperator { AND OR } - -enum GroupItemStatus { - APPROVED // Auto match emails - REJECTED // Never match emails - EVALUATE // Neither auto-approve nor auto-reject; evaluate using other conditions -} diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index dcb809e043..4ab9210a1c 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -8,7 +8,7 @@ import { addGroupItemBody, } from "@/utils/actions/validation"; import { withActionInstrumentation } from "@/utils/actions/middleware"; -import { addGroupItem, rejectGroupItem } from "@/utils/group/group-item"; +import { addGroupItem, deleteGroupItem } from "@/utils/group/group-item"; export const addGroupItemAction = withActionInstrumentation( "addGroupItem", @@ -32,13 +32,13 @@ export const addGroupItemAction = withActionInstrumentation( }, ); -export const rejectGroupItemAction = withActionInstrumentation( - "rejectGroupItem", +export const deleteGroupItemAction = withActionInstrumentation( + "deleteGroupItem", async (id: string) => { const session = await auth(); if (!session?.user.id) return { error: "Not logged in" }; - await rejectGroupItem({ id, userId: session.user.id }); + await deleteGroupItem({ id, userId: session.user.id }); revalidatePath("/automation"); }, diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 0330a09b30..5b7185ca80 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, rejectGroupItem } from "@/utils/group/group-item"; +import { addGroupItem, deleteGroupItem } from "@/utils/group/group-item"; import { addRuleCategories, partialUpdateRule, @@ -388,10 +388,7 @@ ${senderCategory || "No category"} } try { - await rejectGroupItem({ - 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); diff --git a/apps/web/utils/group/group-item.ts b/apps/web/utils/group/group-item.ts index 1bef25f653..fdcee6c947 100644 --- a/apps/web/utils/group/group-item.ts +++ b/apps/web/utils/group/group-item.ts @@ -1,5 +1,5 @@ import prisma, { isDuplicateError } from "@/utils/prisma"; -import { GroupItemStatus, type GroupItemType } from "@prisma/client"; +import type { GroupItemType } from "@prisma/client"; import { captureException } from "@/utils/error"; export async function addGroupItem(data: { @@ -18,15 +18,12 @@ export async function addGroupItem(data: { } } -export async function rejectGroupItem({ +export async function deleteGroupItem({ id, userId, }: { id: string; userId: string; }) { - await prisma.groupItem.update({ - where: { id, group: { userId } }, - data: { status: GroupItemStatus.REJECTED }, - }); + await prisma.groupItem.delete({ where: { id, group: { userId } } }); } From 782d68cdeffa2810fd6311e7a36b810bd6876b36 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:49:20 +0200 Subject: [PATCH 16/28] Revert back to trash icon to remove from group --- apps/web/app/(app)/automation/group/ViewGroup.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(app)/automation/group/ViewGroup.tsx b/apps/web/app/(app)/automation/group/ViewGroup.tsx index b62bfce90c..61f0feb3f2 100644 --- a/apps/web/app/(app)/automation/group/ViewGroup.tsx +++ b/apps/web/app/(app)/automation/group/ViewGroup.tsx @@ -2,7 +2,7 @@ import useSWR, { type KeyedMutator } from "swr"; import Link from "next/link"; -import { PlusIcon, ExternalLinkIcon, XCircleIcon } from "lucide-react"; +import { PlusIcon, ExternalLinkIcon, TrashIcon } from "lucide-react"; import { useState, useCallback, @@ -302,7 +302,7 @@ function GroupItemList({ } }} > - + From 819ba3860c4a200e8a7ebf8d77e355e0a3b6a4de Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 18:06:52 +0200 Subject: [PATCH 17/28] Ignore conditional operator for group --- apps/web/app/(app)/automation/RuleForm.tsx | 4 +- .../app/(app)/automation/rule/create/page.tsx | 10 +---- apps/web/utils/ai/choose-rule/match-rules.ts | 40 +++++++++---------- apps/web/utils/condition.ts | 14 ++----- 4 files changed, 27 insertions(+), 41 deletions(-) diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index a17c41ac0e..2be7e6aee6 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -163,7 +163,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 Object.values(RuleType).find( + (type) => !usedConditions.has(type) && type !== RuleType.GROUP, + ) as Exclude | undefined; }, [conditions]); // biome-ignore lint/correctness/useExhaustiveDependencies: 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/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 22ea045c51..4507a1c370 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -74,26 +74,10 @@ 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 + // group - ignores conditional operator + // if a match is found, return it if (conditionTypes.GROUP) { const { matchingItem, group } = await matchesGroupRule( rule, @@ -101,15 +85,29 @@ async function findPotentialMatchingRules({ 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/condition.ts b/apps/web/utils/condition.ts index 5e75cc755c..8b31f5434d 100644 --- a/apps/web/utils/condition.ts +++ b/apps/web/utils/condition.ts @@ -88,8 +88,7 @@ export function getConditionTypes( } export function getEmptyCondition( - type: RuleType, - groupId?: string, + type: Exclude, category?: string, ): ZodCondition { switch (type) { @@ -98,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, @@ -126,7 +120,6 @@ export function getEmptyCondition( type FlattenedConditions = { instructions?: string | null; - groupId?: string | null; from?: string | null; to?: string | null; subject?: string | null; @@ -143,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; @@ -156,6 +146,8 @@ export const flattenConditions = ( acc.categoryFilterType = condition.categoryFilterType; acc.categoryFilters = condition.categoryFilters; break; + case RuleType.GROUP: + break; default: console.log(`Unhandled condition type: ${condition.type}`); // biome-ignore lint/correctness/noSwitchDeclarations: intentional exhaustive check From 3cc8eb1514a0067321c5de5e110d3b2381ea6d84 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 18:24:47 +0200 Subject: [PATCH 18/28] Clean out a lot of the old groups code --- apps/web/app/(app)/automation/RuleForm.tsx | 16 +- .../app/(app)/automation/create/examples.tsx | 11 +- apps/web/utils/actions/ai-rule.ts | 140 +----------------- apps/web/utils/actions/rule.ts | 15 -- apps/web/utils/actions/validation.ts | 11 -- .../ai/assistant/process-user-request.ts | 10 +- apps/web/utils/ai/rule/create-rule-schema.ts | 7 - apps/web/utils/condition.ts | 2 - apps/web/utils/config.ts | 5 - apps/web/utils/group/group.ts | 97 ------------ apps/web/utils/rule/rule.ts | 12 +- 11 files changed, 12 insertions(+), 314 deletions(-) delete mode 100644 apps/web/utils/group/group.ts diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index 2be7e6aee6..b149aac83e 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -1,14 +1,11 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import useSWR from "swr"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { type FieldError, - type FieldErrors, type SubmitHandler, - type UseFormRegisterReturn, useFieldArray, useForm, } from "react-hook-form"; @@ -17,14 +14,7 @@ import { toast } from "sonner"; import TextareaAutosize from "react-textarea-autosize"; import { capitalCase } from "capital-case"; import { usePostHog } from "posthog-js/react"; -import { - ExternalLinkIcon, - PlusIcon, - FilterIcon, - ChevronDown, - Brain, - XIcon, -} from "lucide-react"; +import { ExternalLinkIcon, PlusIcon, FilterIcon } from "lucide-react"; import { Card } from "@/components/Card"; import { Button } from "@/components/ui/button"; import { ErrorMessage, Input, Label } from "@/components/Input"; @@ -163,8 +153,8 @@ 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) && type !== RuleType.GROUP, + return [RuleType.AI, RuleType.STATIC, RuleType.CATEGORY].find( + (type) => !usedConditions.has(type), ) as Exclude | undefined; }, [conditions]); diff --git a/apps/web/app/(app)/automation/create/examples.tsx b/apps/web/app/(app)/automation/create/examples.tsx index 5e8df7b214..7ddb32c232 100644 --- a/apps/web/app/(app)/automation/create/examples.tsx +++ b/apps/web/app/(app)/automation/create/examples.tsx @@ -9,9 +9,6 @@ import { PresentationIcon, } from "lucide-react"; -export const RECEIPT_GROUP_ID = "RECEIPT"; -export const NEWSLETTER_GROUP_ID = "NEWSLETTER"; - export const examples: { title: string; description: string; @@ -28,8 +25,8 @@ export const examples: { name: "Forward receipts", conditions: [ { - type: RuleType.GROUP, - groupId: RECEIPT_GROUP_ID, + type: RuleType.AI, + instructions: "Forward receipts to alice@accountant.com.", }, ], actions: [ @@ -45,8 +42,8 @@ export const examples: { name: "Archive and label newsletters", conditions: [ { - type: RuleType.GROUP, - groupId: NEWSLETTER_GROUP_ID, + type: RuleType.AI, + instructions: "Archive newsletters and label them as 'Newsletter'.", }, ], actions: [ diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 6a4b8d30d1..bb5a473d7d 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -1,6 +1,5 @@ "use server"; -import type { gmail_v1 } from "@googleapis/gmail"; import { setUser } from "@sentry/nextjs"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import prisma, { isNotFoundError } from "@/utils/prisma"; @@ -13,7 +12,6 @@ import { } from "@/utils/ai/choose-rule/run-rules"; import { emailToContent, parseMessage } from "@/utils/mail"; import { getMessage, getMessages } from "@/utils/gmail/message"; -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"; @@ -41,7 +39,6 @@ import { labelVisibility } from "@/utils/gmail/constants"; import type { CreateOrUpdateRuleSchemaWithCategories } from "@/utils/ai/rule/create-rule-schema"; import { deleteRule, safeCreateRule, safeUpdateRule } from "@/utils/rule/rule"; import { getUserCategoriesForNames } from "@/utils/category.server"; -import { createNewsletterGroup, createReceiptGroup } from "@/utils/group/group"; const logger = createScopedLogger("ai-rule"); @@ -192,8 +189,6 @@ export const createAutomationAction = withActionInstrumentation< if (!userId) return { error: "Not logged in" }; if (!session.accessToken) return { error: "No access token" }; - const gmail = getGmailClient(session); - const user = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -216,99 +211,9 @@ export const createAutomationAction = withActionInstrumentation< if (!result) return { error: "AI error creating rule." }; - const groupIdResult = await getGroupId( - result, - userId, - user.email, - gmail, - session.accessToken, - ); - if (isActionError(groupIdResult)) return groupIdResult; - return await safeCreateRule(result, userId, groupIdResult.groupId, null); + return await safeCreateRule(result, userId, null); }); -async function getGroupId( - result: CreateOrUpdateRuleSchemaWithCategories, - userId: string, - userEmail: string, - gmail: gmail_v1.Gmail, - accessToken: string, -): Promise<{ - groupId?: string | null; - existingRuleId?: string | null; - error?: 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 createNewsletterGroup({ - gmail, - accessToken, - userId, - userEmail, - }); - if ("error" in result) { - return { error: result.error }; - } - 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 createReceiptGroup({ - gmail, - accessToken, - userId, - userEmail, - }); - if ("error" in result) { - return { error: result.error }; - } - 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 }) => { @@ -596,23 +501,6 @@ export const saveRulesPromptAction = withActionInstrumentation( ruleId: rule.ruleId, }); - const gmail = getGmailClient(session); - const groupIdResult = await getGroupId( - rule, - session.user.id, - user.email, - gmail, - session.accessToken, - ); - if (groupIdResult && "error" in 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 || [], @@ -620,13 +508,7 @@ export const saveRulesPromptAction = withActionInstrumentation( editRulesCount++; - await safeUpdateRule( - rule.ruleId, - rule, - session.user.id, - groupIdResult?.groupId, - categoryIds, - ); + await safeUpdateRule(rule.ruleId, rule, session.user.id, categoryIds); } } } else { @@ -653,27 +535,9 @@ export const saveRulesPromptAction = withActionInstrumentation( ruleId: rule.ruleId, }); - const gmail = getGmailClient(session); - const groupIdResult = await getGroupId( - rule, - session.user.id, - user.email, - gmail, - session.accessToken, - ); - 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?.groupId, rule.condition.categories?.categoryFilters || [], ); } diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index cd26198847..e52ebaca92 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -27,7 +27,6 @@ import { } from "@/utils/rule/prompt-file"; import { generatePromptOnDeleteRule } from "@/utils/ai/rule/generate-prompt-on-delete-rule"; import { sanitizeActionFields } from "@/utils/action-item"; -import { createGroup } from "@/utils/group/group"; import { deleteRule } from "@/utils/rule/rule"; export const createRuleAction = withActionInstrumentation( @@ -75,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 @@ -87,18 +85,6 @@ export const createRuleAction = withActionInstrumentation( include: { actions: true, categoryFilters: true, group: true }, }); - const shouldCreateGroup = body.conditions.some( - (c) => c.type === RuleType.GROUP && !c.groupId, - ); - - if (shouldCreateGroup) { - await createGroup({ - name: body.name, - userId: session.user.id, - ruleId: rule.id, - }); - } - await updatePromptFileOnRuleCreated(session.user.id, rule); revalidatePath("/automation"); @@ -160,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 diff --git a/apps/web/utils/actions/validation.ts b/apps/web/utils/actions/validation.ts index 68c45a2a33..5e57c9b025 100644 --- a/apps/web/utils/actions/validation.ts +++ b/apps/web/utils/actions/validation.ts @@ -14,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, @@ -78,7 +72,6 @@ const zodAction = z export const zodRuleType = z.enum([ RuleType.AI, RuleType.STATIC, - RuleType.GROUP, RuleType.CATEGORY, ]); @@ -86,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(), diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 5b7185ca80..8bce209ba9 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -180,16 +180,11 @@ ${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 }); @@ -202,7 +197,6 @@ ${senderCategory || "No category"} const rule = await safeCreateRule( { name, condition, actions }, user.id, - groupId, conditions.categories?.categoryFilters || [], ); 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/condition.ts b/apps/web/utils/condition.ts index 8b31f5434d..de0aec1d86 100644 --- a/apps/web/utils/condition.ts +++ b/apps/web/utils/condition.ts @@ -146,8 +146,6 @@ export const flattenConditions = ( acc.categoryFilterType = condition.categoryFilterType; acc.categoryFilters = condition.categoryFilters; break; - case RuleType.GROUP: - break; default: console.log(`Unhandled condition type: ${condition.type}`); // biome-ignore lint/correctness/noSwitchDeclarations: intentional exhaustive check 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 871212a14b..0000000000 --- a/apps/web/utils/group/group.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { gmail_v1 } from "@googleapis/gmail"; -import prisma, { isDuplicateError } from "@/utils/prisma"; -import { GroupItemType, type Prisma } from "@prisma/client"; -import { GroupName } from "@/utils/config"; -import { findNewsletters } from "@/utils/ai/group/find-newsletters"; -import { findReceipts } from "@/utils/ai/group/find-receipts"; - -export async function createGroup({ - userId, - ruleId, - name, - items, -}: { - userId: string; - ruleId?: string; - name: string; - items?: Prisma.GroupItemCreateManyInput[]; -}) { - try { - const group = await prisma.group.create({ - data: { - name, - userId, - items: { create: items }, - rule: ruleId ? { connect: { id: ruleId } } : undefined, - }, - }); - - return group; - } catch (error) { - if (isDuplicateError(error, "name")) - return { error: "Group with this name already exists" }; - - throw error; - } -} - -export async function createNewsletterGroup({ - gmail, - accessToken, - userId, - userEmail, -}: { - gmail: gmail_v1.Gmail; - accessToken: string; - userId: string; - userEmail: string; -}) { - const name = GroupName.NEWSLETTER; - const existingGroup = await prisma.group.findFirst({ - where: { name, userId }, - select: { id: true }, - }); - if (existingGroup) return { id: existingGroup.id }; - - const newsletters = await findNewsletters(gmail, accessToken, userEmail); - - const group = await createGroup({ - name, - userId, - items: newsletters.map((newsletter) => ({ - type: GroupItemType.FROM, - value: newsletter, - })), - }); - - return group; -} - -export async function createReceiptGroup({ - gmail, - accessToken, - userId, - userEmail, -}: { - gmail: gmail_v1.Gmail; - accessToken: string; - userId: string; - userEmail: string; -}) { - const name = GroupName.RECEIPT; - const existingGroup = await prisma.group.findFirst({ - where: { name, userId }, - select: { id: true }, - }); - if (existingGroup) return { id: existingGroup.id }; - - const receipts = await findReceipts(gmail, accessToken, userEmail); - - const group = await createGroup({ - name, - userId, - items: receipts, - }); - - return group; -} diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 5f2c6f441f..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( @@ -34,7 +33,6 @@ export async function safeCreateRule( const rule = await createRule({ result, userId, - groupId, categoryIds, }); return rule; @@ -44,7 +42,6 @@ export async function safeCreateRule( const rule = await createRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, userId, - groupId, categoryIds, }); return rule; @@ -66,11 +63,10 @@ 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")) { @@ -78,7 +74,6 @@ export async function safeUpdateRule( const rule = await createRule({ result: { ...result, name: `${result.name} - ${Date.now()}` }, userId, - groupId, categoryIds, }); return { id: rule.id }; @@ -99,12 +94,10 @@ export async function safeUpdateRule( async function createRule({ result, userId, - groupId, categoryIds, }: { result: CreateOrUpdateRuleSchemaWithCategories; userId: string; - groupId?: string | null; categoryIds?: string[] | null; }) { return prisma.rule.create({ @@ -123,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 ? { @@ -141,7 +133,6 @@ async function updateRule( ruleId: string, result: CreateOrUpdateRuleSchemaWithCategories, userId: string, - groupId?: string | null, categoryIds?: string[] | null, ) { return prisma.rule.update({ @@ -162,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 ? { From 4f0ec4cf3348c8bd13785a2293631ff53de306e2 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 18:41:35 +0200 Subject: [PATCH 19/28] Remove add to group for AI assistant. Switch naming to patterns instead of group. --- .../ai/assistant/process-user-request.ts | 211 +++++++++--------- 1 file changed, 103 insertions(+), 108 deletions(-) diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 8bce209ba9..5f439b2ae4 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,14 +66,18 @@ 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 @@ -82,12 +86,10 @@ When fixing rules: 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 +- 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. Always end by using the reply tool to explain what changes were made. @@ -191,8 +193,6 @@ ${senderCategory || "No category"} const conditions = condition as CreateRuleSchemaWithCategories["condition"]; - const groupId = null; - try { const rule = await safeCreateRule( { name, condition, actions }, @@ -281,91 +281,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( @@ -373,12 +374,12 @@ ${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 { @@ -387,7 +388,7 @@ ${senderCategory || "No category"} 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, @@ -396,7 +397,7 @@ ${senderCategory || "No category"} }); return { - error: "Failed to delete group item", + error: "Failed to delete pattern", message, }; } @@ -640,28 +641,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) ? ` @@ -671,6 +650,22 @@ function ruleToXML(rule: RuleWithRelations) { : "" } + + ${ + rule.group?.items?.length + ? ` + ${rule.group.items + .map( + (item) => + ` +${item.type} +${item.value} +`, + ) + .join("\n ")} + ` + : "" + } `; } @@ -688,7 +683,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; } From 2ae5a9ebfed5ce2f7715de963e6dc5540da759aa Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 18:51:04 +0200 Subject: [PATCH 20/28] update color and copy --- apps/web/app/(app)/automation/group/LearnedPatterns.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(app)/automation/group/LearnedPatterns.tsx b/apps/web/app/(app)/automation/group/LearnedPatterns.tsx index 7623ab8802..e09d606bc0 100644 --- a/apps/web/app/(app)/automation/group/LearnedPatterns.tsx +++ b/apps/web/app/(app)/automation/group/LearnedPatterns.tsx @@ -18,10 +18,12 @@ export function LearnedPatterns({ groupId }: { groupId: string }) { onOpenChange={setIsOpen} className="overflow-hidden rounded-lg border" > - +
    - - Learned Patterns (Group) + + + Learned Patterns (previously known as Groups) +
    From c9ea2a9bfc0117178ca8f0af9d6bd45e70e0f822 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 21:38:05 +0200 Subject: [PATCH 21/28] Adjust smart cat copy --- apps/web/app/(app)/smart-categories/page.tsx | 4 ++-- .../app/(app)/smart-categories/setup/SetUpCategories.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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. From 7d6befcac701d8bc11005ceb964defb4b4d84dee Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 21:43:59 +0200 Subject: [PATCH 22/28] Remove group from prompt to rules --- apps/web/utils/ai/rule/prompt-to-rules.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/utils/ai/rule/prompt-to-rules.ts b/apps/web/utils/ai/rule/prompt-to-rules.ts index 0b400afba5..19909114f5 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, @@ -127,7 +127,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 +187,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 +197,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": [ From e14da21c56fc6801c17a3aaa9686b8f4cae2715d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 21:53:37 +0200 Subject: [PATCH 23/28] adjust prompts --- apps/web/utils/ai/assistant/process-user-request.ts | 7 +++---- apps/web/utils/ai/rule/prompt-to-rules.ts | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 5f439b2ae4..c5647230d1 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -81,19 +81,18 @@ ${ 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 +- 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 -- 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. 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 = `${ diff --git a/apps/web/utils/ai/rule/prompt-to-rules.ts b/apps/web/utils/ai/rule/prompt-to-rules.ts index 19909114f5..d10d0970c9 100644 --- a/apps/web/utils/ai/rule/prompt-to-rules.ts +++ b/apps/web/utils/ai/rule/prompt-to-rules.ts @@ -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. From a86f1aca99b79afbb23d0b55e3611d20f353a240 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 22:54:02 +0200 Subject: [PATCH 24/28] Add more to assistant prompt --- apps/web/utils/ai/assistant/process-user-request.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index c5647230d1..92abc4a28d 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -90,6 +90,9 @@ Rule matching logic: Best practices: - For static conditions, use email patterns (e.g., '@company.com') when matching multiple addresses - 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 simple English in your reply so that the user understands what was changed. From 45e3902787e366c9da9338219c3786118fa3c81c Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:12:46 +0200 Subject: [PATCH 25/28] Fix tests --- apps/web/utils/ai/rule/create-prompt-from-rule.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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', ); }); }); From 0d3e1abc0e05a2de0ac512a46ba9f1e4f59ef2df Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:15:14 +0200 Subject: [PATCH 26/28] fix broken group logic --- apps/web/utils/ai/choose-rule/match-rules.ts | 2 +- apps/web/utils/ai/rule/create-prompt-from-rule.ts | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 4507a1c370..a60fc699fb 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -78,7 +78,7 @@ async function findPotentialMatchingRules({ // group - ignores conditional operator // if a match is found, return it - if (conditionTypes.GROUP) { + if (rule.groupId) { const { matchingItem, group } = await matchesGroupRule( rule, await getGroups(rule.userId), 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 a13351c59e..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(", "); From 580ba0bba4e9fabd9aa569de0c1ec390ac091c22 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:16:31 +0200 Subject: [PATCH 27/28] Fix tests --- apps/web/utils/ai/choose-rule/match-rules.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 () => { From 737128a6dda38077cc35e0267b9850c9c04c0e63 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:18:40 +0200 Subject: [PATCH 28/28] fix turbo json --- turbo.json | 3 +++ 1 file changed, 3 insertions(+) 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",